diff --git a/workflow-ui/compose-tooling/dependencies/releaseRuntimeClasspath.txt b/workflow-ui/compose-tooling/dependencies/releaseRuntimeClasspath.txt index d6da66f365..51c1ca2b68 100644 --- a/workflow-ui/compose-tooling/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-ui/compose-tooling/dependencies/releaseRuntimeClasspath.txt @@ -3,6 +3,7 @@ :workflow-ui:compose :workflow-ui:core-android :workflow-ui:core-common +androidx.activity:activity-compose:1.6.1 androidx.activity:activity-ktx:1.6.1 androidx.activity:activity:1.6.1 androidx.annotation:annotation-experimental:1.1.0 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/dependencies/releaseRuntimeClasspath.txt b/workflow-ui/compose/dependencies/releaseRuntimeClasspath.txt index 833e3d383b..3ba04f8bb7 100644 --- a/workflow-ui/compose/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-ui/compose/dependencies/releaseRuntimeClasspath.txt @@ -2,6 +2,7 @@ :workflow-runtime :workflow-ui:core-android :workflow-ui:core-common +androidx.activity:activity-compose:1.6.1 androidx.activity:activity-ktx:1.6.1 androidx.activity:activity:1.6.1 androidx.annotation:annotation-experimental:1.1.0 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 8257786a02..d72e094a0d 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/ComponentDialogSetContent.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ComponentDialogSetContent.kt index 96dc07d241..fdd3cebb76 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ComponentDialogSetContent.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ComponentDialogSetContent.kt @@ -5,7 +5,7 @@ import android.util.TypedValue import android.view.WindowManager.LayoutParams.FLAG_DIM_BEHIND import android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL 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 @@ -19,7 +19,7 @@ import com.squareup.workflow1.ui.toViewFactory * the Dialog's content via instances of a particular type of [ScreenOverlay]. * * Dialogs managed this way are compatible with - * [View.setBackHandler][com.squareup.workflow1.ui.setBackHandler], + * [View.backPressedHandler][com.squareup.workflow1.ui.backPressedHandler], * and honor the [OverlayArea] and [CoveredByModal] values placed in * the [ViewEnvironment] by the standard [BodyAndOverlaysScreen] container. */ @@ -33,10 +33,10 @@ public fun > ComponentDialog.setContent( // the appropriate bounds, but Android makes them block everywhere. requireNotNull(window) { "Expected to find a window for $this." }.addFlags(FLAG_NOT_TOUCH_MODAL) - 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() @@ -62,10 +62,17 @@ public fun > ComponentDialog.setContent( clearFlags(FLAG_DIM_BEHIND) } + // Note that we set onBackPressed to null, so that the implementation built + // into ComponentDialog will be used. Our default implementation is a shabby + // imitation of that one, and is going to be removed soon. return OverlayDialogHolder( initialEnvironment = environment, dialog = this, + onBackPressed = null ) { 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")