From 55e13f06fa31acc8ef7e19165ea3e0bacbf5ace4 Mon Sep 17 00:00:00 2001 From: Veyndan Stuart Date: Thu, 14 Sep 2023 14:17:03 +0200 Subject: [PATCH] Add `BackHandler` support on Android --- .../app/cash/redwood/compose/BackHandler.kt | 68 +++++++++++++++++++ .../redwood/compose/RedwoodComposition.kt | 24 ++++++- redwood-composeui/build.gradle | 5 ++ .../composeui/RedwoodContent.android.kt | 49 +++++++++++++ .../cash/redwood/composeui/RedwoodContent.kt | 7 ++ .../redwood/composeui/RedwoodContent.ios.kt | 34 ++++++++++ .../redwood/composeui/RedwoodContent.jvm.kt | 34 ++++++++++ .../redwood/composeui/RedwoodContent.macos.kt | 34 ++++++++++ .../compose/ProtocolRedwoodComposition.kt | 4 +- .../redwood/protocol/compose/ProtocolTest.kt | 10 +++ .../kotlin/app/cash/redwood/ui/Cancellable.kt | 20 ++++++ .../cash/redwood/ui/OnBackPressedCallback.kt | 22 ++++++ .../redwood/ui/OnBackPressedDispatcher.kt | 22 ++++++ .../redwood/testing/TestRedwoodComposition.kt | 10 +++ .../app/cash/redwood/testing/ViewTreesTest.kt | 10 +++ .../redwood/treehouse/treehouseCompose.kt | 48 +++++++++++++ redwood-treehouse-host-composeui/build.gradle | 5 ++ .../composeui/TreehouseContent.android.kt | 49 +++++++++++++ .../treehouse/composeui/TreehouseContent.kt | 7 ++ .../composeui/TreehouseContent.ios.kt | 34 ++++++++++ .../composeui/TreehouseContent.jvm.kt | 34 ++++++++++ .../composeui/TreehouseContent.macos.kt | 34 ++++++++++ redwood-treehouse-host/build.gradle | 2 +- .../cash/redwood/treehouse/TreehouseLayout.kt | 4 +- .../redwood/treehouse/TreehouseLayoutTest.kt | 24 ++++--- .../app/cash/redwood/treehouse/Content.kt | 6 +- .../redwood/treehouse/TreehouseAppContent.kt | 46 ++++++++++++- redwood-treehouse/api/zipline-api.toml | 36 ++++++++++ .../redwood/treehouse/CancellableService.kt | 24 +++++++ .../treehouse/OnBackPressedCallbackService.kt | 28 ++++++++ .../OnBackPressedDispatcherService.kt | 26 +++++++ .../redwood/treehouse/ZiplineTreehouseUi.kt | 11 +++ redwood-widget/build.gradle | 1 + .../app/cash/redwood/widget/RedwoodLayout.kt | 27 ++++++++ .../app/cash/redwood/widget/RedwoodView.kt | 2 + .../app/cash/redwood/widget/RedwoodUIView.kt | 11 +++ .../redwood/widget/RedwoodHTMLElementView.kt | 13 ++++ .../counter/android/views/MainActivity.kt | 2 +- .../android/views/EmojiSearchActivity.kt | 2 +- .../src/main/AndroidManifest.xml | 1 + .../testing/android/views/TestAppActivity.kt | 2 +- .../redwood/testing/presenter/TestApp.kt | 5 +- 42 files changed, 815 insertions(+), 22 deletions(-) create mode 100644 redwood-compose/src/commonMain/kotlin/app/cash/redwood/compose/BackHandler.kt create mode 100644 redwood-composeui/src/androidMain/kotlin/app/cash/redwood/composeui/RedwoodContent.android.kt create mode 100644 redwood-composeui/src/iosMain/kotlin/app/cash/redwood/composeui/RedwoodContent.ios.kt create mode 100644 redwood-composeui/src/jvmMain/kotlin/app/cash/redwood/composeui/RedwoodContent.jvm.kt create mode 100644 redwood-composeui/src/macosMain/kotlin/app/cash/redwood/composeui/RedwoodContent.macos.kt create mode 100644 redwood-runtime/src/commonMain/kotlin/app/cash/redwood/ui/Cancellable.kt create mode 100644 redwood-runtime/src/commonMain/kotlin/app/cash/redwood/ui/OnBackPressedCallback.kt create mode 100644 redwood-runtime/src/commonMain/kotlin/app/cash/redwood/ui/OnBackPressedDispatcher.kt create mode 100644 redwood-treehouse-host-composeui/src/androidMain/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.android.kt create mode 100644 redwood-treehouse-host-composeui/src/iosMain/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.ios.kt create mode 100644 redwood-treehouse-host-composeui/src/jvmMain/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.jvm.kt create mode 100644 redwood-treehouse-host-composeui/src/macosMain/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.macos.kt create mode 100644 redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/CancellableService.kt create mode 100644 redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/OnBackPressedCallbackService.kt create mode 100644 redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/OnBackPressedDispatcherService.kt diff --git a/redwood-compose/src/commonMain/kotlin/app/cash/redwood/compose/BackHandler.kt b/redwood-compose/src/commonMain/kotlin/app/cash/redwood/compose/BackHandler.kt new file mode 100644 index 0000000000..10104566eb --- /dev/null +++ b/redwood-compose/src/commonMain/kotlin/app/cash/redwood/compose/BackHandler.kt @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2023 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 androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import app.cash.redwood.ui.OnBackPressedCallback +import app.cash.redwood.ui.OnBackPressedDispatcher + +public val LocalOnBackPressedDispatcher: ProvidableCompositionLocal = + compositionLocalOf { + throw AssertionError("OnBackPressedDispatcher was not provided!") + } + +public val OnBackPressedDispatcher.Companion.current: OnBackPressedDispatcher + @Composable + @ReadOnlyComposable + get() = LocalOnBackPressedDispatcher.current + +// Multiplatform variant of +// https://github.com/androidx/androidx/blob/94ae1a9fb3ce778295e8cc724ae29f1231436bcb/activity/activity-compose/src/main/java/androidx/activity/compose/BackHandler.kt#L82 +@Composable +public fun BackHandler(enabled: Boolean = true, onBack: () -> Unit) { + // Safely update the current `onBack` lambda when a new one is provided. + val currentOnBack by rememberUpdatedState(onBack) + // Remember in Composition a back callback that calls the `onBack` lambda. + // Explicit return type necessary per https://youtrack.jetbrains.com/issue/KT-42073 + val backCallback: OnBackPressedCallback = remember { + object : OnBackPressedCallback(enabled) { + override fun handleOnBackPressed() { + currentOnBack() + } + } + } + // On every successful composition, update the callback with the `enabled` value. + SideEffect { + backCallback.isEnabled = enabled + } + val backDispatcher = OnBackPressedDispatcher.current + DisposableEffect(backDispatcher) { + // Add callback to the backDispatcher. + val cancellable = backDispatcher.addCallback(backCallback) + // When the effect leaves the Composition, remove the callback. + onDispose { + cancellable.cancel() + } + } +} diff --git a/redwood-compose/src/commonMain/kotlin/app/cash/redwood/compose/RedwoodComposition.kt b/redwood-compose/src/commonMain/kotlin/app/cash/redwood/compose/RedwoodComposition.kt index fed1de6575..ee81beb1eb 100644 --- a/redwood-compose/src/commonMain/kotlin/app/cash/redwood/compose/RedwoodComposition.kt +++ b/redwood-compose/src/commonMain/kotlin/app/cash/redwood/compose/RedwoodComposition.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.currentComposer import androidx.compose.runtime.getValue import androidx.compose.runtime.snapshots.Snapshot import app.cash.redwood.RedwoodCodegenApi +import app.cash.redwood.ui.OnBackPressedDispatcher import app.cash.redwood.ui.UiConfiguration import app.cash.redwood.widget.RedwoodView import app.cash.redwood.widget.Widget @@ -55,7 +56,14 @@ public fun RedwoodComposition( ): RedwoodComposition { view.reset() - return RedwoodComposition(scope, view.children, view.uiConfiguration, provider, onEndChanges) + return RedwoodComposition( + scope, + view.children, + view.onBackPressedDispatcher, + view.uiConfiguration, + provider, + onEndChanges, + ) } /** @@ -65,15 +73,22 @@ public fun RedwoodComposition( public fun RedwoodComposition( scope: CoroutineScope, container: Widget.Children, + onBackPressedDispatcher: OnBackPressedDispatcher, uiConfigurations: StateFlow, provider: Widget.Provider, onEndChanges: () -> Unit = {}, ): RedwoodComposition { - return WidgetRedwoodComposition(scope, uiConfigurations, NodeApplier(provider, container, onEndChanges)) + return WidgetRedwoodComposition( + scope, + onBackPressedDispatcher, + uiConfigurations, + NodeApplier(provider, container, onEndChanges), + ) } private class WidgetRedwoodComposition( private val scope: CoroutineScope, + private val onBackPressedDispatcher: OnBackPressedDispatcher, private val uiConfigurations: StateFlow, applier: NodeApplier, ) : RedwoodComposition { @@ -100,7 +115,10 @@ private class WidgetRedwoodComposition( override fun setContent(content: @Composable () -> Unit) { composition.setContent { val uiConfiguration by uiConfigurations.collectAsState() - CompositionLocalProvider(LocalUiConfiguration provides uiConfiguration) { + CompositionLocalProvider( + LocalOnBackPressedDispatcher provides onBackPressedDispatcher, + LocalUiConfiguration provides uiConfiguration, + ) { content() } } diff --git a/redwood-composeui/build.gradle b/redwood-composeui/build.gradle index 7d8b46cbe4..858bdb291e 100644 --- a/redwood-composeui/build.gradle +++ b/redwood-composeui/build.gradle @@ -19,6 +19,11 @@ kotlin { implementation projects.redwoodWidgetCompose } } + androidMain { + dependencies { + implementation libs.androidx.activity.compose + } + } } } diff --git a/redwood-composeui/src/androidMain/kotlin/app/cash/redwood/composeui/RedwoodContent.android.kt b/redwood-composeui/src/androidMain/kotlin/app/cash/redwood/composeui/RedwoodContent.android.kt new file mode 100644 index 0000000000..3804d55a28 --- /dev/null +++ b/redwood-composeui/src/androidMain/kotlin/app/cash/redwood/composeui/RedwoodContent.android.kt @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2023 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.composeui + +import androidx.activity.OnBackPressedCallback as AndroidOnBackPressedCallback +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import app.cash.redwood.ui.Cancellable +import app.cash.redwood.ui.OnBackPressedCallback as RedwoodOnBackPressedCallback +import app.cash.redwood.ui.OnBackPressedDispatcher as RedwoodOnBackPressedDispatcher + +@Composable +internal actual fun platformOnBackPressedDispatcher(): RedwoodOnBackPressedDispatcher { + val delegate = LocalOnBackPressedDispatcherOwner.current!!.onBackPressedDispatcher + return remember(delegate) { + object : RedwoodOnBackPressedDispatcher { + override fun addCallback(onBackPressedCallback: RedwoodOnBackPressedCallback): Cancellable { + val androidOnBackPressedCallback = onBackPressedCallback.toAndroid() + delegate.addCallback(androidOnBackPressedCallback) + return object : Cancellable { + override fun cancel() { + androidOnBackPressedCallback.remove() + } + } + } + } + } +} + +private fun RedwoodOnBackPressedCallback.toAndroid(): AndroidOnBackPressedCallback = + object : AndroidOnBackPressedCallback(this@toAndroid.isEnabled) { + override fun handleOnBackPressed() { + this@toAndroid.handleOnBackPressed() + } + } diff --git a/redwood-composeui/src/commonMain/kotlin/app/cash/redwood/composeui/RedwoodContent.kt b/redwood-composeui/src/commonMain/kotlin/app/cash/redwood/composeui/RedwoodContent.kt index 145892d6f3..543c0b0966 100644 --- a/redwood-composeui/src/commonMain/kotlin/app/cash/redwood/composeui/RedwoodContent.kt +++ b/redwood-composeui/src/commonMain/kotlin/app/cash/redwood/composeui/RedwoodContent.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import app.cash.redwood.compose.RedwoodComposition import app.cash.redwood.ui.Density +import app.cash.redwood.ui.OnBackPressedDispatcher import app.cash.redwood.ui.Size import app.cash.redwood.ui.UiConfiguration import app.cash.redwood.ui.dp as redwoodDp @@ -45,6 +46,8 @@ public fun RedwoodContent( ) { val scope = rememberCoroutineScope() + val onBackPressedDispatcher = platformOnBackPressedDispatcher() + var viewportSize by remember { mutableStateOf(Size.Zero) } val density = LocalDensity.current val uiConfiguration = UiConfiguration( @@ -57,6 +60,7 @@ public fun RedwoodContent( val redwoodView = remember { object : RedwoodView<@Composable () -> Unit> { override val children = ComposeWidgetChildren() + override val onBackPressedDispatcher = onBackPressedDispatcher override val uiConfiguration = MutableStateFlow(uiConfiguration) override fun reset() { children.remove(0, children.widgets.size) @@ -85,3 +89,6 @@ public fun RedwoodContent( redwoodView.children.render() } } + +@Composable +internal expect fun platformOnBackPressedDispatcher(): OnBackPressedDispatcher diff --git a/redwood-composeui/src/iosMain/kotlin/app/cash/redwood/composeui/RedwoodContent.ios.kt b/redwood-composeui/src/iosMain/kotlin/app/cash/redwood/composeui/RedwoodContent.ios.kt new file mode 100644 index 0000000000..c1f47d0482 --- /dev/null +++ b/redwood-composeui/src/iosMain/kotlin/app/cash/redwood/composeui/RedwoodContent.ios.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2023 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.composeui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import app.cash.redwood.ui.Cancellable +import app.cash.redwood.ui.OnBackPressedCallback +import app.cash.redwood.ui.OnBackPressedDispatcher + +@Composable +internal actual fun platformOnBackPressedDispatcher(): OnBackPressedDispatcher { + return remember { + object : OnBackPressedDispatcher { + override fun addCallback(onBackPressedCallback: OnBackPressedCallback): Cancellable = + object : Cancellable { + override fun cancel() = Unit + } + } + } +} diff --git a/redwood-composeui/src/jvmMain/kotlin/app/cash/redwood/composeui/RedwoodContent.jvm.kt b/redwood-composeui/src/jvmMain/kotlin/app/cash/redwood/composeui/RedwoodContent.jvm.kt new file mode 100644 index 0000000000..c1f47d0482 --- /dev/null +++ b/redwood-composeui/src/jvmMain/kotlin/app/cash/redwood/composeui/RedwoodContent.jvm.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2023 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.composeui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import app.cash.redwood.ui.Cancellable +import app.cash.redwood.ui.OnBackPressedCallback +import app.cash.redwood.ui.OnBackPressedDispatcher + +@Composable +internal actual fun platformOnBackPressedDispatcher(): OnBackPressedDispatcher { + return remember { + object : OnBackPressedDispatcher { + override fun addCallback(onBackPressedCallback: OnBackPressedCallback): Cancellable = + object : Cancellable { + override fun cancel() = Unit + } + } + } +} diff --git a/redwood-composeui/src/macosMain/kotlin/app/cash/redwood/composeui/RedwoodContent.macos.kt b/redwood-composeui/src/macosMain/kotlin/app/cash/redwood/composeui/RedwoodContent.macos.kt new file mode 100644 index 0000000000..c1f47d0482 --- /dev/null +++ b/redwood-composeui/src/macosMain/kotlin/app/cash/redwood/composeui/RedwoodContent.macos.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2023 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.composeui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import app.cash.redwood.ui.Cancellable +import app.cash.redwood.ui.OnBackPressedCallback +import app.cash.redwood.ui.OnBackPressedDispatcher + +@Composable +internal actual fun platformOnBackPressedDispatcher(): OnBackPressedDispatcher { + return remember { + object : OnBackPressedDispatcher { + override fun addCallback(onBackPressedCallback: OnBackPressedCallback): Cancellable = + object : Cancellable { + override fun cancel() = Unit + } + } + } +} diff --git a/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/ProtocolRedwoodComposition.kt b/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/ProtocolRedwoodComposition.kt index b71007a3e0..2e4dc951ca 100644 --- a/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/ProtocolRedwoodComposition.kt +++ b/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/ProtocolRedwoodComposition.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.MonotonicFrameClock import app.cash.redwood.compose.LocalWidgetVersion import app.cash.redwood.compose.RedwoodComposition import app.cash.redwood.protocol.ChangesSink +import app.cash.redwood.ui.OnBackPressedDispatcher import app.cash.redwood.ui.UiConfiguration import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow @@ -34,9 +35,10 @@ public fun ProtocolRedwoodComposition( bridge: ProtocolBridge, changesSink: ChangesSink, widgetVersion: UInt, + onBackPressedDispatcher: OnBackPressedDispatcher, uiConfigurations: StateFlow, ): RedwoodComposition { - val composition = RedwoodComposition(scope, bridge.root, uiConfigurations, bridge.provider) { + val composition = RedwoodComposition(scope, bridge.root, onBackPressedDispatcher, uiConfigurations, bridge.provider) { bridge.getChangesOrNull()?.let(changesSink::sendChanges) } return ProtocolRedwoodComposition(composition, widgetVersion) diff --git a/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/ProtocolTest.kt b/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/ProtocolTest.kt index 5b62216200..097dcaee17 100644 --- a/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/ProtocolTest.kt +++ b/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/ProtocolTest.kt @@ -32,6 +32,9 @@ import app.cash.redwood.protocol.PropertyChange import app.cash.redwood.protocol.PropertyTag import app.cash.redwood.protocol.WidgetTag import app.cash.redwood.testing.TestRedwoodComposition +import app.cash.redwood.ui.Cancellable +import app.cash.redwood.ui.OnBackPressedCallback +import app.cash.redwood.ui.OnBackPressedDispatcher import app.cash.redwood.ui.UiConfiguration import assertk.assertThat import assertk.assertions.isEqualTo @@ -58,6 +61,13 @@ class ProtocolTest { bridge = bridge, changesSink = ::error, widgetVersion = 22U, + onBackPressedDispatcher = object : OnBackPressedDispatcher { + override fun addCallback(onBackPressedCallback: OnBackPressedCallback): Cancellable { + return object : Cancellable { + override fun cancel() = Unit + } + } + }, uiConfigurations = MutableStateFlow(UiConfiguration()), ) diff --git a/redwood-runtime/src/commonMain/kotlin/app/cash/redwood/ui/Cancellable.kt b/redwood-runtime/src/commonMain/kotlin/app/cash/redwood/ui/Cancellable.kt new file mode 100644 index 0000000000..09cf44b5f1 --- /dev/null +++ b/redwood-runtime/src/commonMain/kotlin/app/cash/redwood/ui/Cancellable.kt @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2023 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.ui + +public interface Cancellable { + public fun cancel() +} diff --git a/redwood-runtime/src/commonMain/kotlin/app/cash/redwood/ui/OnBackPressedCallback.kt b/redwood-runtime/src/commonMain/kotlin/app/cash/redwood/ui/OnBackPressedCallback.kt new file mode 100644 index 0000000000..4526d170ad --- /dev/null +++ b/redwood-runtime/src/commonMain/kotlin/app/cash/redwood/ui/OnBackPressedCallback.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2023 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.ui + +public abstract class OnBackPressedCallback(enabled: Boolean) { + public var isEnabled: Boolean = enabled + + public abstract fun handleOnBackPressed() +} diff --git a/redwood-runtime/src/commonMain/kotlin/app/cash/redwood/ui/OnBackPressedDispatcher.kt b/redwood-runtime/src/commonMain/kotlin/app/cash/redwood/ui/OnBackPressedDispatcher.kt new file mode 100644 index 0000000000..53eacc401c --- /dev/null +++ b/redwood-runtime/src/commonMain/kotlin/app/cash/redwood/ui/OnBackPressedDispatcher.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2023 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.ui + +public interface OnBackPressedDispatcher { + public fun addCallback(onBackPressedCallback: OnBackPressedCallback): Cancellable + + public companion object +} diff --git a/redwood-testing/src/commonMain/kotlin/app/cash/redwood/testing/TestRedwoodComposition.kt b/redwood-testing/src/commonMain/kotlin/app/cash/redwood/testing/TestRedwoodComposition.kt index d157175c35..93c5a114e9 100644 --- a/redwood-testing/src/commonMain/kotlin/app/cash/redwood/testing/TestRedwoodComposition.kt +++ b/redwood-testing/src/commonMain/kotlin/app/cash/redwood/testing/TestRedwoodComposition.kt @@ -18,6 +18,9 @@ package app.cash.redwood.testing import androidx.compose.runtime.BroadcastFrameClock import androidx.compose.runtime.Composable import app.cash.redwood.compose.RedwoodComposition +import app.cash.redwood.ui.Cancellable +import app.cash.redwood.ui.OnBackPressedCallback +import app.cash.redwood.ui.OnBackPressedDispatcher import app.cash.redwood.ui.UiConfiguration import app.cash.redwood.widget.Widget import kotlin.time.Duration @@ -73,6 +76,13 @@ private class RealTestRedwoodComposition( private val composition = RedwoodComposition( scope = scope + clock, container = container, + onBackPressedDispatcher = object : OnBackPressedDispatcher { + override fun addCallback(onBackPressedCallback: OnBackPressedCallback): Cancellable { + return object : Cancellable { + override fun cancel() = Unit + } + } + }, uiConfigurations = uiConfigurations, provider = provider, onEndChanges = { diff --git a/redwood-testing/src/commonTest/kotlin/app/cash/redwood/testing/ViewTreesTest.kt b/redwood-testing/src/commonTest/kotlin/app/cash/redwood/testing/ViewTreesTest.kt index 83aa7d0ae0..aaf74f3c27 100644 --- a/redwood-testing/src/commonTest/kotlin/app/cash/redwood/testing/ViewTreesTest.kt +++ b/redwood-testing/src/commonTest/kotlin/app/cash/redwood/testing/ViewTreesTest.kt @@ -31,6 +31,9 @@ import app.cash.redwood.protocol.PropertyTag import app.cash.redwood.protocol.WidgetTag import app.cash.redwood.protocol.compose.ProtocolRedwoodComposition import app.cash.redwood.protocol.widget.ProtocolBridge +import app.cash.redwood.ui.Cancellable +import app.cash.redwood.ui.OnBackPressedCallback +import app.cash.redwood.ui.OnBackPressedDispatcher import app.cash.redwood.ui.UiConfiguration import app.cash.redwood.widget.MutableListChildren import assertk.assertThat @@ -110,6 +113,13 @@ class ViewTreesTest { bridge = TestSchemaProtocolBridge.create(), changesSink = { protocolChanges = it }, widgetVersion = UInt.MAX_VALUE, + onBackPressedDispatcher = object : OnBackPressedDispatcher { + override fun addCallback(onBackPressedCallback: OnBackPressedCallback): Cancellable { + return object : Cancellable { + override fun cancel() = Unit + } + } + }, uiConfigurations = MutableStateFlow(UiConfiguration()), ) composition.setContent(content) diff --git a/redwood-treehouse-guest/src/commonMain/kotlin/app/cash/redwood/treehouse/treehouseCompose.kt b/redwood-treehouse-guest/src/commonMain/kotlin/app/cash/redwood/treehouse/treehouseCompose.kt index a6a792da0b..0e9ec91a45 100644 --- a/redwood-treehouse-guest/src/commonMain/kotlin/app/cash/redwood/treehouse/treehouseCompose.kt +++ b/redwood-treehouse-guest/src/commonMain/kotlin/app/cash/redwood/treehouse/treehouseCompose.kt @@ -20,6 +20,9 @@ import app.cash.redwood.compose.RedwoodComposition import app.cash.redwood.protocol.EventSink import app.cash.redwood.protocol.compose.ProtocolBridge import app.cash.redwood.protocol.compose.ProtocolRedwoodComposition +import app.cash.redwood.ui.Cancellable +import app.cash.redwood.ui.OnBackPressedCallback +import app.cash.redwood.ui.OnBackPressedDispatcher import app.cash.redwood.ui.UiConfiguration import app.cash.zipline.ZiplineScope import app.cash.zipline.ZiplineScoped @@ -54,16 +57,39 @@ private class RedwoodZiplineTreehouseUi( private lateinit var saveableStateRegistry: SaveableStateRegistry + @Deprecated( + "Use `start` method that takes in an `OnBackPressedDispatcherService` instead.", + ReplaceWith("start(changesSink, TODO(), uiConfigurations, stateSnapshot)"), + ) override fun start( changesSink: ChangesSinkService, uiConfigurations: StateFlow, stateSnapshot: StateSnapshot?, + ) { + val onBackPressedDispatcher = object : OnBackPressedDispatcherService { + override fun addCallback( + onBackPressedCallback: OnBackPressedCallbackService, + ): CancellableService { + return object : CancellableService { + override fun cancel() = Unit + } + } + } + start(changesSink, onBackPressedDispatcher, uiConfigurations, stateSnapshot) + } + + override fun start( + changesSink: ChangesSinkService, + onBackPressedDispatcher: OnBackPressedDispatcherService, + uiConfigurations: StateFlow, + stateSnapshot: StateSnapshot?, ) { val composition = ProtocolRedwoodComposition( scope = appLifecycle.coroutineScope + appLifecycle.frameClock, bridge = bridge, widgetVersion = appLifecycle.widgetVersion, changesSink = changesSink, + onBackPressedDispatcher = onBackPressedDispatcher.asNonService(), uiConfigurations = uiConfigurations, ) this.composition = composition @@ -94,3 +120,25 @@ private class RedwoodZiplineTreehouseUi( scope.close() } } + +private fun OnBackPressedDispatcherService.asNonService(): OnBackPressedDispatcher { + return object : OnBackPressedDispatcher { + override fun addCallback(onBackPressedCallback: OnBackPressedCallback): Cancellable { + return this@asNonService.addCallback(onBackPressedCallback.asService()) + } + } +} + +private fun OnBackPressedCallback.asService(): OnBackPressedCallbackService { + return object : OnBackPressedCallbackService { + override var isEnabled: Boolean + get() = this@asService.isEnabled + set(value) { + this@asService.isEnabled = value + } + + override fun handleOnBackPressed() { + this@asService.handleOnBackPressed() + } + } +} diff --git a/redwood-treehouse-host-composeui/build.gradle b/redwood-treehouse-host-composeui/build.gradle index 82c4309c29..ee07096668 100644 --- a/redwood-treehouse-host-composeui/build.gradle +++ b/redwood-treehouse-host-composeui/build.gradle @@ -25,6 +25,11 @@ kotlin { implementation projects.redwoodWidgetCompose } } + androidMain { + dependencies { + implementation libs.androidx.activity.compose + } + } } } diff --git a/redwood-treehouse-host-composeui/src/androidMain/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.android.kt b/redwood-treehouse-host-composeui/src/androidMain/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.android.kt new file mode 100644 index 0000000000..f97857b813 --- /dev/null +++ b/redwood-treehouse-host-composeui/src/androidMain/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.android.kt @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2023 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.treehouse.composeui + +import androidx.activity.OnBackPressedCallback as AndroidOnBackPressedCallback +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import app.cash.redwood.ui.Cancellable +import app.cash.redwood.ui.OnBackPressedCallback as RedwoodOnBackPressedCallback +import app.cash.redwood.ui.OnBackPressedDispatcher as RedwoodOnBackPressedDispatcher + +@Composable +internal actual fun platformOnBackPressedDispatcher(): RedwoodOnBackPressedDispatcher { + val delegate = LocalOnBackPressedDispatcherOwner.current!!.onBackPressedDispatcher + return remember(delegate) { + object : RedwoodOnBackPressedDispatcher { + override fun addCallback(onBackPressedCallback: RedwoodOnBackPressedCallback): Cancellable { + val androidOnBackPressedCallback = onBackPressedCallback.toAndroid() + delegate.addCallback(androidOnBackPressedCallback) + return object : Cancellable { + override fun cancel() { + androidOnBackPressedCallback.remove() + } + } + } + } + } +} + +private fun RedwoodOnBackPressedCallback.toAndroid(): AndroidOnBackPressedCallback = + object : AndroidOnBackPressedCallback(this@toAndroid.isEnabled) { + override fun handleOnBackPressed() { + this@toAndroid.handleOnBackPressed() + } + } diff --git a/redwood-treehouse-host-composeui/src/commonMain/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.kt b/redwood-treehouse-host-composeui/src/commonMain/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.kt index 91fc39ad1d..15f0ec98f3 100644 --- a/redwood-treehouse-host-composeui/src/commonMain/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.kt +++ b/redwood-treehouse-host-composeui/src/commonMain/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.kt @@ -38,6 +38,7 @@ import app.cash.redwood.treehouse.TreehouseView.ReadyForContentChangeListener import app.cash.redwood.treehouse.TreehouseView.WidgetSystem import app.cash.redwood.treehouse.bindWhenReady import app.cash.redwood.ui.Density +import app.cash.redwood.ui.OnBackPressedDispatcher import app.cash.redwood.ui.Size import app.cash.redwood.ui.UiConfiguration import app.cash.redwood.ui.dp as redwoodDp @@ -51,6 +52,8 @@ public fun TreehouseContent( codeListener: CodeListener = CodeListener(), contentSource: TreehouseContentSource, ) { + val onBackPressedDispatcher = platformOnBackPressedDispatcher() + var viewportSize by remember { mutableStateOf(Size.Zero) } val density = LocalDensity.current val uiConfiguration = UiConfiguration( @@ -63,6 +66,7 @@ public fun TreehouseContent( val treehouseView = remember(widgetSystem) { object : TreehouseView<@Composable () -> Unit> { override val children = ComposeWidgetChildren() + override val onBackPressedDispatcher = onBackPressedDispatcher override val uiConfiguration = MutableStateFlow(uiConfiguration) override val widgetSystem = widgetSystem override val readyForContent = true @@ -92,3 +96,6 @@ public fun TreehouseContent( treehouseView.children.render() } } + +@Composable +internal expect fun platformOnBackPressedDispatcher(): OnBackPressedDispatcher diff --git a/redwood-treehouse-host-composeui/src/iosMain/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.ios.kt b/redwood-treehouse-host-composeui/src/iosMain/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.ios.kt new file mode 100644 index 0000000000..da59bae1c4 --- /dev/null +++ b/redwood-treehouse-host-composeui/src/iosMain/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.ios.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2023 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.treehouse.composeui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import app.cash.redwood.ui.Cancellable +import app.cash.redwood.ui.OnBackPressedCallback +import app.cash.redwood.ui.OnBackPressedDispatcher + +@Composable +internal actual fun platformOnBackPressedDispatcher(): OnBackPressedDispatcher { + return remember { + object : OnBackPressedDispatcher { + override fun addCallback(onBackPressedCallback: OnBackPressedCallback): Cancellable = + object : Cancellable { + override fun cancel() = Unit + } + } + } +} diff --git a/redwood-treehouse-host-composeui/src/jvmMain/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.jvm.kt b/redwood-treehouse-host-composeui/src/jvmMain/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.jvm.kt new file mode 100644 index 0000000000..da59bae1c4 --- /dev/null +++ b/redwood-treehouse-host-composeui/src/jvmMain/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.jvm.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2023 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.treehouse.composeui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import app.cash.redwood.ui.Cancellable +import app.cash.redwood.ui.OnBackPressedCallback +import app.cash.redwood.ui.OnBackPressedDispatcher + +@Composable +internal actual fun platformOnBackPressedDispatcher(): OnBackPressedDispatcher { + return remember { + object : OnBackPressedDispatcher { + override fun addCallback(onBackPressedCallback: OnBackPressedCallback): Cancellable = + object : Cancellable { + override fun cancel() = Unit + } + } + } +} diff --git a/redwood-treehouse-host-composeui/src/macosMain/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.macos.kt b/redwood-treehouse-host-composeui/src/macosMain/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.macos.kt new file mode 100644 index 0000000000..da59bae1c4 --- /dev/null +++ b/redwood-treehouse-host-composeui/src/macosMain/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.macos.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2023 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.treehouse.composeui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import app.cash.redwood.ui.Cancellable +import app.cash.redwood.ui.OnBackPressedCallback +import app.cash.redwood.ui.OnBackPressedDispatcher + +@Composable +internal actual fun platformOnBackPressedDispatcher(): OnBackPressedDispatcher { + return remember { + object : OnBackPressedDispatcher { + override fun addCallback(onBackPressedCallback: OnBackPressedCallback): Cancellable = + object : Cancellable { + override fun cancel() = Unit + } + } + } +} diff --git a/redwood-treehouse-host/build.gradle b/redwood-treehouse-host/build.gradle index 58415c57ed..f941dab89a 100644 --- a/redwood-treehouse-host/build.gradle +++ b/redwood-treehouse-host/build.gradle @@ -29,7 +29,7 @@ kotlin { androidMain { dependencies { api libs.okHttp - implementation libs.androidx.core + implementation libs.androidx.activity } } diff --git a/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/TreehouseLayout.kt b/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/TreehouseLayout.kt index 7f2f79b8ae..80b8f9a915 100644 --- a/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/TreehouseLayout.kt +++ b/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/TreehouseLayout.kt @@ -21,6 +21,7 @@ import android.os.Parcel import android.os.Parcelable import android.view.View import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import androidx.activity.OnBackPressedDispatcher as AndroidOnBackPressedDispatcher import app.cash.redwood.treehouse.TreehouseView.ReadyForContentChangeListener import app.cash.redwood.treehouse.TreehouseView.WidgetSystem import app.cash.redwood.widget.RedwoodLayout @@ -36,7 +37,8 @@ public typealias TreehouseWidgetView = TreehouseLayout public class TreehouseLayout( context: Context, override val widgetSystem: WidgetSystem, -) : RedwoodLayout(context), TreehouseView { + androidOnBackPressedDispatcher: AndroidOnBackPressedDispatcher, +) : RedwoodLayout(context, androidOnBackPressedDispatcher), TreehouseView { override var readyForContentChangeListener: ReadyForContentChangeListener? = null set(value) { check(value == null || field == null) { "View already bound to a listener" } diff --git a/redwood-treehouse-host/src/androidUnitTest/kotlin/app/cash/redwood/treehouse/TreehouseLayoutTest.kt b/redwood-treehouse-host/src/androidUnitTest/kotlin/app/cash/redwood/treehouse/TreehouseLayoutTest.kt index 45d99956b6..20b36ee8c7 100644 --- a/redwood-treehouse-host/src/androidUnitTest/kotlin/app/cash/redwood/treehouse/TreehouseLayoutTest.kt +++ b/redwood-treehouse-host/src/androidUnitTest/kotlin/app/cash/redwood/treehouse/TreehouseLayoutTest.kt @@ -15,12 +15,12 @@ */ package app.cash.redwood.treehouse -import android.app.Activity import android.content.res.Configuration import android.content.res.Configuration.UI_MODE_NIGHT_MASK import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.view.View import android.view.ViewGroup +import androidx.activity.ComponentActivity import androidx.core.graphics.Insets import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat @@ -50,7 +50,8 @@ class TreehouseLayoutTest { private val context = RuntimeEnvironment.getApplication()!! @Test fun widgetsAddChildViews() { - val layout = TreehouseLayout(context, throwingWidgetSystem) + val activity = Robolectric.buildActivity(ComponentActivity::class.java).resume().visible().get() + val layout = TreehouseLayout(context, throwingWidgetSystem, activity.onBackPressedDispatcher) val view = View(context) layout.children.insert(0, viewWidget(view)) @@ -59,9 +60,9 @@ class TreehouseLayoutTest { } @Test fun attachAndDetachSendsStateChange() { - val activity = Robolectric.buildActivity(Activity::class.java).resume().visible().get() + val activity = Robolectric.buildActivity(ComponentActivity::class.java).resume().visible().get() val parent = activity.findViewById(android.R.id.content) - val layout = TreehouseLayout(context, throwingWidgetSystem) + val layout = TreehouseLayout(context, throwingWidgetSystem, activity.onBackPressedDispatcher) val listener = CountingReadyForContentChangeListener() layout.readyForContentChangeListener = listener @@ -75,7 +76,8 @@ class TreehouseLayoutTest { } @Test fun resetClearsUntrackedChildren() { - val layout = TreehouseLayout(context, throwingWidgetSystem) + val activity = Robolectric.buildActivity(ComponentActivity::class.java).resume().visible().get() + val layout = TreehouseLayout(context, throwingWidgetSystem, activity.onBackPressedDispatcher) layout.addView(View(context)) assertThat(layout.childCount).isEqualTo(1) @@ -85,7 +87,8 @@ class TreehouseLayoutTest { } @Test fun resetClearsTrackedWidgets() { - val layout = TreehouseLayout(context, throwingWidgetSystem) + val activity = Robolectric.buildActivity(ComponentActivity::class.java).resume().visible().get() + val layout = TreehouseLayout(context, throwingWidgetSystem, activity.onBackPressedDispatcher) // Needed to access internal state which cannot be reasonably observed through the public API. val children = layout.children as ViewGroupChildren @@ -101,12 +104,14 @@ class TreehouseLayoutTest { val newConfig = Configuration(context.resources.configuration) newConfig.uiMode = (newConfig.uiMode and UI_MODE_NIGHT_MASK.inv()) or UI_MODE_NIGHT_YES val newContext = context.createConfigurationContext(newConfig) // Needs API 26. - val layout = TreehouseLayout(newContext, throwingWidgetSystem) + val activity = Robolectric.buildActivity(ComponentActivity::class.java).resume().visible().get() + val layout = TreehouseLayout(newContext, throwingWidgetSystem, activity.onBackPressedDispatcher) assertThat(layout.uiConfiguration.value).isEqualTo(UiConfiguration(darkMode = true)) } @Test fun uiConfigurationEmitsUiModeChanges() = runTest { - val layout = TreehouseLayout(context, throwingWidgetSystem) + val activity = Robolectric.buildActivity(ComponentActivity::class.java).resume().visible().get() + val layout = TreehouseLayout(context, throwingWidgetSystem, activity.onBackPressedDispatcher) layout.uiConfiguration.test { assertThat(awaitItem()).isEqualTo(UiConfiguration(darkMode = false)) @@ -119,7 +124,8 @@ class TreehouseLayoutTest { } @Test fun uiConfigurationEmitsSystemBarsSafeAreaInsetsChanges() = runTest { - val layout = TreehouseLayout(context, throwingWidgetSystem) + val activity = Robolectric.buildActivity(ComponentActivity::class.java).resume().visible().get() + val layout = TreehouseLayout(context, throwingWidgetSystem, activity.onBackPressedDispatcher) layout.uiConfiguration.test { assertThat(awaitItem()).isEqualTo(UiConfiguration(safeAreaInsets = Margin.Zero)) val insets = Insets.of(10, 20, 30, 40) diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/Content.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/Content.kt index 70cc6197bb..585d3b1e07 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/Content.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/Content.kt @@ -15,6 +15,7 @@ */ package app.cash.redwood.treehouse +import app.cash.redwood.ui.OnBackPressedDispatcher import app.cash.redwood.ui.UiConfiguration import kotlin.native.ObjCName import kotlinx.coroutines.CancellationException @@ -40,7 +41,10 @@ public interface Content { /** * Immediately begins preparing the widget tree. */ - public fun preload(uiConfiguration: UiConfiguration) + public fun preload( + onBackPressedDispatcher: OnBackPressedDispatcher, + uiConfiguration: UiConfiguration, + ) /** * It is an error to bind multiple views simultaneously. diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt index ca90462ace..baa16a3723 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt @@ -20,6 +20,8 @@ import app.cash.redwood.protocol.Event import app.cash.redwood.protocol.EventSink import app.cash.redwood.protocol.widget.ProtocolBridge import app.cash.redwood.protocol.widget.ProtocolNode +import app.cash.redwood.ui.OnBackPressedCallback +import app.cash.redwood.ui.OnBackPressedDispatcher import app.cash.redwood.ui.UiConfiguration import app.cash.redwood.widget.Widget import app.cash.zipline.ZiplineScope @@ -47,6 +49,7 @@ private sealed interface ViewState { object None : ViewState class Preloading( + val onBackPressedDispatcher: OnBackPressedDispatcher, val uiConfiguration: UiConfiguration, ) : ViewState @@ -73,13 +76,16 @@ internal class TreehouseAppContent( private val stateFlow = MutableStateFlow>(State(ViewState.None, CodeState.Idle())) - override fun preload(uiConfiguration: UiConfiguration) { + override fun preload( + onBackPressedDispatcher: OnBackPressedDispatcher, + uiConfiguration: UiConfiguration, + ) { treehouseApp.dispatchers.checkUi() val previousState = stateFlow.value check(previousState.viewState is ViewState.None) - val nextViewState = ViewState.Preloading(uiConfiguration) + val nextViewState = ViewState.Preloading(onBackPressedDispatcher, uiConfiguration) // Start the code if necessary. val ziplineSession = treehouseApp.ziplineSession @@ -89,6 +95,7 @@ internal class TreehouseAppContent( startViewCodeContentBinding( ziplineSession = ziplineSession, isInitialLaunch = true, + onBackPressedDispatcher = onBackPressedDispatcher, firstUiConfiguration = MutableStateFlow(nextViewState.uiConfiguration), ), ) @@ -120,6 +127,7 @@ internal class TreehouseAppContent( startViewCodeContentBinding( ziplineSession = ziplineSession, isInitialLaunch = true, + onBackPressedDispatcher = nextViewState.view.onBackPressedDispatcher, firstUiConfiguration = nextViewState.view.uiConfiguration, ), ) @@ -179,6 +187,12 @@ internal class TreehouseAppContent( val viewState = previousState.viewState val previousCodeState = previousState.codeState + val onBackPressedDispatcher = when (viewState) { + is ViewState.Preloading -> viewState.onBackPressedDispatcher + is ViewState.Bound -> viewState.view.onBackPressedDispatcher + else -> error("unexpected receiveZiplineSession with no view bound and no preload") + } + val uiConfiguration = when (viewState) { is ViewState.Preloading -> MutableStateFlow(viewState.uiConfiguration) is ViewState.Bound -> viewState.view.uiConfiguration @@ -189,6 +203,7 @@ internal class TreehouseAppContent( startViewCodeContentBinding( ziplineSession = next, isInitialLaunch = previousCodeState is CodeState.Idle, + onBackPressedDispatcher = onBackPressedDispatcher, firstUiConfiguration = uiConfiguration, ), ) @@ -210,6 +225,7 @@ internal class TreehouseAppContent( private fun startViewCodeContentBinding( ziplineSession: ZiplineSession, isInitialLaunch: Boolean, + onBackPressedDispatcher: OnBackPressedDispatcher, firstUiConfiguration: StateFlow, ): ViewContentCodeBinding { dispatchers.checkUi() @@ -223,6 +239,7 @@ internal class TreehouseAppContent( stateFlow = stateFlow, isInitialLaunch = isInitialLaunch, session = ziplineSession, + onBackPressedDispatcher = onBackPressedDispatcher, firstUiConfiguration = firstUiConfiguration, ).apply { start(ziplineSession) @@ -251,6 +268,7 @@ private class ViewContentCodeBinding( val stateFlow: MutableStateFlow>, private val isInitialLaunch: Boolean, session: ZiplineSession, + private val onBackPressedDispatcher: OnBackPressedDispatcher, firstUiConfiguration: StateFlow, ) : EventSink, ChangesSinkService, TreehouseView.SaveCallback { private val uiConfigurationFlow = SequentialStateFlow(firstUiConfiguration) @@ -372,6 +390,30 @@ private class ViewContentCodeBinding( val restoredState = if (restoredId != null) app.stateStore.get(restoredId.value.orEmpty()) else null treehouseUi.start( changesSink = this@ViewContentCodeBinding, + onBackPressedDispatcher = object : OnBackPressedDispatcherService { + override fun addCallback(onBackPressedCallback: OnBackPressedCallbackService): CancellableService { + app.dispatchers.checkZipline() + val cancellable = onBackPressedDispatcher.addCallback( + object : OnBackPressedCallback(onBackPressedCallback.isEnabled) { + override fun handleOnBackPressed() { + bindingScope.launch(app.dispatchers.zipline) { + onBackPressedCallback.handleOnBackPressed() + } + } + }, + ) + return object : CancellableService { + override fun cancel() { + app.dispatchers.checkZipline() + cancellable.cancel() + } + + override fun close() { + cancel() + } + } + } + }, uiConfigurations = uiConfigurationFlow, stateSnapshot = restoredState, ) diff --git a/redwood-treehouse/api/zipline-api.toml b/redwood-treehouse/api/zipline-api.toml index 554e581e52..fd668a23e7 100644 --- a/redwood-treehouse/api/zipline-api.toml +++ b/redwood-treehouse/api/zipline-api.toml @@ -37,6 +37,16 @@ functions = [ "odhmO/d6", ] +[app.cash.redwood.treehouse.CancellableService] + +functions = [ + # fun cancel(): kotlin.Unit + "EhTc1FUm", + + # fun close(): kotlin.Unit + "moYx+T3e", +] + [app.cash.redwood.treehouse.ChangesSinkService] functions = [ @@ -47,6 +57,29 @@ functions = [ "W8qOuU0t", ] +[app.cash.redwood.treehouse.OnBackPressedCallbackService] + +functions = [ + # fun close(): kotlin.Unit + "moYx+T3e", + + # fun handleOnBackPressed(): kotlin.Unit + "NjIN59uX", + + # var isEnabled: kotlin.Boolean + "yKh+yo1c", +] + +[app.cash.redwood.treehouse.OnBackPressedDispatcherService] + +functions = [ + # fun addCallback(app.cash.redwood.treehouse.OnBackPressedCallbackService): app.cash.redwood.treehouse.CancellableService + "F7ShXQYg", + + # fun close(): kotlin.Unit + "moYx+T3e", +] + [app.cash.redwood.treehouse.ZiplineTreehouseUi] functions = [ @@ -59,6 +92,9 @@ functions = [ # fun snapshotState(): app.cash.redwood.treehouse.StateSnapshot "mPBGCHFl", + # fun start(app.cash.redwood.treehouse.ChangesSinkService, app.cash.redwood.treehouse.OnBackPressedDispatcherService, kotlinx.coroutines.flow.StateFlow, app.cash.redwood.treehouse.StateSnapshot): kotlin.Unit + "UkTy28z8", + # fun start(app.cash.redwood.treehouse.ChangesSinkService, kotlinx.coroutines.flow.StateFlow, app.cash.redwood.treehouse.StateSnapshot): kotlin.Unit "FiVQXMQW", ] diff --git a/redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/CancellableService.kt b/redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/CancellableService.kt new file mode 100644 index 0000000000..f9831a5cac --- /dev/null +++ b/redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/CancellableService.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2023 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.treehouse + +import app.cash.redwood.ui.Cancellable +import app.cash.zipline.ZiplineService +import kotlin.native.ObjCName + +/** Redwood's [Cancellable] but implementing [ZiplineService]. */ +@ObjCName("CancellableService", exact = true) +public interface CancellableService : ZiplineService, Cancellable diff --git a/redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/OnBackPressedCallbackService.kt b/redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/OnBackPressedCallbackService.kt new file mode 100644 index 0000000000..a29dd52b76 --- /dev/null +++ b/redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/OnBackPressedCallbackService.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2023 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.treehouse + +import app.cash.redwood.ui.OnBackPressedCallback +import app.cash.zipline.ZiplineService +import kotlin.native.ObjCName + +/** Redwood's [OnBackPressedCallback] but implementing [ZiplineService]. */ +@ObjCName("OnBackPressedCallbackService", exact = true) +public interface OnBackPressedCallbackService : ZiplineService { + public var isEnabled: Boolean + + public fun handleOnBackPressed() +} diff --git a/redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/OnBackPressedDispatcherService.kt b/redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/OnBackPressedDispatcherService.kt new file mode 100644 index 0000000000..634afdc4ff --- /dev/null +++ b/redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/OnBackPressedDispatcherService.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2023 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.treehouse + +import app.cash.redwood.ui.OnBackPressedDispatcher +import app.cash.zipline.ZiplineService +import kotlin.native.ObjCName + +/** Redwood's [OnBackPressedDispatcher] but implementing [ZiplineService]. */ +@ObjCName("OnBackPressedDispatcherService", exact = true) +public interface OnBackPressedDispatcherService : ZiplineService { + public fun addCallback(onBackPressedCallback: OnBackPressedCallbackService): CancellableService +} diff --git a/redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/ZiplineTreehouseUi.kt b/redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/ZiplineTreehouseUi.kt index e042de7f27..b585ee7378 100644 --- a/redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/ZiplineTreehouseUi.kt +++ b/redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/ZiplineTreehouseUi.kt @@ -28,6 +28,17 @@ import kotlinx.coroutines.flow.StateFlow */ @ObjCName("ZiplineTreehouseUi", exact = true) public interface ZiplineTreehouseUi : ZiplineService, EventSink { + public fun start( + changesSink: ChangesSinkService, + onBackPressedDispatcher: OnBackPressedDispatcherService, + uiConfigurations: StateFlow, + stateSnapshot: StateSnapshot?, + ) + + @Deprecated( + "Use `start` method that takes in an `OnBackPressedDispatcherService` instead.", + ReplaceWith("start(changesSink, TODO(), uiConfigurations, stateSnapshot)"), + ) public fun start( changesSink: ChangesSinkService, uiConfigurations: StateFlow, diff --git a/redwood-widget/build.gradle b/redwood-widget/build.gradle index 8209791174..97b6cff3bf 100644 --- a/redwood-widget/build.gradle +++ b/redwood-widget/build.gradle @@ -29,6 +29,7 @@ kotlin { androidMain { dependencies { implementation libs.androidx.core + implementation libs.androidx.activity } } androidUnitTest { diff --git a/redwood-widget/src/androidMain/kotlin/app/cash/redwood/widget/RedwoodLayout.kt b/redwood-widget/src/androidMain/kotlin/app/cash/redwood/widget/RedwoodLayout.kt index c1c88b972e..1126db1d20 100644 --- a/redwood-widget/src/androidMain/kotlin/app/cash/redwood/widget/RedwoodLayout.kt +++ b/redwood-widget/src/androidMain/kotlin/app/cash/redwood/widget/RedwoodLayout.kt @@ -20,21 +20,41 @@ import android.content.Context import android.content.res.Configuration import android.view.View import android.widget.FrameLayout +import androidx.activity.OnBackPressedCallback as AndroidOnBackPressedCallback +import androidx.activity.OnBackPressedDispatcher as AndroidOnBackPressedDispatcher import androidx.core.graphics.Insets +import app.cash.redwood.ui.Cancellable import app.cash.redwood.ui.Density +import app.cash.redwood.ui.OnBackPressedCallback as RedwoodOnBackPressedCallback +import app.cash.redwood.ui.OnBackPressedDispatcher as RedwoodOnBackPressedDispatcher import app.cash.redwood.ui.Size import app.cash.redwood.ui.UiConfiguration import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +@SuppressLint("ViewConstructor") public open class RedwoodLayout( context: Context, + androidOnBackPressedDispatcher: AndroidOnBackPressedDispatcher, ) : FrameLayout(context), RedwoodView { private val _children = ViewGroupChildren(this) override val children: Widget.Children get() = _children private val mutableUiConfiguration = MutableStateFlow(computeUiConfiguration()) + override val onBackPressedDispatcher: RedwoodOnBackPressedDispatcher = + object : RedwoodOnBackPressedDispatcher { + override fun addCallback(onBackPressedCallback: RedwoodOnBackPressedCallback): Cancellable { + val androidOnBackPressedCallback = onBackPressedCallback.toAndroid() + androidOnBackPressedDispatcher.addCallback(androidOnBackPressedCallback) + return object : Cancellable { + override fun cancel() { + androidOnBackPressedCallback.remove() + } + } + } + } + override val uiConfiguration: StateFlow get() = mutableUiConfiguration @@ -80,3 +100,10 @@ public open class RedwoodLayout( ) } } + +private fun RedwoodOnBackPressedCallback.toAndroid(): AndroidOnBackPressedCallback = + object : AndroidOnBackPressedCallback(this@toAndroid.isEnabled) { + override fun handleOnBackPressed() { + this@toAndroid.handleOnBackPressed() + } + } diff --git a/redwood-widget/src/commonMain/kotlin/app/cash/redwood/widget/RedwoodView.kt b/redwood-widget/src/commonMain/kotlin/app/cash/redwood/widget/RedwoodView.kt index fd355305de..c73b3349f8 100644 --- a/redwood-widget/src/commonMain/kotlin/app/cash/redwood/widget/RedwoodView.kt +++ b/redwood-widget/src/commonMain/kotlin/app/cash/redwood/widget/RedwoodView.kt @@ -15,6 +15,7 @@ */ package app.cash.redwood.widget +import app.cash.redwood.ui.OnBackPressedDispatcher import app.cash.redwood.ui.UiConfiguration import kotlin.native.ObjCName import kotlinx.coroutines.flow.StateFlow @@ -22,6 +23,7 @@ import kotlinx.coroutines.flow.StateFlow @ObjCName("RedwoodView", exact = true) public interface RedwoodView { public val children: Widget.Children + public val onBackPressedDispatcher: OnBackPressedDispatcher public val uiConfiguration: StateFlow /** diff --git a/redwood-widget/src/iosMain/kotlin/app/cash/redwood/widget/RedwoodUIView.kt b/redwood-widget/src/iosMain/kotlin/app/cash/redwood/widget/RedwoodUIView.kt index 59f323e4dd..fa42c4672c 100644 --- a/redwood-widget/src/iosMain/kotlin/app/cash/redwood/widget/RedwoodUIView.kt +++ b/redwood-widget/src/iosMain/kotlin/app/cash/redwood/widget/RedwoodUIView.kt @@ -15,9 +15,12 @@ */ package app.cash.redwood.widget +import app.cash.redwood.ui.Cancellable import app.cash.redwood.ui.Default import app.cash.redwood.ui.Density import app.cash.redwood.ui.Margin +import app.cash.redwood.ui.OnBackPressedCallback +import app.cash.redwood.ui.OnBackPressedDispatcher import app.cash.redwood.ui.Size import app.cash.redwood.ui.UiConfiguration import kotlinx.cinterop.CValue @@ -39,6 +42,14 @@ public open class RedwoodUIView( private val mutableUiConfiguration = MutableStateFlow(computeUiConfiguration(view.traitCollection, view.bounds)) + override val onBackPressedDispatcher: OnBackPressedDispatcher = object : OnBackPressedDispatcher { + override fun addCallback(onBackPressedCallback: OnBackPressedCallback): Cancellable { + return object : Cancellable { + override fun cancel() = Unit + } + } + } + override val uiConfiguration: StateFlow get() = mutableUiConfiguration diff --git a/redwood-widget/src/jsMain/kotlin/app/cash/redwood/widget/RedwoodHTMLElementView.kt b/redwood-widget/src/jsMain/kotlin/app/cash/redwood/widget/RedwoodHTMLElementView.kt index f93bf7c9e6..9192a68a72 100644 --- a/redwood-widget/src/jsMain/kotlin/app/cash/redwood/widget/RedwoodHTMLElementView.kt +++ b/redwood-widget/src/jsMain/kotlin/app/cash/redwood/widget/RedwoodHTMLElementView.kt @@ -15,6 +15,9 @@ */ package app.cash.redwood.widget +import app.cash.redwood.ui.Cancellable +import app.cash.redwood.ui.OnBackPressedCallback +import app.cash.redwood.ui.OnBackPressedDispatcher import app.cash.redwood.ui.Size import app.cash.redwood.ui.UiConfiguration import app.cash.redwood.ui.dp @@ -38,6 +41,16 @@ private class RedwoodHTMLElementView( ) : RedwoodView { private val _children = HTMLElementChildren(element) override val children: Children get() = _children + + override val onBackPressedDispatcher: OnBackPressedDispatcher = object : OnBackPressedDispatcher { + override fun addCallback(onBackPressedCallback: OnBackPressedCallback): Cancellable { + // TODO Delegate `onBackPressedCallback` to browser + return object : Cancellable { + override fun cancel() = Unit + } + } + } + override val uiConfiguration = MutableStateFlow( UiConfiguration( darkMode = window.matchMedia("(prefers-color-scheme: dark)").matches, diff --git a/samples/counter/android-views/src/main/kotlin/com/example/redwood/counter/android/views/MainActivity.kt b/samples/counter/android-views/src/main/kotlin/com/example/redwood/counter/android/views/MainActivity.kt index d0f9887baf..385dbd468f 100644 --- a/samples/counter/android-views/src/main/kotlin/com/example/redwood/counter/android/views/MainActivity.kt +++ b/samples/counter/android-views/src/main/kotlin/com/example/redwood/counter/android/views/MainActivity.kt @@ -32,7 +32,7 @@ class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val redwoodView = RedwoodLayout(this) + val redwoodView = RedwoodLayout(this, onBackPressedDispatcher) setContentView(redwoodView) val composition = RedwoodComposition( diff --git a/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt b/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt index 78bdf4f05c..ab70c64b69 100644 --- a/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt +++ b/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt @@ -72,7 +72,7 @@ class EmojiSearchActivity : ComponentActivity() { } setContentView( - TreehouseLayout(this, widgetSystem).apply { + TreehouseLayout(this, widgetSystem, onBackPressedDispatcher).apply { // The view needs to have an id for Android to populate saved data back this.id = 9000 treehouseContentSource.bindWhenReady(this, treehouseApp) diff --git a/test-app/android-views/src/main/AndroidManifest.xml b/test-app/android-views/src/main/AndroidManifest.xml index 951f81e76c..d2d73e285f 100644 --- a/test-app/android-views/src/main/AndroidManifest.xml +++ b/test-app/android-views/src/main/AndroidManifest.xml @@ -23,6 +23,7 @@