From 5d5a2fffe58561ea11e1ba4848f5fd46495c2916 Mon Sep 17 00:00:00 2001 From: Jesse Wilson Date: Wed, 20 Nov 2024 19:12:43 -0500 Subject: [PATCH] Guest-side API for new insets (#2463) Co-authored-by: Jesse Wilson --- .../api/android/redwood-compose.api | 2 + redwood-compose/api/jvm/redwood-compose.api | 2 + redwood-compose/api/redwood-compose.klib.api | 3 + .../cash/redwood/compose/UiConfiguration.kt | 65 +++++++++ .../redwood/compose/LocalViewInsetsTest.kt | 137 ++++++++++++++++++ 5 files changed, 209 insertions(+) create mode 100644 redwood-compose/src/commonTest/kotlin/app/cash/redwood/compose/LocalViewInsetsTest.kt diff --git a/redwood-compose/api/android/redwood-compose.api b/redwood-compose/api/android/redwood-compose.api index f091344ed4..d84f19f0b2 100644 --- a/redwood-compose/api/android/redwood-compose.api +++ b/redwood-compose/api/android/redwood-compose.api @@ -39,8 +39,10 @@ public final class app/cash/redwood/compose/RedwoodCompositionKt { } public final class app/cash/redwood/compose/UiConfigurationKt { + public static final fun ConsumeInsets (Lapp/cash/redwood/ui/Margin;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V public static final fun getCurrent (Lapp/cash/redwood/ui/UiConfiguration$Companion;Landroidx/compose/runtime/Composer;I)Lapp/cash/redwood/ui/UiConfiguration; public static final fun getLocalUiConfiguration ()Landroidx/compose/runtime/ProvidableCompositionLocal; + public static final fun getLocalViewInsets ()Landroidx/compose/runtime/ProvidableCompositionLocal; } public final class app/cash/redwood/compose/WidgetVersionKt { diff --git a/redwood-compose/api/jvm/redwood-compose.api b/redwood-compose/api/jvm/redwood-compose.api index 5c4dab8d6a..16adadebf1 100644 --- a/redwood-compose/api/jvm/redwood-compose.api +++ b/redwood-compose/api/jvm/redwood-compose.api @@ -17,8 +17,10 @@ public final class app/cash/redwood/compose/RedwoodCompositionKt { } public final class app/cash/redwood/compose/UiConfigurationKt { + public static final fun ConsumeInsets (Lapp/cash/redwood/ui/Margin;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V public static final fun getCurrent (Lapp/cash/redwood/ui/UiConfiguration$Companion;Landroidx/compose/runtime/Composer;I)Lapp/cash/redwood/ui/UiConfiguration; public static final fun getLocalUiConfiguration ()Landroidx/compose/runtime/ProvidableCompositionLocal; + public static final fun getLocalViewInsets ()Landroidx/compose/runtime/ProvidableCompositionLocal; } public final class app/cash/redwood/compose/WidgetVersionKt { diff --git a/redwood-compose/api/redwood-compose.klib.api b/redwood-compose/api/redwood-compose.klib.api index 984eed4805..262c9df5cf 100644 --- a/redwood-compose/api/redwood-compose.klib.api +++ b/redwood-compose/api/redwood-compose.klib.api @@ -16,6 +16,8 @@ final val app.cash.redwood.compose/LocalOnBackPressedDispatcher // app.cash.redw final fun (): androidx.compose.runtime/ProvidableCompositionLocal // app.cash.redwood.compose/LocalOnBackPressedDispatcher.|(){}[0] final val app.cash.redwood.compose/LocalUiConfiguration // app.cash.redwood.compose/LocalUiConfiguration|{}LocalUiConfiguration[0] final fun (): androidx.compose.runtime/ProvidableCompositionLocal // app.cash.redwood.compose/LocalUiConfiguration.|(){}[0] +final val app.cash.redwood.compose/LocalViewInsets // app.cash.redwood.compose/LocalViewInsets|{}LocalViewInsets[0] + final fun (): androidx.compose.runtime/ProvidableCompositionLocal // app.cash.redwood.compose/LocalViewInsets.|(){}[0] final val app.cash.redwood.compose/LocalWidgetVersion // app.cash.redwood.compose/LocalWidgetVersion|{}LocalWidgetVersion[0] final fun (): androidx.compose.runtime/ProvidableCompositionLocal // app.cash.redwood.compose/LocalWidgetVersion.|(){}[0] final val app.cash.redwood.compose/WidgetVersion // app.cash.redwood.compose/WidgetVersion|{}WidgetVersion[0] @@ -32,6 +34,7 @@ final val app.cash.redwood.compose/current // app.cash.redwood.compose/current|@ final fun <#A: kotlin/Any> app.cash.redwood.compose/RedwoodComposition(kotlinx.coroutines/CoroutineScope, app.cash.redwood.widget/RedwoodView<#A>, app.cash.redwood.widget/WidgetSystem<#A>, kotlin/Function0 = ...): app.cash.redwood.compose/RedwoodComposition // app.cash.redwood.compose/RedwoodComposition|RedwoodComposition(kotlinx.coroutines.CoroutineScope;app.cash.redwood.widget.RedwoodView<0:0>;app.cash.redwood.widget.WidgetSystem<0:0>;kotlin.Function0){0§}[0] final fun <#A: kotlin/Any> app.cash.redwood.compose/RedwoodComposition(kotlinx.coroutines/CoroutineScope, app.cash.redwood.widget/Widget.Children<#A>, app.cash.redwood.ui/OnBackPressedDispatcher, androidx.compose.runtime.saveable/SaveableStateRegistry?, kotlinx.coroutines.flow/StateFlow, app.cash.redwood.widget/WidgetSystem<#A>, kotlin/Function0 = ...): app.cash.redwood.compose/RedwoodComposition // app.cash.redwood.compose/RedwoodComposition|RedwoodComposition(kotlinx.coroutines.CoroutineScope;app.cash.redwood.widget.Widget.Children<0:0>;app.cash.redwood.ui.OnBackPressedDispatcher;androidx.compose.runtime.saveable.SaveableStateRegistry?;kotlinx.coroutines.flow.StateFlow;app.cash.redwood.widget.WidgetSystem<0:0>;kotlin.Function0){0§}[0] final fun app.cash.redwood.compose/BackHandler(kotlin/Boolean, kotlin/Function0, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // app.cash.redwood.compose/BackHandler|BackHandler(kotlin.Boolean;kotlin.Function0;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] +final fun app.cash.redwood.compose/ConsumeInsets(app.cash.redwood.ui/Margin?, kotlin/Function3, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // app.cash.redwood.compose/ConsumeInsets|ConsumeInsets(app.cash.redwood.ui.Margin?;kotlin.Function3;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] final fun app.cash.redwood.compose/app_cash_redwood_compose_ChildrenNode$stableprop_getter(): kotlin/Int // app.cash.redwood.compose/app_cash_redwood_compose_ChildrenNode$stableprop_getter|app_cash_redwood_compose_ChildrenNode$stableprop_getter(){}[0] final fun app.cash.redwood.compose/app_cash_redwood_compose_NodeApplier$stableprop_getter(): kotlin/Int // app.cash.redwood.compose/app_cash_redwood_compose_NodeApplier$stableprop_getter|app_cash_redwood_compose_NodeApplier$stableprop_getter(){}[0] final fun app.cash.redwood.compose/app_cash_redwood_compose_RedwoodComposeContent$stableprop_getter(): kotlin/Int // app.cash.redwood.compose/app_cash_redwood_compose_RedwoodComposeContent$stableprop_getter|app_cash_redwood_compose_RedwoodComposeContent$stableprop_getter(){}[0] diff --git a/redwood-compose/src/commonMain/kotlin/app/cash/redwood/compose/UiConfiguration.kt b/redwood-compose/src/commonMain/kotlin/app/cash/redwood/compose/UiConfiguration.kt index da280b33c5..a86e10951f 100644 --- a/redwood-compose/src/commonMain/kotlin/app/cash/redwood/compose/UiConfiguration.kt +++ b/redwood-compose/src/commonMain/kotlin/app/cash/redwood/compose/UiConfiguration.kt @@ -16,10 +16,14 @@ package app.cash.redwood.compose import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.compositionLocalWithComputedDefaultOf +import app.cash.redwood.ui.Margin import app.cash.redwood.ui.UiConfiguration +import app.cash.redwood.ui.dp /** * Provide various configurations of the UI. @@ -43,3 +47,64 @@ public val UiConfiguration.Companion.current: UiConfiguration @Composable @ReadOnlyComposable get() = LocalUiConfiguration.current + +/** + * The insets of the viewport that the composition is not safe for content because it is obscured + * by the system. + * + * To avoid double-consuming insets, consume them using [ConsumeInsets]. + */ +public val LocalViewInsets: ProvidableCompositionLocal = + compositionLocalWithComputedDefaultOf { + LocalUiConfiguration.currentValue.viewInsets + } + +/** + * Consume [LocalViewInsets] for the execution of [block]. This will consume all available insets + * unless [maximumValue] is set. + * + * The parameter to [block] is the actual insets that were consumed. + * + * Note that this function **does not** apply to the code preceding or following the caller, so it + * is possible for insets to be consumed multiple times. In layouts like `Box`, this may be the + * desired behavior. + */ +@Composable +public fun ConsumeInsets( + maximumValue: Margin? = null, + block: @Composable (Margin) -> Unit, +) { + val previous = LocalViewInsets.current + + val consumed: Margin + val updated: Margin + if (maximumValue != null) { + consumed = previous.coerceAtMost(maximumValue) + updated = previous - consumed + } else { + consumed = previous + updated = Margin.Zero + } + + CompositionLocalProvider(LocalViewInsets provides updated) { + block(consumed) + } +} + +private fun Margin.coerceAtMost(maximumValue: Margin): Margin { + return Margin( + start = start.value.coerceAtMost(maximumValue.start.value).dp, + end = end.value.coerceAtMost(maximumValue.end.value).dp, + top = top.value.coerceAtMost(maximumValue.top.value).dp, + bottom = bottom.value.coerceAtMost(maximumValue.bottom.value).dp, + ) +} + +private operator fun Margin.minus(other: Margin): Margin { + return Margin( + start = (start.value - other.start.value).dp, + end = (end.value - other.end.value).dp, + top = (top.value - other.top.value).dp, + bottom = (bottom.value - other.bottom.value).dp, + ) +} diff --git a/redwood-compose/src/commonTest/kotlin/app/cash/redwood/compose/LocalViewInsetsTest.kt b/redwood-compose/src/commonTest/kotlin/app/cash/redwood/compose/LocalViewInsetsTest.kt new file mode 100644 index 0000000000..1f436b612d --- /dev/null +++ b/redwood-compose/src/commonTest/kotlin/app/cash/redwood/compose/LocalViewInsetsTest.kt @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2024 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package app.cash.redwood.compose + +import app.cash.redwood.ui.Margin +import app.cash.redwood.ui.dp +import assertk.assertThat +import assertk.assertions.containsExactly +import com.example.redwood.testapp.compose.Text +import com.example.redwood.testapp.testing.TestSchemaTester +import com.example.redwood.testapp.testing.TextValue +import kotlin.test.Test +import kotlinx.coroutines.test.runTest + +class LocalViewInsetsTest { + @Test + fun localInsetsUpdated() = runTest { + TestSchemaTester { + uiConfigurations.value = uiConfigurations.value.copy( + viewInsets = Margin(top = 30.dp), + ) + + setContent { + Text("available=${LocalViewInsets.current}") + } + + assertThat(awaitSnapshot()).containsExactly( + TextValue(text = "available=${Margin(top = 30.0.dp)}"), + ) + + uiConfigurations.value = uiConfigurations.value.copy( + viewInsets = Margin(top = 20.dp), + ) + + assertThat(awaitSnapshot()).containsExactly( + TextValue(text = "available=${Margin(top = 20.0.dp)}"), + ) + } + } + + @Test + fun consumeInsets() = runTest { + TestSchemaTester { + uiConfigurations.value = uiConfigurations.value.copy( + viewInsets = Margin(top = 30.dp), + ) + + setContent { + Text("before available=${LocalViewInsets.current}") + + ConsumeInsets { consumed -> + Text("child consumed=$consumed") + Text("child available=${LocalViewInsets.current}") + } + + Text("after available=${LocalViewInsets.current}") + } + + assertThat(awaitSnapshot()).containsExactly( + TextValue(text = "before available=${Margin(top = 30.0.dp)}"), + TextValue(text = "child consumed=${Margin(top = 30.0.dp)}"), + TextValue(text = "child available=${Margin(top = 0.0.dp)}"), + TextValue(text = "after available=${Margin(top = 30.0.dp)}"), + ) + } + } + + @Test + fun consumeInsetsWithMax() = runTest { + TestSchemaTester { + uiConfigurations.value = uiConfigurations.value.copy( + viewInsets = Margin(top = 30.dp), + ) + + setContent { + Text("before available=${LocalViewInsets.current}") + + ConsumeInsets(maximumValue = Margin(top = 10.0.dp)) { consumed -> + Text("child consumed=$consumed") + Text("child available=${LocalViewInsets.current}") + } + + Text("after available=${LocalViewInsets.current}") + } + + assertThat(awaitSnapshot()).containsExactly( + TextValue(text = "before available=${Margin(top = 30.0.dp)}"), + TextValue(text = "child consumed=${Margin(top = 10.0.dp)}"), + TextValue(text = "child available=${Margin(top = 20.0.dp)}"), + TextValue(text = "after available=${Margin(top = 30.0.dp)}"), + ) + } + } + + @Test + fun consumeAllInsetsWithMaxAndUpdates() = runTest { + TestSchemaTester { + uiConfigurations.value = uiConfigurations.value.copy( + viewInsets = Margin(10.0.dp, 20.0.dp, 30.0.dp, 40.0.dp), + ) + + setContent { + ConsumeInsets(maximumValue = Margin(all = 15.dp)) { consumed -> + Text("consumed=$consumed") + Text("available=${LocalViewInsets.current}") + } + } + + assertThat(awaitSnapshot()).containsExactly( + TextValue(text = "consumed=${Margin(10.0.dp, 15.0.dp, 15.0.dp, 15.0.dp)}"), + TextValue(text = "available=${Margin(0.0.dp, 5.0.dp, 15.0.dp, 25.0.dp)}"), + ) + + uiConfigurations.value = uiConfigurations.value.copy( + viewInsets = Margin(11.0.dp, 21.0.dp, 31.0.dp, 41.0.dp), + ) + + assertThat(awaitSnapshot()).containsExactly( + TextValue(text = "consumed=${Margin(11.0.dp, 15.0.dp, 15.0.dp, 15.0.dp)}"), + TextValue(text = "available=${Margin(0.0.dp, 6.0.dp, 16.0.dp, 26.0.dp)}"), + ) + } + } +}