diff --git a/samples/containers/hello-back-button/src/androidTest/java/com/squareup/sample/hellobackbutton/HelloBackButtonEspressoTest.kt b/samples/containers/hello-back-button/src/androidTest/java/com/squareup/sample/hellobackbutton/HelloBackButtonEspressoTest.kt index f3e7ec5607..3b4edbff71 100644 --- a/samples/containers/hello-back-button/src/androidTest/java/com/squareup/sample/hellobackbutton/HelloBackButtonEspressoTest.kt +++ b/samples/containers/hello-back-button/src/androidTest/java/com/squareup/sample/hellobackbutton/HelloBackButtonEspressoTest.kt @@ -27,6 +27,14 @@ class HelloBackButtonEspressoTest { .around(IdlingDispatcherRule) @Test fun wrappedTakesPrecedence() { + // The root workflow (AreYouSureWorkflow) wraps its child renderings + // (instances of HelloBackButtonScreen) in its own BackButtonScreen, + // which shows the "Are you sure" dialog. + // That should only be in effect on the Able screen, which sets no backHandler of its + // own. The Baker and Charlie screens set their own backHandlers, + // which should take precedence over the root one. Thus, we should + // be able to push to Charlie and pop all the way back to Able + // without seeing the "Are you sure" dialog. onView(withId(R.id.hello_message)).apply { check(matches(withText("Able"))) perform(click()) diff --git a/samples/containers/hello-back-button/src/main/java/com/squareup/sample/hellobackbutton/AreYouSureWorkflow.kt b/samples/containers/hello-back-button/src/main/java/com/squareup/sample/hellobackbutton/AreYouSureWorkflow.kt index 546cc0f343..c18c08c9c6 100644 --- a/samples/containers/hello-back-button/src/main/java/com/squareup/sample/hellobackbutton/AreYouSureWorkflow.kt +++ b/samples/containers/hello-back-button/src/main/java/com/squareup/sample/hellobackbutton/AreYouSureWorkflow.kt @@ -68,7 +68,7 @@ object AreYouSureWorkflow : BackButtonScreen(ableBakerCharlie) { // While we always provide a back button handler, by default the view code // associated with BackButtonScreen ignores ours if the view created for the - // wrapped rendering sets a handler of its own. (Set BackButtonScreen.override + // wrapped rendering sets a handler of its own. (Set BackButtonScreen.shadow // to change this precedence.) context.actionSink.send(maybeQuit) } diff --git a/samples/containers/hello-back-button/src/main/java/com/squareup/sample/hellobackbutton/HelloBackButtonScreen.kt b/samples/containers/hello-back-button/src/main/java/com/squareup/sample/hellobackbutton/HelloBackButtonScreen.kt index 46ba78f537..fe00971b2d 100644 --- a/samples/containers/hello-back-button/src/main/java/com/squareup/sample/hellobackbutton/HelloBackButtonScreen.kt +++ b/samples/containers/hello-back-button/src/main/java/com/squareup/sample/hellobackbutton/HelloBackButtonScreen.kt @@ -6,7 +6,7 @@ import com.squareup.sample.hellobackbutton.databinding.HelloBackButtonLayoutBind import com.squareup.workflow1.ui.AndroidScreen import com.squareup.workflow1.ui.ScreenViewFactory import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.backPressedHandler +import com.squareup.workflow1.ui.setBackHandler @OptIn(WorkflowUiExperimentalApi::class) data class HelloBackButtonScreen( @@ -18,6 +18,6 @@ data class HelloBackButtonScreen( ScreenViewFactory.fromViewBinding(HelloBackButtonLayoutBinding::inflate) { rendering, _ -> helloMessage.text = rendering.message helloMessage.setOnClickListener { rendering.onClick() } - helloMessage.backPressedHandler = rendering.onBackPressed + helloMessage.setBackHandler(rendering.onBackPressed) } } diff --git a/samples/containers/poetry/src/main/java/com/squareup/sample/poetry/StanzaListScreen.kt b/samples/containers/poetry/src/main/java/com/squareup/sample/poetry/StanzaListScreen.kt index 9f415fab3c..b94624f866 100644 --- a/samples/containers/poetry/src/main/java/com/squareup/sample/poetry/StanzaListScreen.kt +++ b/samples/containers/poetry/src/main/java/com/squareup/sample/poetry/StanzaListScreen.kt @@ -1,5 +1,6 @@ package com.squareup.sample.poetry +import android.annotation.SuppressLint import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -15,9 +16,9 @@ import com.squareup.workflow1.ui.ScreenViewFactory import com.squareup.workflow1.ui.ScreenViewRunner import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.backPressedHandler import com.squareup.workflow1.ui.container.BackStackConfig import com.squareup.workflow1.ui.container.BackStackConfig.Other +import com.squareup.workflow1.ui.setBackHandler @OptIn(WorkflowUiExperimentalApi::class) data class StanzaListScreen( @@ -40,6 +41,7 @@ private class StanzaListLayoutRunner(view: View) : ScreenViewRunner= 0) recyclerView.scrollToPosition(rendering.selection) diff --git a/samples/containers/poetry/src/main/java/com/squareup/sample/poetry/StanzaScreen.kt b/samples/containers/poetry/src/main/java/com/squareup/sample/poetry/StanzaScreen.kt index f5a2c069c0..a0273905b6 100644 --- a/samples/containers/poetry/src/main/java/com/squareup/sample/poetry/StanzaScreen.kt +++ b/samples/containers/poetry/src/main/java/com/squareup/sample/poetry/StanzaScreen.kt @@ -17,9 +17,9 @@ import com.squareup.workflow1.ui.ScreenViewFactory import com.squareup.workflow1.ui.ScreenViewRunner import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.backPressedHandler import com.squareup.workflow1.ui.container.BackStackConfig import com.squareup.workflow1.ui.container.BackStackConfig.None +import com.squareup.workflow1.ui.setBackHandler @OptIn(WorkflowUiExperimentalApi::class) data class StanzaScreen( @@ -93,8 +93,10 @@ private class StanzaLayoutRunner(private val view: View) : ScreenViewRunner) { diff --git a/samples/dungeon/timemachine-shakeable/src/main/java/com/squareup/sample/timemachine/shakeable/ShakeableTimeMachineLayoutRunner.kt b/samples/dungeon/timemachine-shakeable/src/main/java/com/squareup/sample/timemachine/shakeable/ShakeableTimeMachineLayoutRunner.kt index 5e646d695e..363fcf46d1 100644 --- a/samples/dungeon/timemachine-shakeable/src/main/java/com/squareup/sample/timemachine/shakeable/ShakeableTimeMachineLayoutRunner.kt +++ b/samples/dungeon/timemachine-shakeable/src/main/java/com/squareup/sample/timemachine/shakeable/ShakeableTimeMachineLayoutRunner.kt @@ -13,7 +13,7 @@ import com.squareup.workflow1.ui.ScreenViewRunner import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.WorkflowViewStub -import com.squareup.workflow1.ui.backPressedHandler +import com.squareup.workflow1.ui.setBackHandler import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.ExperimentalTime @@ -44,7 +44,7 @@ class ShakeableTimeMachineLayoutRunner( environment: ViewEnvironment ) { // Only handle back presses explicitly if in playback mode. - view.backPressedHandler = rendering.onResumeRecording.takeUnless { rendering.recording } + view.setBackHandler(rendering.onResumeRecording.takeUnless { rendering.recording }) seek.max = rendering.totalDuration.toProgressInt() seek.progress = rendering.playbackPosition.toProgressInt() diff --git a/samples/tictactoe/app/src/main/java/com/squareup/sample/authworkflow/LoginViewFactory.kt b/samples/tictactoe/app/src/main/java/com/squareup/sample/authworkflow/LoginViewFactory.kt index 926e34a937..48a16af737 100644 --- a/samples/tictactoe/app/src/main/java/com/squareup/sample/authworkflow/LoginViewFactory.kt +++ b/samples/tictactoe/app/src/main/java/com/squareup/sample/authworkflow/LoginViewFactory.kt @@ -4,7 +4,7 @@ import com.squareup.sample.tictactoe.databinding.LoginLayoutBinding import com.squareup.workflow1.ui.ScreenViewFactory import com.squareup.workflow1.ui.ScreenViewFactory.Companion.fromViewBinding import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.backPressedHandler +import com.squareup.workflow1.ui.setBackHandler @OptIn(WorkflowUiExperimentalApi::class) internal val LoginViewFactory: ScreenViewFactory = @@ -15,5 +15,5 @@ internal val LoginViewFactory: ScreenViewFactory = rendering.onLogin(loginEmail.text.toString(), loginPassword.text.toString()) } - root.backPressedHandler = { rendering.onCancel() } + root.setBackHandler(rendering.onCancel) } diff --git a/samples/tictactoe/app/src/main/java/com/squareup/sample/authworkflow/SecondFactorViewFactory.kt b/samples/tictactoe/app/src/main/java/com/squareup/sample/authworkflow/SecondFactorViewFactory.kt index ced7617eeb..30feb2bed2 100644 --- a/samples/tictactoe/app/src/main/java/com/squareup/sample/authworkflow/SecondFactorViewFactory.kt +++ b/samples/tictactoe/app/src/main/java/com/squareup/sample/authworkflow/SecondFactorViewFactory.kt @@ -4,12 +4,12 @@ import com.squareup.sample.tictactoe.databinding.SecondFactorLayoutBinding import com.squareup.workflow1.ui.ScreenViewFactory import com.squareup.workflow1.ui.ScreenViewFactory.Companion.fromViewBinding import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.backPressedHandler +import com.squareup.workflow1.ui.setBackHandler @OptIn(WorkflowUiExperimentalApi::class) internal val SecondFactorViewFactory: ScreenViewFactory = fromViewBinding(SecondFactorLayoutBinding::inflate) { rendering, _ -> - root.backPressedHandler = { rendering.onCancel() } + root.setBackHandler(rendering.onCancel) secondFactorToolbar.setNavigationOnClickListener { rendering.onCancel() } secondFactorErrorMessage.text = rendering.errorMessage diff --git a/samples/tictactoe/app/src/main/java/com/squareup/sample/gameworkflow/GameOverLayoutRunner.kt b/samples/tictactoe/app/src/main/java/com/squareup/sample/gameworkflow/GameOverLayoutRunner.kt index 96616916e4..f9ae5de207 100644 --- a/samples/tictactoe/app/src/main/java/com/squareup/sample/gameworkflow/GameOverLayoutRunner.kt +++ b/samples/tictactoe/app/src/main/java/com/squareup/sample/gameworkflow/GameOverLayoutRunner.kt @@ -15,7 +15,7 @@ import com.squareup.workflow1.ui.ScreenViewFactory.Companion.fromViewBinding import com.squareup.workflow1.ui.ScreenViewRunner import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.backPressedHandler +import com.squareup.workflow1.ui.setBackHandler @OptIn(WorkflowUiExperimentalApi::class) internal class GameOverLayoutRunner( @@ -40,7 +40,7 @@ internal class GameOverLayoutRunner( rendering.onPlayAgain() true } - binding.root.backPressedHandler = { rendering.onExit() } + binding.root.setBackHandler(rendering.onExit) when (rendering.endGameState.syncState) { SAVING -> { @@ -48,6 +48,7 @@ internal class GameOverLayoutRunner( saveItem.title = "saving…" saveItem.setOnMenuItemClickListener(null) } + SAVE_FAILED -> { saveItem.isEnabled = true saveItem.title = "Unsaved" @@ -56,6 +57,7 @@ internal class GameOverLayoutRunner( true } } + SAVED -> { saveItem.isVisible = false saveItem.setOnMenuItemClickListener(null) diff --git a/samples/tictactoe/app/src/main/java/com/squareup/sample/gameworkflow/GamePlayViewFactory.kt b/samples/tictactoe/app/src/main/java/com/squareup/sample/gameworkflow/GamePlayViewFactory.kt index 85d4160cce..55433a74a8 100644 --- a/samples/tictactoe/app/src/main/java/com/squareup/sample/gameworkflow/GamePlayViewFactory.kt +++ b/samples/tictactoe/app/src/main/java/com/squareup/sample/gameworkflow/GamePlayViewFactory.kt @@ -6,7 +6,7 @@ import com.squareup.sample.tictactoe.databinding.GamePlayLayoutBinding import com.squareup.workflow1.ui.ScreenViewFactory import com.squareup.workflow1.ui.ScreenViewFactory.Companion.fromViewBinding import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.backPressedHandler +import com.squareup.workflow1.ui.setBackHandler @OptIn(WorkflowUiExperimentalApi::class) internal val GamePlayViewFactory: ScreenViewFactory = @@ -15,7 +15,7 @@ internal val GamePlayViewFactory: ScreenViewFactory = rendering.gameState.board.render(gamePlayBoard.root) setCellClickListeners(gamePlayBoard.root, rendering.gameState, rendering.onClick) - root.backPressedHandler = rendering.onQuit + root.setBackHandler(rendering.onQuit) } private fun setCellClickListeners( diff --git a/samples/tictactoe/app/src/main/java/com/squareup/sample/gameworkflow/NewGameViewFactory.kt b/samples/tictactoe/app/src/main/java/com/squareup/sample/gameworkflow/NewGameViewFactory.kt index 5279abcd54..afb44d84d6 100644 --- a/samples/tictactoe/app/src/main/java/com/squareup/sample/gameworkflow/NewGameViewFactory.kt +++ b/samples/tictactoe/app/src/main/java/com/squareup/sample/gameworkflow/NewGameViewFactory.kt @@ -4,7 +4,7 @@ import com.squareup.sample.tictactoe.databinding.NewGameLayoutBinding import com.squareup.workflow1.ui.ScreenViewFactory import com.squareup.workflow1.ui.ScreenViewFactory.Companion.fromViewBinding import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.backPressedHandler +import com.squareup.workflow1.ui.setBackHandler @OptIn(WorkflowUiExperimentalApi::class) internal val NewGameViewFactory: ScreenViewFactory = @@ -16,5 +16,5 @@ internal val NewGameViewFactory: ScreenViewFactory = rendering.onStartGame(playerX.text.toString(), playerO.text.toString()) } - root.backPressedHandler = { rendering.onCancel() } + root.setBackHandler(rendering.onCancel) } diff --git a/samples/todo-android/app/src/main/java/com/squareup/sample/todo/TodoEditorScreen.kt b/samples/todo-android/app/src/main/java/com/squareup/sample/todo/TodoEditorScreen.kt index e36c769825..04307b28ed 100644 --- a/samples/todo-android/app/src/main/java/com/squareup/sample/todo/TodoEditorScreen.kt +++ b/samples/todo-android/app/src/main/java/com/squareup/sample/todo/TodoEditorScreen.kt @@ -12,7 +12,7 @@ import com.squareup.workflow1.ui.ScreenViewFactory import com.squareup.workflow1.ui.ScreenViewRunner import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.backPressedHandler +import com.squareup.workflow1.ui.setBackHandler import com.squareup.workflow1.ui.container.BackStackConfig import com.squareup.workflow1.ui.container.BackStackConfig.Other import com.squareup.workflow1.ui.control @@ -62,7 +62,7 @@ private class Runner( if (environment[BackStackConfig] == Other) { todoEditorToolbar.setNavigationOnClickListener { rendering.onGoBackClicked() } - root.backPressedHandler = { rendering.onGoBackClicked() } + root.setBackHandler(rendering.onGoBackClicked) } else { todoEditorToolbar.navigationIcon = null } 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 f8add72d8c..223b7a12ee 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,7 +18,9 @@ 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.onBackPressedDispatcherOwner import com.squareup.workflow1.ui.androidx.WorkflowAndroidXSupport.onBackPressedDispatcherOwnerOrNull +import com.squareup.workflow1.ui.androidx.WorkflowLifecycleOwner import com.squareup.workflow1.ui.asScreen import com.squareup.workflow1.ui.bindShowRendering import com.squareup.workflow1.ui.container.BackButtonScreen @@ -79,7 +81,17 @@ public open class ModalViewContainer @JvmOverloads constructor( initialEnvironment = initialViewEnvironment, contextForNewView = this.context, container = this - ) + ) { view, doStart -> + // Note that we never call destroyOnDetach for this owner. That's okay because + // ModalContainer.update puts one in place above us on the decor view, + // and cleans it up. It's in place by the time we attach to the window, and + // so becomes our parent. + WorkflowLifecycleOwner.installOn( + view, + initialViewEnvironment.onBackPressedDispatcherOwner(view) + ) + doStart() + } return buildDialogForView(viewHolder.view) .apply { diff --git a/workflow-ui/core-android/api/core-android.api b/workflow-ui/core-android/api/core-android.api index 568598f2eb..a191ae04a1 100644 --- a/workflow-ui/core-android/api/core-android.api +++ b/workflow-ui/core-android/api/core-android.api @@ -162,6 +162,12 @@ public final class com/squareup/workflow1/ui/TextControllerControlEditTextKt { public static final fun control (Lcom/squareup/workflow1/ui/TextController;Landroid/widget/EditText;)V } +public final class com/squareup/workflow1/ui/ViewBackHandlerKt { + public static final fun setBackHandler (Landroid/view/View;Lkotlin/jvm/functions/Function0;)V + public static final fun setBackHandler (Landroid/view/View;ZLkotlin/jvm/functions/Function0;)V + public static synthetic fun setBackHandler$default (Landroid/view/View;ZLkotlin/jvm/functions/Function0;ILjava/lang/Object;)V +} + public final class com/squareup/workflow1/ui/ViewBindingScreenViewFactory : com/squareup/workflow1/ui/ScreenViewFactory { public fun (Lkotlin/reflect/KClass;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;)V public fun buildView (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/content/Context;Landroid/view/ViewGroup;)Lcom/squareup/workflow1/ui/ScreenViewHolder; diff --git a/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/BackPressedHandlerTest.kt b/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/BackPressedHandlerTest.kt index 62ec7f9623..42bc760d42 100644 --- a/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/BackPressedHandlerTest.kt +++ b/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/BackPressedHandlerTest.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package com.squareup.workflow1.ui import android.view.View diff --git a/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/ViewBackHandlerTest.kt b/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/ViewBackHandlerTest.kt new file mode 100644 index 0000000000..e0b6fa0d7a --- /dev/null +++ b/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/ViewBackHandlerTest.kt @@ -0,0 +1,54 @@ +package com.squareup.workflow1.ui + +import android.view.View +import androidx.activity.ComponentActivity +import androidx.test.ext.junit.rules.ActivityScenarioRule +import com.google.common.truth.Truth.assertThat +import com.squareup.workflow1.ui.androidx.WorkflowLifecycleOwner +import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule +import leakcanary.DetectLeaksAfterTestSuccess +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain + +@OptIn(WorkflowUiExperimentalApi::class) +internal class ViewBackHandlerTest { + private val scenarioRule = ActivityScenarioRule(ComponentActivity::class.java) + private val scenario get() = scenarioRule.scenario + + @get:Rule val rules: RuleChain = RuleChain.outerRule(DetectLeaksAfterTestSuccess()) + .around(scenarioRule) + .around(IdlingDispatcherRule) + + private var viewHandlerCount = 0 + private fun viewBackHandler() { + viewHandlerCount++ + } + + @Test fun itWorksWhenHandlerIsAddedBeforeAttach() { + scenario.onActivity { activity -> + val view = View(activity) + WorkflowLifecycleOwner.installOn(view, activity) + view.setBackHandler { viewBackHandler() } + + activity.setContentView(view) + assertThat(viewHandlerCount).isEqualTo(0) + + activity.onBackPressedDispatcher.onBackPressed() + assertThat(viewHandlerCount).isEqualTo(1) + } + } + + @Test fun itWorksWhenHandlerIsAddedAfterAttach() { + scenario.onActivity { activity -> + val view = View(activity) + activity.setContentView(view) + + view.setBackHandler { viewBackHandler() } + assertThat(viewHandlerCount).isEqualTo(0) + + activity.onBackPressedDispatcher.onBackPressed() + assertThat(viewHandlerCount).isEqualTo(1) + } + } +} 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 faedd4819a..9e0ab11254 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 @@ -13,6 +13,7 @@ import com.squareup.workflow1.ui.androidx.WorkflowAndroidXSupport.onBackPressedD * A function passed to [View.backPressedHandler], to be called if the back * button is pressed while that view is attached to a window. */ +@Deprecated("Use View.backHandler()") @WorkflowUiExperimentalApi public typealias BackPressHandler = () -> Unit @@ -24,7 +25,9 @@ public typealias BackPressHandler = () -> Unit * That means that this is a last-registered-first-served mechanism, and that it is * compatible with Compose back button handling. */ +@Suppress("DEPRECATION") @WorkflowUiExperimentalApi +@Deprecated("Use setOrClearBackHandler") public var View.backPressedHandler: BackPressHandler? get() = observerOrNull?.handler set(value) { @@ -37,9 +40,9 @@ public var View.backPressedHandler: BackPressHandler? @WorkflowUiExperimentalApi private var View.observerOrNull: AttachStateAndLifecycleObserver? - get() = getTag(R.id.view_back_handler) as AttachStateAndLifecycleObserver? + get() = getTag(R.id.view_deprecated_back_handler) as AttachStateAndLifecycleObserver? set(value) { - setTag(R.id.view_back_handler, value) + setTag(R.id.view_deprecated_back_handler, value) } /** @@ -79,7 +82,7 @@ private var View.observerOrNull: AttachStateAndLifecycleObserver? @WorkflowUiExperimentalApi private class AttachStateAndLifecycleObserver( private val view: View, - val handler: BackPressHandler + @Suppress("DEPRECATION") val handler: BackPressHandler ) : OnAttachStateChangeListener, DefaultLifecycleObserver { private val onBackPressedCallback = NullableOnBackPressedCallback() private var lifecycleOrNull: Lifecycle? = null @@ -130,6 +133,7 @@ private class AttachStateAndLifecycleObserver( @WorkflowUiExperimentalApi internal class NullableOnBackPressedCallback : OnBackPressedCallback(false) { + @Suppress("DEPRECATION") var handlerOrNull: BackPressHandler? = null override fun handleOnBackPressed() { diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ViewBackHandler.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ViewBackHandler.kt new file mode 100644 index 0000000000..cfe2cee40f --- /dev/null +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ViewBackHandler.kt @@ -0,0 +1,72 @@ +package com.squareup.workflow1.ui + +import android.view.View +import androidx.activity.OnBackPressedCallback +import androidx.lifecycle.ViewTreeLifecycleOwner +import com.squareup.workflow1.ui.androidx.WorkflowAndroidXSupport.onBackPressedDispatcherOwnerOrNull + +/** + * A function to be called if the device back button is pressed while this + * view is active, as determined by its [ViewTreeLifecycleOwner], via + * an [OnBackPressedCallback]. On succeeding calls, the previously created + * [OnBackPressedCallback] will be updated, and will maintain its position + * in the [OnBackPressedDispatcher][androidx.activity.OnBackPressedDispatcher] + * priority queue. + * + * @param enabled updates the [OnBackPressedCallback.isEnabled] value + * + * @param onBack the function to run from [OnBackPressedCallback.handleOnBackPressed] + */ +@WorkflowUiExperimentalApi +public fun View.setBackHandler( + enabled: Boolean = true, + onBack: () -> Unit +) { + val callback = onBackPressedCallbackOrNull ?: MutableOnBackPressedCallback().apply { + onBackPressedCallbackOrNull = this + + val dispatcher = requireNotNull(onBackPressedDispatcherOwnerOrNull()?.onBackPressedDispatcher) { + "Unable to find a onBackPressedDispatcherOwner for ${this@setBackHandler}." + } + val lifecycleOwner = requireNotNull(ViewTreeLifecycleOwner.get(this@setBackHandler)) { + "Unable to find a ViewTreeLifecycleOwner for ${this@setBackHandler}." + } + + dispatcher.addCallback(lifecycleOwner, this) + } + callback.isEnabled = enabled + callback.handler = onBack +} + +/** + * Wrapper for the two arg variant of [setBackHandler], a convenience for the + * common pattern of using a nullable function as the back handler to indicate + * that back handling should be disabled. + * + * @param onBack the handler function to run when the device back button is tapped / + * back gesture is made. If null, the relevant [OnBackPressedCallback] will be disabled, + * but it will still exist -- this [View]'s priority in the + * [OnBackPressedDispatcher][androidx.activity.OnBackPressedDispatcher] queue + * will not change, should a non-null handler be provided by a later call. + */ +@WorkflowUiExperimentalApi +public fun View.setBackHandler(onBack: (() -> Unit)?) { + onBack?.let { setBackHandler(enabled = true, it) } + ?: setBackHandler(enabled = false) {} +} + +@WorkflowUiExperimentalApi +private var View.onBackPressedCallbackOrNull: MutableOnBackPressedCallback? + get() = getTag(R.id.view_back_handler) as MutableOnBackPressedCallback? + set(value) { + setTag(R.id.view_back_handler, value) + } + +@WorkflowUiExperimentalApi +private class MutableOnBackPressedCallback : OnBackPressedCallback(false) { + var handler: () -> Unit = {} + + override fun handleOnBackPressed() { + handler() + } +} diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/BackButtonScreen.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/BackButtonScreen.kt index 45b919fd45..308ec4bbb2 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/BackButtonScreen.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/BackButtonScreen.kt @@ -1,18 +1,19 @@ package com.squareup.workflow1.ui.container import com.squareup.workflow1.ui.AndroidScreen +import com.squareup.workflow1.ui.Compatible.Companion.keyFor import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.ScreenViewFactory import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.Wrapper -import com.squareup.workflow1.ui.backPressedHandler +import com.squareup.workflow1.ui.setBackHandler /** * Adds optional back button handling to a [content] rendering, possibly overriding that * the wrapped rendering's own back button handler. * * @param shadow If `true`, [onBackPressed] is set as the - * [backPressedHandler][android.view.View.backPressedHandler] after + * [backPressedHandler][android.view.View.setBackHandler] after * the [content] rendering's view is built / updated, effectively overriding it. * If false (the default), [onBackPressed] is set afterward, to allow the wrapped rendering to * take precedence if it sets a `backPressedHandler` of its own -- the handler provided @@ -28,16 +29,19 @@ public class BackButtonScreen( public val shadow: Boolean = false, public val onBackPressed: (() -> Unit)? = null ) : Wrapper, AndroidScreen> { + // If they change the shadow value, we need to build a new view to reorder the handlers. + override val compatibilityKey: String = keyFor(content, "BackButtonScreen+shadow:$shadow") + override fun map(transform: (C) -> D): BackButtonScreen = BackButtonScreen(transform(content), shadow, onBackPressed) override val viewFactory: ScreenViewFactory> = ScreenViewFactory.forWrapper { view, backButtonScreen, env, showContent -> if (!backButtonScreen.shadow) { - // Place our handler before invoking innerShowRendering, so that + // Place our handler before invoking showContent, so that // its later calls to view.backPressedHandler will take precedence // over ours. - view.backPressedHandler = backButtonScreen.onBackPressed + view.setBackHandler(backButtonScreen.onBackPressed) } // Show the content Screen. @@ -45,7 +49,7 @@ public class BackButtonScreen( if (backButtonScreen.shadow) { // Place our handler after invoking innerShowRendering, so that ours wins. - view.backPressedHandler = backButtonScreen.onBackPressed + view.setBackHandler(backButtonScreen.onBackPressed) } } 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 1972ddb93d..d568326f66 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 @@ -30,9 +30,11 @@ public fun > ComponentDialog.setContent( 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 - } + // Note that we never call destroyOnDetach for this owner. That's okay because + // DialogSession.showNewDialog puts one in place above us on the decor view, + // and cleans it up. It's in place by the time we attach to the window, and + // so becomes our parent. + WorkflowLifecycleOwner.installOn(view = view, onBackPressedDispatcherOwner = this) doStart() } diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ScreenOverlayDialogFactory.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ScreenOverlayDialogFactory.kt index cd818d943a..9c448428ae 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ScreenOverlayDialogFactory.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ScreenOverlayDialogFactory.kt @@ -12,7 +12,9 @@ import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.ScreenViewHolder import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.backPressedHandler +import com.squareup.workflow1.ui.androidx.WorkflowAndroidXSupport.onBackPressedDispatcherOwner +import com.squareup.workflow1.ui.androidx.WorkflowLifecycleOwner +import com.squareup.workflow1.ui.setBackHandler import com.squareup.workflow1.ui.show import com.squareup.workflow1.ui.startShowing import com.squareup.workflow1.ui.toViewFactory @@ -39,12 +41,12 @@ public open class ScreenOverlayDialogFactory>( val modal = initialRendering is ModalOverlay return OverlayDialogHolder(initialEnvironment, dialog) { overlayRendering, environment -> - // For a modal, on each update put a no-op backPressedHandler in place on the + // For a modal, on each update put a no-op backHandler in place on the // decorView before updating, to ensure that the global androidx // OnBackPressedDispatcher doesn't fire any set by lower layers. We put this // in place before each call to show(), so the real content view will be able // to clobber it. - if (modal) content.view.backPressedHandler = {} + if (modal) content.view.setBackHandler {} content.show(overlayRendering.content, environment) } } @@ -59,7 +61,17 @@ public open class ScreenOverlayDialogFactory>( context: Context ): OverlayDialogHolder { val contentViewHolder = initialRendering.content.toViewFactory(initialEnvironment) - .startShowing(initialRendering.content, initialEnvironment, context) + .startShowing(initialRendering.content, initialEnvironment, context) { view, doStart -> + // Note that we never call destroyOnDetach for this owner. That's okay because + // DialogSession.showNewDialog puts one in place above us on the decor view, + // and cleans it up. It's in place by the time we attach to the window, and + // so becomes our parent. + WorkflowLifecycleOwner.installOn( + view, + initialEnvironment.onBackPressedDispatcherOwner(view) + ) + doStart() + } return buildDialogWithContent( initialRendering, diff --git a/workflow-ui/core-android/src/main/res/values/ids.xml b/workflow-ui/core-android/src/main/res/values/ids.xml index d1100790a8..590f08f752 100644 --- a/workflow-ui/core-android/src/main/res/values/ids.xml +++ b/workflow-ui/core-android/src/main/res/values/ids.xml @@ -9,6 +9,8 @@ + +