From d29aea2c13b9122f4cd50f723122f9cba93e94ac Mon Sep 17 00:00:00 2001 From: Ray Ryan Date: Tue, 13 Jun 2023 14:50:00 -0700 Subject: [PATCH] Better support for `View.findViewTreeOnBackPressedDispatcherOwner`. `fun View.findViewTreeOnBackPressedDispatcherOwner()` is AndroidX's preferred entry point to the exciting new world of `OnBackPressedDispatcherOwner`. With this PR we support it explicitly, in particular taking care to ensure that it can be called by newly constructed views before they have been attached to a parent. That is, we take care to make eager calls `View.setViewTreeSavedStateRegistryOwner` on every view we build. We accomplish this mainly by riding the rails previously laid down via `WorkflowLifecycleOwner.installOn`, which now requires an `OnBackPressedDispatcherOwner` parameter. (This is the method used by `WorkflowViewStub` _et al_ to ensure that we're managing the JetPack Lifecycle correctly, and `OnBackPressedDispatcherOwner` is just another piece of that puzzle.) In aid of that, we introduce a new `ViewEnvironmentKey`, `OnBackPressedDispatcherOwnerKey`. It is initialized by `WorkflowLayout`, our new `ComponentDialog.setContent` extension, and `@Composable fun WorkflowRendering()`. This key is not intended for use by feature code, it's more of an implementation detail that has to stay public to allow custom containers to be built. It ensures that `WorkflowViewStub` and friends will have access to the correct `OnBackPressedDispatcherOwner` before they have access to a parent view. TODO: add tests of `@Composable fun BackHandler()`, in both activity and dialog windows. --- workflow-ui/compose/build.gradle.kts | 1 + .../workflow1/ui/compose/WorkflowRendering.kt | 32 ++++++++++++-- .../workflow1/ui/modal/ModalContainer.kt | 4 ++ .../workflow1/ui/modal/ModalViewContainer.kt | 4 +- workflow-ui/core-android/api/core-android.api | 13 ++++-- .../ui/container/ViewStateCacheTest.kt | 16 ++++--- .../squareup/workflow1/ui/BackPressHandler.kt | 13 +----- .../ui/OnBackPressedDispatcherOwnerKey.kt | 15 +++++++ .../squareup/workflow1/ui/WorkflowLayout.kt | 24 +++++++++-- .../squareup/workflow1/ui/WorkflowViewStub.kt | 7 ++-- .../ui/androidx/WorkflowAndroidXSupport.kt | 42 +++++++++++++++++++ .../ui/androidx/WorkflowLifecycleOwner.kt | 13 +++++- .../ui/container/BackStackContainer.kt | 3 +- .../ui/container/ContentDialogSetContent.kt | 15 ++++--- .../workflow1/ui/container/DialogSession.kt | 22 ++++++++++ .../ui/container/OverlayDialogHolder.kt | 10 ++--- ...orkflowSavedStateRegistryAggregatorTest.kt | 20 +++++---- 17 files changed, 202 insertions(+), 52 deletions(-) create mode 100644 workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/OnBackPressedDispatcherOwnerKey.kt diff --git a/workflow-ui/compose/build.gradle.kts b/workflow-ui/compose/build.gradle.kts index 67c8790889..f398d3efd5 100644 --- a/workflow-ui/compose/build.gradle.kts +++ b/workflow-ui/compose/build.gradle.kts @@ -47,6 +47,7 @@ dependencies { api(project(":workflow-ui:core-common")) implementation(composeBom) + implementation(libs.androidx.activity.compose) implementation(libs.androidx.compose.foundation.layout) implementation(libs.androidx.compose.runtime.saveable) implementation(libs.androidx.compose.ui) diff --git a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/WorkflowRendering.kt b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/WorkflowRendering.kt index f0a02f85ce..891c148f2d 100644 --- a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/WorkflowRendering.kt +++ b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/WorkflowRendering.kt @@ -3,6 +3,9 @@ package com.squareup.workflow1.ui.compose import android.view.View +import androidx.activity.OnBackPressedDispatcherOwner +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.activity.setViewTreeOnBackPressedDispatcherOwner import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -20,6 +23,7 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.ViewTreeLifecycleOwner import com.squareup.workflow1.ui.Compatible +import com.squareup.workflow1.ui.OnBackPressedDispatcherOwnerKey import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.ScreenViewFactory import com.squareup.workflow1.ui.ScreenViewFactoryFinder @@ -170,6 +174,10 @@ private fun ScreenViewFactory.asComposeViewFactory() * we already have the correct one. * - Propagate the current [LifecycleOwner] from [LocalLifecycleOwner] by setting it as the * [ViewTreeLifecycleOwner] on the view. + * - Propagate the current [OnBackPressedDispatcherOwner] from either + * [LocalOnBackPressedDispatcherOwner] or the [viewEnvironment], + * both on the [AndroidView] via [setViewTreeOnBackPressedDispatcherOwner], + * and in the [ViewEnvironment] for use by any nested [WorkflowViewStub] * * Like `WorkflowViewStub`, this function uses the [originalFactory] to create and memoize a * [View] to display the [rendering], keeps it updated with the latest [rendering] and @@ -181,17 +189,35 @@ private fun ScreenViewFactory.asComposeViewFactory() ) { val lifecycleOwner = LocalLifecycleOwner.current + // Make sure any nested WorkflowViewStub will be able to propagate the + // OnBackPressedDispatcherOwner, if we found one. No need to fail fast here. + // It's only an issue if someone tries to use it, and the error message + // at those call sites should be clear enough. + val onBackOrNull = LocalOnBackPressedDispatcherOwner.current + ?: viewEnvironment.map[OnBackPressedDispatcherOwnerKey] as? OnBackPressedDispatcherOwner + + val envWithOnBack = onBackOrNull + ?.let { viewEnvironment + (OnBackPressedDispatcherOwnerKey to it) } + ?: viewEnvironment + AndroidView( factory = { context -> + // We pass in a null container because the container isn't a View, it's a composable. The // compose machinery will generate an intermediate view that it ends up adding this to but // we don't have access to that. - originalFactory.startShowing(rendering, viewEnvironment, context, container = null) + originalFactory + .startShowing(rendering, envWithOnBack, context, container = null) .let { viewHolder -> // Put the viewHolder in a tag so that we can find it in the update lambda, below. viewHolder.view.setTag(R.id.workflow_screen_view_holder, viewHolder) - // Unfortunately AndroidView doesn't propagate this itself. + + // Unfortunately AndroidView doesn't propagate these itself. ViewTreeLifecycleOwner.set(viewHolder.view, lifecycleOwner) + onBackOrNull?.let { + viewHolder.view.setViewTreeOnBackPressedDispatcherOwner(it) + } + // We don't propagate the (non-compose) SavedStateRegistryOwner, or the (compose) // SaveableStateRegistry, because currently all our navigation is implemented as // Android views, which ensures there is always an Android view between any state @@ -206,7 +232,7 @@ private fun ScreenViewFactory.asComposeViewFactory() @Suppress("UNCHECKED_CAST") val viewHolder = view.getTag(R.id.workflow_screen_view_holder) as ScreenViewHolder - viewHolder.show(rendering, viewEnvironment) + viewHolder.show(rendering, envWithOnBack) } ) } diff --git a/workflow-ui/container-android/src/main/java/com/squareup/workflow1/ui/modal/ModalContainer.kt b/workflow-ui/container-android/src/main/java/com/squareup/workflow1/ui/modal/ModalContainer.kt index edb8b086d1..69d905f556 100644 --- a/workflow-ui/container-android/src/main/java/com/squareup/workflow1/ui/modal/ModalContainer.kt +++ b/workflow-ui/container-android/src/main/java/com/squareup/workflow1/ui/modal/ModalContainer.kt @@ -13,6 +13,7 @@ import android.view.View import android.view.ViewGroup import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout +import androidx.activity.OnBackPressedDispatcherOwner import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner @@ -20,6 +21,7 @@ import com.squareup.workflow1.ui.Compatible import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.WorkflowViewStub +import com.squareup.workflow1.ui.androidx.WorkflowAndroidXSupport.onBackPressedDispatcherOwner import com.squareup.workflow1.ui.androidx.WorkflowAndroidXSupport.stateRegistryOwnerFromViewTreeOrContext import com.squareup.workflow1.ui.androidx.WorkflowLifecycleOwner import com.squareup.workflow1.ui.androidx.WorkflowSavedStateRegistryAggregator @@ -88,6 +90,8 @@ public abstract class ModalContainer @JvmOverloads constr // any, and so we can use our lifecycle to destroy-on-detach the dialog hierarchy. WorkflowLifecycleOwner.installOn( dialogView, + (ref.dialog as? OnBackPressedDispatcherOwner) + ?: viewEnvironment.onBackPressedDispatcherOwner(this), findParentLifecycle = { parentLifecycleOwner.lifecycle } ) // Ensure that each dialog has its own SavedStateRegistryOwner, diff --git a/workflow-ui/container-android/src/main/java/com/squareup/workflow1/ui/modal/ModalViewContainer.kt b/workflow-ui/container-android/src/main/java/com/squareup/workflow1/ui/modal/ModalViewContainer.kt index 23af029de7..f8add72d8c 100644 --- a/workflow-ui/container-android/src/main/java/com/squareup/workflow1/ui/modal/ModalViewContainer.kt +++ b/workflow-ui/container-android/src/main/java/com/squareup/workflow1/ui/modal/ModalViewContainer.kt @@ -18,11 +18,11 @@ import com.squareup.workflow1.ui.ScreenViewHolder import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.ViewRegistry import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.androidx.WorkflowAndroidXSupport.onBackPressedDispatcherOwnerOrNull import com.squareup.workflow1.ui.asScreen import com.squareup.workflow1.ui.bindShowRendering import com.squareup.workflow1.ui.container.BackButtonScreen import com.squareup.workflow1.ui.modal.ModalViewContainer.Companion.binding -import com.squareup.workflow1.ui.onBackPressedDispatcherOwnerOrNull import com.squareup.workflow1.ui.show import com.squareup.workflow1.ui.startShowing import com.squareup.workflow1.ui.toViewFactory @@ -93,7 +93,7 @@ public open class ModalViewContainer @JvmOverloads constructor( setOnKeyListener { _, keyCode, keyEvent -> if (keyCode == KeyEvent.KEYCODE_BACK && keyEvent.action == ACTION_UP) { - viewHolder.view.context.onBackPressedDispatcherOwnerOrNull() + viewHolder.view.onBackPressedDispatcherOwnerOrNull() ?.onBackPressedDispatcher ?.let { if (it.hasEnabledCallbacks()) it.onBackPressed() diff --git a/workflow-ui/core-android/api/core-android.api b/workflow-ui/core-android/api/core-android.api index 9e46a57cdc..568598f2eb 100644 --- a/workflow-ui/core-android/api/core-android.api +++ b/workflow-ui/core-android/api/core-android.api @@ -33,7 +33,6 @@ public final class com/squareup/workflow1/ui/BackButtonScreen : com/squareup/wor public final class com/squareup/workflow1/ui/BackPressHandlerKt { public static final fun getBackPressedHandler (Landroid/view/View;)Lkotlin/jvm/functions/Function0; - public static final fun onBackPressedDispatcherOwnerOrNull (Landroid/content/Context;)Landroidx/activity/OnBackPressedDispatcherOwner; public static final fun setBackPressedHandler (Landroid/view/View;Lkotlin/jvm/functions/Function0;)V } @@ -77,6 +76,12 @@ public final class com/squareup/workflow1/ui/LayoutScreenViewFactory : com/squar public fun getType ()Lkotlin/reflect/KClass; } +public final class com/squareup/workflow1/ui/OnBackPressedDispatcherOwnerKey : com/squareup/workflow1/ui/ViewEnvironmentKey { + public static final field INSTANCE Lcom/squareup/workflow1/ui/OnBackPressedDispatcherOwnerKey; + public fun getDefault ()Landroidx/activity/OnBackPressedDispatcherOwner; + public synthetic fun getDefault ()Ljava/lang/Object; +} + public final class com/squareup/workflow1/ui/ParcelableTextController : android/os/Parcelable, com/squareup/workflow1/ui/TextController { public static final field CREATOR Lcom/squareup/workflow1/ui/ParcelableTextController$CREATOR; public synthetic fun (Landroid/os/Parcel;Lkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -275,6 +280,8 @@ public final class com/squareup/workflow1/ui/androidx/WorkflowAndroidXSupport { public static final field INSTANCE Lcom/squareup/workflow1/ui/androidx/WorkflowAndroidXSupport; public final fun lifecycleOwnerFromContext (Landroid/content/Context;)Landroidx/lifecycle/LifecycleOwner; public final fun lifecycleOwnerFromViewTreeOrContextOrNull (Landroid/view/View;)Landroidx/lifecycle/LifecycleOwner; + public final fun onBackPressedDispatcherOwner (Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/view/View;)Landroidx/activity/OnBackPressedDispatcherOwner; + public final fun onBackPressedDispatcherOwnerOrNull (Landroid/view/View;)Landroidx/activity/OnBackPressedDispatcherOwner; public final fun stateRegistryOwnerFromViewTreeOrContext (Landroid/view/View;)Landroidx/savedstate/SavedStateRegistryOwner; } @@ -285,8 +292,8 @@ public abstract interface class com/squareup/workflow1/ui/androidx/WorkflowLifec public final class com/squareup/workflow1/ui/androidx/WorkflowLifecycleOwner$Companion { public final fun get (Landroid/view/View;)Lcom/squareup/workflow1/ui/androidx/WorkflowLifecycleOwner; - public final fun installOn (Landroid/view/View;Lkotlin/jvm/functions/Function1;)V - public static synthetic fun installOn$default (Lcom/squareup/workflow1/ui/androidx/WorkflowLifecycleOwner$Companion;Landroid/view/View;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V + public final fun installOn (Landroid/view/View;Landroidx/activity/OnBackPressedDispatcherOwner;Lkotlin/jvm/functions/Function1;)V + public static synthetic fun installOn$default (Lcom/squareup/workflow1/ui/androidx/WorkflowLifecycleOwner$Companion;Landroid/view/View;Landroidx/activity/OnBackPressedDispatcherOwner;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V } public final class com/squareup/workflow1/ui/androidx/WorkflowSavedStateRegistryAggregator { diff --git a/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/container/ViewStateCacheTest.kt b/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/container/ViewStateCacheTest.kt index d1c041d5b4..f3391b54a1 100644 --- a/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/container/ViewStateCacheTest.kt +++ b/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/container/ViewStateCacheTest.kt @@ -5,6 +5,9 @@ import android.os.Build.VERSION_CODES import android.os.Parcel import android.os.Parcelable import android.util.SparseArray +import androidx.activity.OnBackPressedDispatcher +import androidx.activity.OnBackPressedDispatcherOwner +import androidx.lifecycle.Lifecycle import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.google.common.truth.Truth.assertThat @@ -32,6 +35,11 @@ internal class ViewStateCacheTest { private val instrumentation = InstrumentationRegistry.getInstrumentation() private val viewEnvironment = EMPTY + private val fakeOnBack = object : OnBackPressedDispatcherOwner { + override fun getLifecycle(): Lifecycle = error("") + override fun getOnBackPressedDispatcher(): OnBackPressedDispatcher = error("") + } + private object AScreen : Screen @Test fun saves_and_restores_self() { @@ -59,9 +67,7 @@ internal class ViewStateCacheTest { @Suppress("DEPRECATION") parcel.readParcelable(ViewStateCache.Saved::class.java.classLoader)!! } - ).let { restoredState -> - ViewStateCache().apply { restore(restoredState) } - } + ).let { restoredState -> ViewStateCache().apply { restore(restoredState) } } assertThat(restoredCache.equalsForTest(cache)).isTrue() } @@ -125,7 +131,7 @@ internal class ViewStateCacheTest { // "Navigate" back to the first screen, restoring state. val firstViewRestored = ViewStateTestView(instrumentation.context).apply { id = 2 - WorkflowLifecycleOwner.installOn(this) + WorkflowLifecycleOwner.installOn(this, fakeOnBack) } val firstHolderRestored = ScreenViewHolder>(EMPTY, firstViewRestored) { _, _ -> }.also { @@ -194,7 +200,7 @@ internal class ViewStateCacheTest { ): ScreenViewHolder> { val view = ViewStateTestView(instrumentation.context).also { view -> id?.let { view.id = id } - WorkflowLifecycleOwner.installOn(view) + WorkflowLifecycleOwner.installOn(view, fakeOnBack) } return ScreenViewHolder>(EMPTY, view) { _, _ -> }.also { it.show(firstRendering, viewEnvironment) diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/BackPressHandler.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/BackPressHandler.kt index 49a7f7bd51..faedd4819a 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/BackPressHandler.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/BackPressHandler.kt @@ -1,15 +1,13 @@ package com.squareup.workflow1.ui -import android.content.Context -import android.content.ContextWrapper import android.view.View import android.view.View.OnAttachStateChangeListener import androidx.activity.OnBackPressedCallback -import androidx.activity.OnBackPressedDispatcherOwner import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ViewTreeLifecycleOwner +import com.squareup.workflow1.ui.androidx.WorkflowAndroidXSupport.onBackPressedDispatcherOwnerOrNull /** * A function passed to [View.backPressedHandler], to be called if the back @@ -87,7 +85,7 @@ private class AttachStateAndLifecycleObserver( private var lifecycleOrNull: Lifecycle? = null fun start() { - view.context.onBackPressedDispatcherOwnerOrNull() + view.onBackPressedDispatcherOwnerOrNull() ?.let { owner -> owner.onBackPressedDispatcher.addCallback(owner, onBackPressedCallback) view.addOnAttachStateChangeListener(this) @@ -138,10 +136,3 @@ internal class NullableOnBackPressedCallback : OnBackPressedCallback(false) { handlerOrNull?.invoke() } } - -@WorkflowUiExperimentalApi -public tailrec fun Context.onBackPressedDispatcherOwnerOrNull(): OnBackPressedDispatcherOwner? = - when (this) { - is OnBackPressedDispatcherOwner -> this - else -> (this as? ContextWrapper)?.baseContext?.onBackPressedDispatcherOwnerOrNull() - } diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/OnBackPressedDispatcherOwnerKey.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/OnBackPressedDispatcherOwnerKey.kt new file mode 100644 index 0000000000..87a3341feb --- /dev/null +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/OnBackPressedDispatcherOwnerKey.kt @@ -0,0 +1,15 @@ +package com.squareup.workflow1.ui + +import androidx.activity.OnBackPressedDispatcherOwner + +/** + * Used by container classes to ensure that + * [View.findViewTreeOnBackPressedDispatcherOwner][androidx.activity.findViewTreeOnBackPressedDispatcherOwner] + * works before new views are attached to their parents. Not intended for use by + * feature code. + */ +@WorkflowUiExperimentalApi +public object OnBackPressedDispatcherOwnerKey : + ViewEnvironmentKey() { + override val default: OnBackPressedDispatcherOwner get() = error("Unset") +} diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/WorkflowLayout.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/WorkflowLayout.kt index b230ef2c07..7ed280da40 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/WorkflowLayout.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/WorkflowLayout.kt @@ -2,7 +2,6 @@ package com.squareup.workflow1.ui import android.content.Context import android.os.Build.VERSION -import android.os.Build.VERSION_CODES import android.os.Parcel import android.os.Parcelable import android.os.Parcelable.Creator @@ -17,6 +16,7 @@ import androidx.lifecycle.Lifecycle.State import androidx.lifecycle.Lifecycle.State.STARTED import androidx.lifecycle.coroutineScope import androidx.lifecycle.repeatOnLifecycle +import com.squareup.workflow1.ui.androidx.WorkflowAndroidXSupport.onBackPressedDispatcherOwnerOrNull import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -68,7 +68,7 @@ public class WorkflowLayout( rootScreen: Screen, environment: ViewEnvironment = ViewEnvironment.EMPTY ) { - showing.show(rootScreen, environment) + showing.show(rootScreen, environment.withOnBackDispatcher()) restoredChildState?.let { restoredState -> restoredChildState = null showing.actual.restoreHierarchyState(restoredState) @@ -127,7 +127,7 @@ public class WorkflowLayout( environment: ViewEnvironment ) { @Suppress("DEPRECATION") - showing.update(newRendering, environment) + showing.update(newRendering, environment.withOnBackDispatcher()) restoredChildState?.let { restoredState -> restoredChildState = null showing.actual.restoreHierarchyState(restoredState) @@ -242,6 +242,22 @@ public class WorkflowLayout( // Make a no-op call. } + /** + * Attempts to seed the [ViewEnvironment] with an [OnBackPressedDispatcherOwnerKey] + * value if one wasn't set already. We're priming the pump that our + * `ViewEnvironment.onBackPressedDispatcherOwner` call relies on. + */ + private fun ViewEnvironment.withOnBackDispatcher(): ViewEnvironment { + val envWithOnBack = if (map.containsKey(OnBackPressedDispatcherOwnerKey)) { + this + } else { + this@WorkflowLayout.onBackPressedDispatcherOwnerOrNull() + ?.let { this@withOnBackDispatcher + (OnBackPressedDispatcherOwnerKey to it) } + ?: this + } + return envWithOnBack + } + private class SavedState : BaseSavedState { constructor( superState: Parcelable?, @@ -251,7 +267,7 @@ public class WorkflowLayout( } constructor(source: Parcel) : super(source) { - this.childState = if (VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) { + this.childState = if (VERSION.SDK_INT >= 33) { source.readSparseArray(SavedState::class.java.classLoader, Parcelable::class.java)!! } else { @Suppress("DEPRECATION") diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/WorkflowViewStub.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/WorkflowViewStub.kt index 4993a6b7fa..26e0b80604 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/WorkflowViewStub.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/WorkflowViewStub.kt @@ -9,6 +9,7 @@ import androidx.annotation.IdRes import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.findViewTreeSavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import com.squareup.workflow1.ui.androidx.WorkflowAndroidXSupport.onBackPressedDispatcherOwner import com.squareup.workflow1.ui.androidx.WorkflowLifecycleOwner /** @@ -231,10 +232,10 @@ public class WorkflowViewStub @JvmOverloads constructor( holder = rendering.toViewFactory(viewEnvironment) .startShowing(rendering, viewEnvironment, parent.context, parent) { view, doStart -> - WorkflowLifecycleOwner.installOn(view) + WorkflowLifecycleOwner.installOn(view, viewEnvironment.onBackPressedDispatcherOwner(parent)) doStart() - }.also { - val newView = it.view + }.apply { + val newView = view if (inflatedId != NO_ID) newView.id = inflatedId if (updatesVisibility) newView.visibility = visibility diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/androidx/WorkflowAndroidXSupport.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/androidx/WorkflowAndroidXSupport.kt index 347b2af8d1..08b2dd66bb 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/androidx/WorkflowAndroidXSupport.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/androidx/WorkflowAndroidXSupport.kt @@ -3,10 +3,14 @@ package com.squareup.workflow1.ui.androidx import android.content.Context import android.content.ContextWrapper import android.view.View +import androidx.activity.OnBackPressedDispatcherOwner +import androidx.activity.findViewTreeOnBackPressedDispatcherOwner import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ViewTreeLifecycleOwner import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.findViewTreeSavedStateRegistryOwner +import com.squareup.workflow1.ui.OnBackPressedDispatcherOwnerKey +import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import kotlin.reflect.KClass import kotlin.reflect.cast @@ -49,6 +53,44 @@ public object WorkflowAndroidXSupport { "Expected to find a SavedStateRegistryOwner either in a parent view or the Context of $view" } + /** + * Looks for an [OnBackPressedDispatcherOwner] in the receiving [ViewEnvironment]. + * Failing that, falls through to [View.onBackPressedDispatcherOwnerOrNull]. + * Patterned after the heuristic in Compose's `LocalOnBackPressedDispatcherOwner`. + * + * Mainly intended as support for finding the [OnBackPressedDispatcherOwner] parameter + * required by [WorkflowLifecycleOwner.installOn]. That is, this is for + * use by custom containers that can't use + * [WorkflowViewStub][com.squareup.workflow1.ui.WorkflowViewStub] or other standard + * containers, which have this call built in. + * + * @throws IllegalArgumentException if no [OnBackPressedDispatcherOwner] can be found + */ + @WorkflowUiExperimentalApi + public fun ViewEnvironment.onBackPressedDispatcherOwner( + container: View + ): OnBackPressedDispatcherOwner { + return (map[OnBackPressedDispatcherOwnerKey] as? OnBackPressedDispatcherOwner) + ?: container.onBackPressedDispatcherOwnerOrNull() + ?: throw IllegalArgumentException( + "Expected to find an OnBackPressedDispatcherOwner in one of: $this " + + "bound to OnBackPressedDispatcherOwnerKey, or " + + "$container via findViewTreeOnBackPressedDispatcherOwner(), or " + + "up the Context chain of that view." + ) + } + + /** + * Looks for a [View]'s [OnBackPressedDispatcherOwner] via the usual + * [findViewTreeOnBackPressedDispatcherOwner] method, and if that fails + * checks its [Context][View.getContext]. + */ + @WorkflowUiExperimentalApi + public fun View.onBackPressedDispatcherOwnerOrNull(): OnBackPressedDispatcherOwner? { + return findViewTreeOnBackPressedDispatcherOwner() + ?: context.ownerOrNull(OnBackPressedDispatcherOwner::class) + } + /** * Tries to get the parent [SavedStateRegistryOwner] from the current view via * [findViewTreeSavedStateRegistryOwner], if that fails it looks up the context chain for a registry diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/androidx/WorkflowLifecycleOwner.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/androidx/WorkflowLifecycleOwner.kt index 7e875eb49c..640a0db251 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/androidx/WorkflowLifecycleOwner.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/androidx/WorkflowLifecycleOwner.kt @@ -2,6 +2,8 @@ package com.squareup.workflow1.ui.androidx import android.view.View import android.view.View.OnAttachStateChangeListener +import androidx.activity.OnBackPressedDispatcherOwner +import androidx.activity.setViewTreeOnBackPressedDispatcherOwner import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting.PRIVATE import androidx.lifecycle.Lifecycle @@ -27,7 +29,8 @@ import com.squareup.workflow1.ui.androidx.WorkflowLifecycleOwner.Companion.insta * This type is meant to help integrate with [ViewTreeLifecycleOwner] by allowing the creation of a * tree of [LifecycleOwner]s that mirrors the view tree. * - * Custom container views that use [ScreenViewFactory.startShowing] to create their children + * Custom container views that use + * [ScreenViewFactory.startShowing][com.squareup.workflow1.ui.startShowing] to create their children * _must_ ensure they call [destroyOnDetach] on the outgoing view before they replace children * with new views. If this is not done, then certain processes that are started by that view's * subtree may continue to run long after the view has been detached, and memory and other @@ -62,6 +65,12 @@ public interface WorkflowLifecycleOwner : LifecycleOwner { * If this is not done, any observers registered with the [Lifecycle] may be leaked as they will * never see the destroy event. * + * @param onBackPressedDispatcherOwner A [OnBackPressedDispatcherOwner] to be installed + * in parallel with the [WorkflowLifecycleOwner], required to ensure that + * [View.findViewTreeOnBackPressedDispatcherOwner][androidx.activity.findViewTreeOnBackPressedDispatcherOwner] + * always works. + * Typical use is to find one via `ViewEnvironment.onBackPressedDispatcherOwner` + * * @param findParentLifecycle A function that is called whenever [view] is attached, and should * return the [Lifecycle] to use as the parent lifecycle. If not specified, defaults to looking * up the view tree by calling [ViewTreeLifecycleOwner.get] on [view]'s parent, and if none is @@ -73,9 +82,11 @@ public interface WorkflowLifecycleOwner : LifecycleOwner { */ public fun installOn( view: View, + onBackPressedDispatcherOwner: OnBackPressedDispatcherOwner, findParentLifecycle: (View) -> Lifecycle = this::findParentViewTreeLifecycle ) { RealWorkflowLifecycleOwner(findParentLifecycle).also { + view.setViewTreeOnBackPressedDispatcherOwner(onBackPressedDispatcherOwner) ViewTreeLifecycleOwner.set(view, it) view.addOnAttachStateChangeListener(it) } diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/BackStackContainer.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/BackStackContainer.kt index cc845c26f1..8006d6a9a2 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/BackStackContainer.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/BackStackContainer.kt @@ -23,6 +23,7 @@ import com.squareup.workflow1.ui.R import com.squareup.workflow1.ui.ScreenViewHolder import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.androidx.WorkflowAndroidXSupport.onBackPressedDispatcherOwner import com.squareup.workflow1.ui.androidx.WorkflowAndroidXSupport.stateRegistryOwnerFromViewTreeOrContext import com.squareup.workflow1.ui.androidx.WorkflowLifecycleOwner import com.squareup.workflow1.ui.canShow @@ -94,7 +95,7 @@ public open class BackStackContainer @JvmOverloads constructor( contextForNewView = this.context, container = this, viewStarter = { view, doStart -> - WorkflowLifecycleOwner.installOn(view) + WorkflowLifecycleOwner.installOn(view, environment.onBackPressedDispatcherOwner(this)) doStart() } ) diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ContentDialogSetContent.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ContentDialogSetContent.kt index d4bddcd3cc..9183f9c50e 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ContentDialogSetContent.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ContentDialogSetContent.kt @@ -4,7 +4,7 @@ import android.graphics.drawable.ColorDrawable import android.util.TypedValue import android.view.WindowManager.LayoutParams.FLAG_DIM_BEHIND import androidx.activity.ComponentDialog -import androidx.activity.setViewTreeOnBackPressedDispatcherOwner +import com.squareup.workflow1.ui.OnBackPressedDispatcherOwnerKey import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi @@ -30,10 +30,10 @@ public fun > ComponentDialog.setContent( overlay: O, environment: ViewEnvironment ): OverlayDialogHolder { - val contentHolder = overlay.content.toViewFactory(environment) - .startShowing(overlay.content, environment, context) { view, doStart -> - view.setViewTreeOnBackPressedDispatcherOwner(this@setContent) - WorkflowLifecycleOwner.installOn(view) { + val envWithOnBack = environment + (OnBackPressedDispatcherOwnerKey to this) + val contentHolder = overlay.content.toViewFactory(envWithOnBack) + .startShowing(overlay.content, envWithOnBack, context) { view, doStart -> + WorkflowLifecycleOwner.installOn(view, this) { this@setContent.lifecycle } doStart() @@ -66,6 +66,9 @@ public fun > ComponentDialog.setContent( environment, this ) { newOverlay, newEnvironment -> - contentHolder.show(newOverlay.content, newEnvironment) + contentHolder.show( + newOverlay.content, + newEnvironment + (OnBackPressedDispatcherOwnerKey to this@setContent) + ) } } diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/DialogSession.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/DialogSession.kt index 5e53c5f263..4408ff7221 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/DialogSession.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/DialogSession.kt @@ -10,13 +10,18 @@ import android.view.KeyEvent.KEYCODE_BACK import android.view.KeyEvent.KEYCODE_ESCAPE import android.view.MotionEvent import android.view.Window.Callback +import androidx.activity.OnBackPressedDispatcher +import androidx.activity.OnBackPressedDispatcherOwner import androidx.core.view.doOnAttach import androidx.core.view.doOnDetach import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import com.squareup.workflow1.ui.Compatible +import com.squareup.workflow1.ui.OnBackPressedDispatcherOwnerKey import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.androidx.WorkflowAndroidXSupport.onBackPressedDispatcherOwnerOrNull import com.squareup.workflow1.ui.androidx.WorkflowLifecycleOwner import com.squareup.workflow1.ui.androidx.WorkflowSavedStateRegistryAggregator @@ -150,12 +155,29 @@ internal class DialogSession( } dialog.decorView.also { decorView -> + // We are more defensive than usual about this to ease migration of existing apps + // to ComponentDialog. Perhaps we will never enforce that rigorously. It really only + // matters for ScreenOverlay, and that's enforced via ComponentDialog.setContent. + // Note that onBackPressedDispatcherOwnerOrNull() searches through Context as well, + // so 99% chance we'll hit the Activity before the stub. + val onBack = (dialog as? OnBackPressedDispatcherOwner) + ?: holder.environment.map[OnBackPressedDispatcherOwnerKey] as? OnBackPressedDispatcherOwner + ?: decorView.onBackPressedDispatcherOwnerOrNull() + ?: object : OnBackPressedDispatcherOwner { + override fun getLifecycle(): Lifecycle = + error("To support back press handling extend ComponentDialog: $dialog") + + override fun getOnBackPressedDispatcher(): OnBackPressedDispatcher = + error("To support back press handling extend ComponentDialog: $dialog") + } + // Implementations of buildDialog may set their own WorkflowLifecycleOwner on the // content view, so to avoid interfering with them we also set it here. When the views // are attached, this will become the parent lifecycle of the one from buildDialog if // any, and so we can use our lifecycle to destroy-on-detach the dialog hierarchy. WorkflowLifecycleOwner.installOn( decorView, + onBack, findParentLifecycle = { parentLifecycleOwner.lifecycle } ) // Ensure that each dialog has its own SavedStateRegistryOwner, diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/OverlayDialogHolder.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/OverlayDialogHolder.kt index ed6dcdc983..fb6947927d 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/OverlayDialogHolder.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/OverlayDialogHolder.kt @@ -4,8 +4,8 @@ import android.app.Dialog import android.graphics.Rect import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.androidx.WorkflowAndroidXSupport.onBackPressedDispatcherOwnerOrNull import com.squareup.workflow1.ui.compatible -import com.squareup.workflow1.ui.onBackPressedDispatcherOwnerOrNull import com.squareup.workflow1.ui.show /** @@ -50,13 +50,11 @@ public interface OverlayDialogHolder { * instead of [Dialog.onBackPressed]. * * The default implementation provided by the factory function below looks for the - * [OnBackPressedDispatcherOwner][com.squareup.workflow1.ui.onBackPressedDispatcherOwnerOrNull] + * [OnBackPressedDispatcherOwner][onBackPressedDispatcherOwnerOrNull] * and invokes its [onBackPressed][androidx.activity.OnBackPressedDispatcher.onBackPressed] * method. */ - @Deprecated( - "This will be deleted in the next release, use ComponentDialog and OnBackPressedDispatcher." - ) + @Deprecated("This will soon be deleted. Use ComponentDialog and OnBackPressedDispatcher.") public val onBackPressed: (() -> Unit)? public companion object { @@ -65,7 +63,7 @@ public interface OverlayDialogHolder { dialog: Dialog, onUpdateBounds: ((Rect) -> Unit)? = { dialog.setBounds(it) }, onBackPressed: (() -> Unit)? = { - dialog.context.onBackPressedDispatcherOwnerOrNull() + dialog.decorView.onBackPressedDispatcherOwnerOrNull() ?.onBackPressedDispatcher ?.let { if (it.hasEnabledCallbacks()) it.onBackPressed() diff --git a/workflow-ui/core-android/src/test/java/com/squareup/workflow1/ui/androidx/WorkflowSavedStateRegistryAggregatorTest.kt b/workflow-ui/core-android/src/test/java/com/squareup/workflow1/ui/androidx/WorkflowSavedStateRegistryAggregatorTest.kt index 068e570a88..32945976f6 100644 --- a/workflow-ui/core-android/src/test/java/com/squareup/workflow1/ui/androidx/WorkflowSavedStateRegistryAggregatorTest.kt +++ b/workflow-ui/core-android/src/test/java/com/squareup/workflow1/ui/androidx/WorkflowSavedStateRegistryAggregatorTest.kt @@ -2,6 +2,8 @@ package com.squareup.workflow1.ui.androidx import android.os.Bundle import android.view.View +import androidx.activity.OnBackPressedDispatcher +import androidx.activity.OnBackPressedDispatcherOwner import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle.Event.ON_CREATE import androidx.lifecycle.Lifecycle.State.RESUMED @@ -22,6 +24,10 @@ import kotlin.test.assertFailsWith @RunWith(RobolectricTestRunner::class) @OptIn(WorkflowUiExperimentalApi::class) internal class WorkflowSavedStateRegistryAggregatorTest { + private val fakeOnBack = object : OnBackPressedDispatcherOwner { + override fun getLifecycle(): Lifecycle = error("") + override fun getOnBackPressedDispatcher(): OnBackPressedDispatcher = error("") + } @Test fun `attach stops observing previous parent when called multiple times without detach`() { val aggregator = WorkflowSavedStateRegistryAggregator() @@ -68,7 +74,7 @@ internal class WorkflowSavedStateRegistryAggregatorTest { @Test fun `install throws on redundant call`() { val view = View(ApplicationProvider.getApplicationContext()).apply { this.setViewTreeSavedStateRegistryOwner(SimpleStateRegistry()) - WorkflowLifecycleOwner.installOn(this) + WorkflowLifecycleOwner.installOn(this, fakeOnBack) } val aggregator = WorkflowSavedStateRegistryAggregator() @@ -148,7 +154,7 @@ internal class WorkflowSavedStateRegistryAggregatorTest { stateRegistryController.performRestore(null) } val childView = View(ApplicationProvider.getApplicationContext()).apply { - WorkflowLifecycleOwner.installOn(this) { parent.lifecycle } + WorkflowLifecycleOwner.installOn(this, fakeOnBack) { parent.lifecycle } } var childSaveCount = 0 @@ -173,7 +179,7 @@ internal class WorkflowSavedStateRegistryAggregatorTest { stateRegistryController.performRestore(null) } val childView = View(ApplicationProvider.getApplicationContext()).apply { - WorkflowLifecycleOwner.installOn(this) { parent.lifecycle } + WorkflowLifecycleOwner.installOn(this, fakeOnBack) { parent.lifecycle } } var childSaveCount = 0 @@ -197,7 +203,7 @@ internal class WorkflowSavedStateRegistryAggregatorTest { stateRegistryController.performRestore(null) } val childView = View(ApplicationProvider.getApplicationContext()).apply { - WorkflowLifecycleOwner.installOn(this) { parent.lifecycle } + WorkflowLifecycleOwner.installOn(this, fakeOnBack) { parent.lifecycle } } aggregator.installChildRegistryOwnerOn(childView, "childKey") assertThat(childView.savedStateRegistry.isRestored).isFalse() @@ -222,7 +228,7 @@ internal class WorkflowSavedStateRegistryAggregatorTest { // Store some data in the system. val viewToSave = View(ApplicationProvider.getApplicationContext()).apply { - WorkflowLifecycleOwner.installOn(this) { parentToSave.lifecycle } + WorkflowLifecycleOwner.installOn(this, fakeOnBack) { parentToSave.lifecycle } } aggregatorToSave.installChildRegistryOwnerOn(viewToSave, "childKey") viewToSave.savedStateRegistry.registerSavedStateProvider("key") { bundleOf("data" to "value") } @@ -237,7 +243,7 @@ internal class WorkflowSavedStateRegistryAggregatorTest { } aggregatorToRestore.attachToParentRegistry("parentKey", parentToRestore) val viewToRestore = View(ApplicationProvider.getApplicationContext()).apply { - WorkflowLifecycleOwner.installOn(this) { parentToRestore.lifecycle } + WorkflowLifecycleOwner.installOn(this, fakeOnBack) { parentToRestore.lifecycle } } aggregatorToRestore.installChildRegistryOwnerOn(viewToRestore, "childKey") parentToRestore.lifecycleRegistry.currentState = RESUMED @@ -263,7 +269,7 @@ internal class WorkflowSavedStateRegistryAggregatorTest { parent.lifecycleRegistry.handleLifecycleEvent(ON_CREATE) val childView = View(ApplicationProvider.getApplicationContext()).apply { - WorkflowLifecycleOwner.installOn(this) { parent.lifecycle } + WorkflowLifecycleOwner.installOn(this, fakeOnBack) { parent.lifecycle } } aggregator.installChildRegistryOwnerOn(childView, "childKey")