From b455ddee15ed08792520ef37359d5b52c458dec3 Mon Sep 17 00:00:00 2001 From: Ray Ryan Date: Mon, 29 Nov 2021 10:58:36 -0800 Subject: [PATCH] Introduces ScreenOverlay and ModalScreenOverlayDialogFactory `ScreenOverlay` marks an `Overlay` with a `Screen` defining its content. `ModalScreenOverlayDialogFactory` can show a `ScreenOverlay` as an `android.app.Dialog`, taking care of updating the content view and managing the Back button. It's the much simpler replacement for `ModalViewContainer`. `ModalScreenOverlayDialogFactory` works with `BodyAndModalsContainer` and `LayeredDialogs` to perform two important tricks: - `updateBounds(Dialog, Rect)`, which allows dialogs to be restrictred to a subset of the screen. Implemented via the private `ModalArea` value in `ViewEnvironment` - Touch and keyboard events are blocked by views that are covered by dialogs managed by a `ModalScreenOverlayDialogFactory`. Same for dialogs with a lower z order. We use another private `ViewEnvironment` value for that, `CoveredByModal`. In sample code, `PanelOverlay` replaces `PanelContainerScreen`, and the Tic Tac Toe sample is updated to use the new hotness. I think the diff of the sample code really highlights how much the new marker interfaces improve our composition story. That said, the `ScrimScreen` bit in the sample is a little rough. In the old code we magically swizzled the scrim into place automatically. This PR seems big enough already, so I'll follow up with a clean up that does something similar and deletes all the deprecated sample code. Fixes #259, #138, #99, #204, #314, #589 --- .../sample/container/SampleContainers.kt | 7 +- .../container/{panel => }/ScrimContainer.kt | 26 +- .../sample/container/panel/Contexts.kt | 6 - .../sample/container/panel/PanelContainer.kt | 72 ------ .../panel/PanelOverlayDialogFactory.kt | 65 +++++ .../src/main/res/values-land/bools.xml | 4 - .../android/src/main/res/values/bools.xml | 1 - samples/containers/common/build.gradle.kts | 1 + .../container/panel/PanelContainerScreen.kt | 55 ---- .../sample/container/panel/PanelOverlay.kt | 10 + .../container/panel/ScrimContainerScreen.kt | 14 -- .../sample/container/panel/ScrimScreen.kt | 18 ++ .../sample/gameworkflow/RunGameWorkflow.kt | 238 +++++++++--------- .../sample/mainworkflow/TicTacToeWorkflow.kt | 95 +++---- .../mainworkflow/TicTacToeWorkflowTest.kt | 25 +- .../ui/modal/AlertContainerScreen.kt | 17 +- .../workflow1/ui/modal/AlertScreen.kt | 17 +- .../squareup/workflow1/ui/modal/HasModals.kt | 5 +- workflow-ui/core-android/api/core-android.api | 36 ++- .../squareup/workflow1/ui/AndroidOverlay.kt | 9 + .../ui/container/AlertOverlayDialogFactory.kt | 73 ++++-- .../ui/container/AndroidDialogBounds.kt | 78 ++++++ .../ui/container/BodyAndModalsContainer.kt | 111 +++++--- .../workflow1/ui/container/CoveredByModal.kt | 13 + .../workflow1/ui/container/DialogHolder.kt | 39 +++ .../ui/container/DispatchCancelEvent.kt | 13 + .../workflow1/ui/container/LayeredDialogs.kt | 25 +- .../workflow1/ui/container/ModalArea.kt | 20 ++ .../ModalScreenOverlayDialogFactory.kt | 109 ++++++++ .../ModalScreenOverlayOnBackPressed.kt | 40 +++ .../core-android/src/main/res/values/ids.xml | 19 +- workflow-ui/core-common/api/core-common.api | 13 +- .../com/squareup/workflow1/ui/AsScreen.kt | 6 +- .../ui/container/BodyAndModalsScreen.kt | 12 +- .../workflow1/ui/container/ScreenOverlay.kt | 16 ++ 35 files changed, 856 insertions(+), 452 deletions(-) rename samples/containers/android/src/main/java/com/squareup/sample/container/{panel => }/ScrimContainer.kt (81%) delete mode 100644 samples/containers/android/src/main/java/com/squareup/sample/container/panel/PanelContainer.kt create mode 100644 samples/containers/android/src/main/java/com/squareup/sample/container/panel/PanelOverlayDialogFactory.kt delete mode 100644 samples/containers/android/src/main/res/values-land/bools.xml delete mode 100644 samples/containers/common/src/main/java/com/squareup/sample/container/panel/PanelContainerScreen.kt create mode 100644 samples/containers/common/src/main/java/com/squareup/sample/container/panel/PanelOverlay.kt delete mode 100644 samples/containers/common/src/main/java/com/squareup/sample/container/panel/ScrimContainerScreen.kt create mode 100644 samples/containers/common/src/main/java/com/squareup/sample/container/panel/ScrimScreen.kt create mode 100644 workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidOverlay.kt create mode 100644 workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/AndroidDialogBounds.kt create mode 100644 workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/CoveredByModal.kt create mode 100644 workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/DispatchCancelEvent.kt create mode 100644 workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ModalArea.kt create mode 100644 workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ModalScreenOverlayDialogFactory.kt create mode 100644 workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ModalScreenOverlayOnBackPressed.kt create mode 100644 workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/container/ScreenOverlay.kt diff --git a/samples/containers/android/src/main/java/com/squareup/sample/container/SampleContainers.kt b/samples/containers/android/src/main/java/com/squareup/sample/container/SampleContainers.kt index fed55967a5..24de7be68e 100644 --- a/samples/containers/android/src/main/java/com/squareup/sample/container/SampleContainers.kt +++ b/samples/containers/android/src/main/java/com/squareup/sample/container/SampleContainers.kt @@ -1,12 +1,11 @@ package com.squareup.sample.container import com.squareup.sample.container.overviewdetail.OverviewDetailContainer -import com.squareup.sample.container.panel.PanelContainer -import com.squareup.sample.container.panel.ScrimContainer -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.sample.container.panel.PanelOverlayDialogFactory import com.squareup.workflow1.ui.ViewRegistry +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi @OptIn(WorkflowUiExperimentalApi::class) val SampleContainers = ViewRegistry( - BackButtonViewFactory, OverviewDetailContainer, PanelContainer, ScrimContainer + BackButtonViewFactory, OverviewDetailContainer, PanelOverlayDialogFactory, ScrimContainer ) diff --git a/samples/containers/android/src/main/java/com/squareup/sample/container/panel/ScrimContainer.kt b/samples/containers/android/src/main/java/com/squareup/sample/container/ScrimContainer.kt similarity index 81% rename from samples/containers/android/src/main/java/com/squareup/sample/container/panel/ScrimContainer.kt rename to samples/containers/android/src/main/java/com/squareup/sample/container/ScrimContainer.kt index 2bd40d659e..e30b9f9837 100644 --- a/samples/containers/android/src/main/java/com/squareup/sample/container/panel/ScrimContainer.kt +++ b/samples/containers/android/src/main/java/com/squareup/sample/container/ScrimContainer.kt @@ -1,4 +1,4 @@ -package com.squareup.sample.container.panel +package com.squareup.sample.container import android.animation.ValueAnimator import android.content.Context @@ -6,7 +6,7 @@ import android.util.AttributeSet import android.view.View import android.view.ViewGroup import androidx.core.content.ContextCompat -import com.squareup.sample.container.R +import com.squareup.sample.container.panel.ScrimScreen import com.squareup.workflow1.ui.ManualScreenViewFactory import com.squareup.workflow1.ui.ScreenViewFactory import com.squareup.workflow1.ui.WorkflowUiExperimentalApi @@ -17,9 +17,9 @@ import com.squareup.workflow1.ui.bindShowRendering * A view that renders only its first child, behind a smoke scrim if * [isDimmed] is true (tablets only). Other children are ignored. * - * Able to [render][com.squareup.workflow1.ui.showRendering] [ScrimContainerScreen]. + * Able to [render][com.squareup.workflow1.ui.showRendering] [ScrimScreen]. */ -class ScrimContainer @JvmOverloads constructor( +internal class ScrimContainer @JvmOverloads constructor( context: Context, attributeSet: AttributeSet? = null, defStyle: Int = 0, @@ -91,21 +91,21 @@ class ScrimContainer @JvmOverloads constructor( } @OptIn(WorkflowUiExperimentalApi::class) - companion object : ScreenViewFactory> by ManualScreenViewFactory( - type = ScrimContainerScreen::class, + companion object : ScreenViewFactory> by ManualScreenViewFactory( + type = ScrimScreen::class, viewConstructor = { initialRendering, initialViewEnvironment, contextForNewView, _ -> val stub = WorkflowViewStub(contextForNewView) ScrimContainer(contextForNewView) - .apply { - layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) - addView(stub) + .also { view -> + view.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + view.addView(stub) - bindShowRendering( - initialRendering, initialViewEnvironment + view.bindShowRendering( + initialRendering, initialViewEnvironment ) { rendering, environment -> - stub.show(rendering.wrapped, environment) - isDimmed = rendering.dimmed + stub.show(rendering.content, environment) + view.isDimmed = rendering.dimmed } } } diff --git a/samples/containers/android/src/main/java/com/squareup/sample/container/panel/Contexts.kt b/samples/containers/android/src/main/java/com/squareup/sample/container/panel/Contexts.kt index 3838e7e0ed..7248d40481 100644 --- a/samples/containers/android/src/main/java/com/squareup/sample/container/panel/Contexts.kt +++ b/samples/containers/android/src/main/java/com/squareup/sample/container/panel/Contexts.kt @@ -2,15 +2,9 @@ package com.squareup.sample.container.panel import android.content.Context import android.content.Context.WINDOW_SERVICE -import android.view.Display import android.view.WindowManager import com.squareup.sample.container.R -val Context.isPortrait: Boolean get() = resources.getBoolean(R.bool.is_portrait) - val Context.isTablet: Boolean get() = resources.getBoolean(R.bool.is_tablet) val Context.windowManager: WindowManager get() = getSystemService(WINDOW_SERVICE) as WindowManager - -@Suppress("DEPRECATION") -val Context.defaultDisplay: Display get() = windowManager.defaultDisplay diff --git a/samples/containers/android/src/main/java/com/squareup/sample/container/panel/PanelContainer.kt b/samples/containers/android/src/main/java/com/squareup/sample/container/panel/PanelContainer.kt deleted file mode 100644 index 9fadf2f180..0000000000 --- a/samples/containers/android/src/main/java/com/squareup/sample/container/panel/PanelContainer.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.squareup.sample.container.panel - -import android.app.Dialog -import android.content.Context -import android.graphics.drawable.ColorDrawable -import android.util.AttributeSet -import android.util.DisplayMetrics -import android.util.TypedValue -import android.view.View -import android.view.ViewGroup -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import com.squareup.sample.container.R -import com.squareup.workflow1.ui.ManualScreenViewFactory -import com.squareup.workflow1.ui.ScreenViewFactory -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.bindShowRendering -import com.squareup.workflow1.ui.modal.ModalViewContainer - -/** - * Used by Tic Tac Workflow sample to show its [PanelContainerScreen]s. - * Extends [ModalViewContainer] to make the dialog square on Tablets, and - * give it an opaque background. - */ -@OptIn(WorkflowUiExperimentalApi::class) -class PanelContainer @JvmOverloads constructor( - context: Context, - attributeSet: AttributeSet? = null, - defStyle: Int = 0, - defStyleRes: Int = 0 -) : ModalViewContainer(context, attributeSet, defStyle, defStyleRes) { - override fun buildDialogForView(view: View): Dialog { - return Dialog(context, R.style.PanelDialog).also { dialog -> - dialog.setContentView(view) - - val typedValue = TypedValue() - context.theme.resolveAttribute(android.R.attr.windowBackground, typedValue, true) - if (typedValue.type in TypedValue.TYPE_FIRST_COLOR_INT..TypedValue.TYPE_LAST_COLOR_INT) { - dialog.window!!.setBackgroundDrawable(ColorDrawable(typedValue.data)) - } - - // Use setLayout to control window size. Note that it must be - // called after setContentView. - // - // Default layout values are MATCH_PARENT in both dimens, which is - // perfect for phone. - - if (context.isTablet) { - val displayMetrics = DisplayMetrics().also { - @Suppress("DEPRECATION") - dialog.context.defaultDisplay.getMetrics(it) - } - - if (context.isPortrait) { - dialog.window!!.setLayout(displayMetrics.widthPixels, displayMetrics.widthPixels) - } else { - dialog.window!!.setLayout(displayMetrics.heightPixels, displayMetrics.heightPixels) - } - } - } - } - - companion object : ScreenViewFactory> by ManualScreenViewFactory( - type = PanelContainerScreen::class, - viewConstructor = { initialRendering, initialEnv, contextForNewView, _ -> - PanelContainer(contextForNewView).apply { - id = R.id.panel_container - layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT) - bindShowRendering(initialRendering, initialEnv, ::update) - } - } - ) -} diff --git a/samples/containers/android/src/main/java/com/squareup/sample/container/panel/PanelOverlayDialogFactory.kt b/samples/containers/android/src/main/java/com/squareup/sample/container/panel/PanelOverlayDialogFactory.kt new file mode 100644 index 0000000000..6a697a7e20 --- /dev/null +++ b/samples/containers/android/src/main/java/com/squareup/sample/container/panel/PanelOverlayDialogFactory.kt @@ -0,0 +1,65 @@ +package com.squareup.sample.container.panel + +import android.app.Dialog +import android.graphics.Rect +import android.graphics.drawable.ColorDrawable +import android.util.TypedValue +import android.view.View +import com.squareup.sample.container.R +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.container.ModalScreenOverlayDialogFactory +import com.squareup.workflow1.ui.container.setBounds + +/** + * Android support for [PanelOverlay]. + */ +@OptIn(WorkflowUiExperimentalApi::class) +internal object PanelOverlayDialogFactory : ModalScreenOverlayDialogFactory>( + type = PanelOverlay::class +) { + override fun buildDialogWithContentView(contentView: View): Dialog { + val context = contentView.context + return Dialog(context, R.style.PanelDialog).also { dialog -> + dialog.setContentView(contentView) + + // Welcome to Android. Nothing workflow-related here, this is just how one + // finds the window background color for the theme. I sure hope it's better in Compose. + val maybeWindowColor = TypedValue() + context.theme.resolveAttribute(android.R.attr.windowBackground, maybeWindowColor, true) + if ( + maybeWindowColor.type in TypedValue.TYPE_FIRST_COLOR_INT..TypedValue.TYPE_LAST_COLOR_INT + ) { + dialog.window!!.setBackgroundDrawable(ColorDrawable(maybeWindowColor.data)) + } + } + } + + override fun updateBounds( + dialog: Dialog, + bounds: Rect + ) { + val refinedBounds: Rect = if (!dialog.context.isTablet) { + // On a phone, fill the bounds entirely. + bounds + } else { + if (bounds.height() > bounds.width()) { + val margin = bounds.height() - bounds.width() + val topDelta = margin / 2 + val bottomDelta = margin - topDelta + Rect(bounds).apply { + top = bounds.top + topDelta + bottom = bounds.bottom - bottomDelta + } + } else { + val margin = bounds.width() - bounds.height() + val leftDelta = margin / 2 + val rightDelta = margin - leftDelta + Rect(bounds).apply { + left = bounds.left + leftDelta + right = bounds.right - rightDelta + } + } + } + dialog.setBounds(refinedBounds) + } +} diff --git a/samples/containers/android/src/main/res/values-land/bools.xml b/samples/containers/android/src/main/res/values-land/bools.xml deleted file mode 100644 index 5208bb6a41..0000000000 --- a/samples/containers/android/src/main/res/values-land/bools.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - false - diff --git a/samples/containers/android/src/main/res/values/bools.xml b/samples/containers/android/src/main/res/values/bools.xml index 462a99e44d..434ba0a58f 100644 --- a/samples/containers/android/src/main/res/values/bools.xml +++ b/samples/containers/android/src/main/res/values/bools.xml @@ -1,5 +1,4 @@ - true false diff --git a/samples/containers/common/build.gradle.kts b/samples/containers/common/build.gradle.kts index fb89fc66b8..8589983bef 100644 --- a/samples/containers/common/build.gradle.kts +++ b/samples/containers/common/build.gradle.kts @@ -5,6 +5,7 @@ plugins { dependencies { implementation(project(":workflow-ui:container-common")) + implementation(project(":workflow-ui:core-android")) implementation(project(":workflow-core")) implementation(Dependencies.Kotlin.Stdlib.jdk6) diff --git a/samples/containers/common/src/main/java/com/squareup/sample/container/panel/PanelContainerScreen.kt b/samples/containers/common/src/main/java/com/squareup/sample/container/panel/PanelContainerScreen.kt deleted file mode 100644 index d99d77d3e5..0000000000 --- a/samples/containers/common/src/main/java/com/squareup/sample/container/panel/PanelContainerScreen.kt +++ /dev/null @@ -1,55 +0,0 @@ -@file:Suppress("DEPRECATION") - -package com.squareup.sample.container.panel - -import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.container.BackStackScreen -import com.squareup.workflow1.ui.modal.HasModals - -/** - * Custom modal container used in Tic Tac Workflow sample. Shows one or more - * nested [sub-flows][modals] over a [baseScreen]. Demonstrates how an app - * can set up a custom modal design element. - * - * Note the trickiness with our implementation of [HasModals], the interface - * that drives Workflow's `ModalContainer`. We wrap the base in a [ScrimContainerScreen] - * to give ourselves control over how the base is dimmed when the card modal is shown. - * - * Tic Tac Workflow uses modals for two purposes: - * - * - Alerts, via the stock `AlertContainerScreen` - * - * - Panels, this class: full screen (phone) or great big square (tablet) - * windows that host sub-tasks like logging in and choosing player names, - * tasks which take multiple steps and involve going backward and forward. - */ -@OptIn(WorkflowUiExperimentalApi::class) -data class PanelContainerScreen constructor( - val baseScreen: B, - override val modals: List> = emptyList() -) : Screen, HasModals, BackStackScreen> { - override val beneathModals: ScrimContainerScreen - get() = ScrimContainerScreen( - wrapped = baseScreen, - dimmed = modals.isNotEmpty() - ) -} - -/** - * Shows the receiving [BackStackScreen] in the only panel over [baseScreen]. - */ -@OptIn(WorkflowUiExperimentalApi::class) -fun BackStackScreen.inPanelOver( - baseScreen: B -): PanelContainerScreen { - return PanelContainerScreen(baseScreen, listOf(this)) -} - -/** - * Shows the receiver as the only panel over [baseScreen], with no back stack. - */ -@OptIn(WorkflowUiExperimentalApi::class) -fun T.firstInPanelOver(baseScreen: B): PanelContainerScreen { - return BackStackScreen(this, emptyList()).inPanelOver(baseScreen) -} diff --git a/samples/containers/common/src/main/java/com/squareup/sample/container/panel/PanelOverlay.kt b/samples/containers/common/src/main/java/com/squareup/sample/container/panel/PanelOverlay.kt new file mode 100644 index 0000000000..a883a956c6 --- /dev/null +++ b/samples/containers/common/src/main/java/com/squareup/sample/container/panel/PanelOverlay.kt @@ -0,0 +1,10 @@ +package com.squareup.sample.container.panel + +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.container.ScreenOverlay + +@OptIn(WorkflowUiExperimentalApi::class) +class PanelOverlay( + override val content: T +) : ScreenOverlay diff --git a/samples/containers/common/src/main/java/com/squareup/sample/container/panel/ScrimContainerScreen.kt b/samples/containers/common/src/main/java/com/squareup/sample/container/panel/ScrimContainerScreen.kt deleted file mode 100644 index ae5e86b6aa..0000000000 --- a/samples/containers/common/src/main/java/com/squareup/sample/container/panel/ScrimContainerScreen.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.squareup.sample.container.panel - -import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi - -/** - * Show a scrim over the [wrapped] item, which is invisible if [dimmed] is false, - * dark if it is true. - */ -@OptIn(WorkflowUiExperimentalApi::class) -class ScrimContainerScreen( - val wrapped: T, - val dimmed: Boolean -) : Screen diff --git a/samples/containers/common/src/main/java/com/squareup/sample/container/panel/ScrimScreen.kt b/samples/containers/common/src/main/java/com/squareup/sample/container/panel/ScrimScreen.kt new file mode 100644 index 0000000000..c0303a87c9 --- /dev/null +++ b/samples/containers/common/src/main/java/com/squareup/sample/container/panel/ScrimScreen.kt @@ -0,0 +1,18 @@ +package com.squareup.sample.container.panel + +import com.squareup.workflow1.ui.Compatible +import com.squareup.workflow1.ui.Compatible.Companion.keyFor +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi + +/** + * Show a scrim over some [content], which is invisible if [dimmed] is false, + * visible if it is true. + */ +@OptIn(WorkflowUiExperimentalApi::class) +class ScrimScreen( + val content: T, + val dimmed: Boolean +) : Screen, Compatible { + override val compatibilityKey = keyFor(content, "ScrimScreen") +} diff --git a/samples/tictactoe/common/src/main/java/com/squareup/sample/gameworkflow/RunGameWorkflow.kt b/samples/tictactoe/common/src/main/java/com/squareup/sample/gameworkflow/RunGameWorkflow.kt index 7b39099048..0ca98e7185 100644 --- a/samples/tictactoe/common/src/main/java/com/squareup/sample/gameworkflow/RunGameWorkflow.kt +++ b/samples/tictactoe/common/src/main/java/com/squareup/sample/gameworkflow/RunGameWorkflow.kt @@ -2,8 +2,6 @@ package com.squareup.sample.gameworkflow -import com.squareup.sample.container.panel.PanelContainerScreen -import com.squareup.sample.container.panel.firstInPanelOver import com.squareup.sample.gameworkflow.Ending.Quitted import com.squareup.sample.gameworkflow.GameLog.LogResult.LOGGED import com.squareup.sample.gameworkflow.GameLog.LogResult.TRY_LATER @@ -25,26 +23,37 @@ import com.squareup.workflow1.runningWorker import com.squareup.workflow1.rx2.asWorker import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.modal.AlertContainerScreen -import com.squareup.workflow1.ui.modal.AlertScreen -import com.squareup.workflow1.ui.modal.AlertScreen.Button.NEGATIVE -import com.squareup.workflow1.ui.modal.AlertScreen.Button.NEUTRAL -import com.squareup.workflow1.ui.modal.AlertScreen.Button.POSITIVE -import com.squareup.workflow1.ui.modal.AlertScreen.Event.ButtonClicked -import com.squareup.workflow1.ui.modal.AlertScreen.Event.Canceled +import com.squareup.workflow1.ui.container.AlertOverlay +import com.squareup.workflow1.ui.container.AlertOverlay.Button.NEGATIVE +import com.squareup.workflow1.ui.container.AlertOverlay.Button.NEUTRAL +import com.squareup.workflow1.ui.container.AlertOverlay.Button.POSITIVE +import com.squareup.workflow1.ui.container.AlertOverlay.Event.ButtonClicked +import com.squareup.workflow1.ui.container.AlertOverlay.Event.Canceled +import com.squareup.workflow1.ui.container.ScreenOverlay enum class RunGameResult { CanceledStart, FinishedPlaying } -typealias RunGameScreen = AlertContainerScreen> +/** + * This workflow renders up to three layers. There is always a [gameScreen], which + * may be covered by a [namePrompt] and [alerts]. By declaring our rendering shape + * this explicitly, we give parent workflows just enough information to recompose, + * without leaking details about every single type of screen we render. + */ +data class RunGameRendering( + val gameScreen: Screen, + val namePrompt: ScreenOverlay<*>? = null, + val alerts: List = emptyList() +) /** * We define this otherwise redundant typealias to keep composite workflows * that build on [RunGameWorkflow] decoupled from it, for ease of testing. */ -typealias RunGameWorkflow = Workflow +typealias RunGameWorkflow = + Workflow /** * Runs the screens around a Tic Tac Toe game: prompts for player names, runs a @@ -55,7 +64,7 @@ class RealRunGameWorkflow( private val takeTurnsWorkflow: TakeTurnsWorkflow, private val gameLog: GameLog ) : RunGameWorkflow, - StatefulWorkflow() { + StatefulWorkflow() { override fun initialState( props: Unit, @@ -69,90 +78,104 @@ class RealRunGameWorkflow( renderProps: Unit, renderState: RunGameState, context: RenderContext - ): RunGameScreen = when (renderState) { - is NewGame -> { - val emptyGameScreen = GamePlayScreen() - - subflowScreen( - base = emptyGameScreen, - subflow = NewGameScreen( - renderState.defaultXName, - renderState.defaultOName, - onCancel = context.eventHandler { setOutput(CanceledStart) }, - onStartGame = context.eventHandler { x, o -> state = Playing(PlayerInfo(x, o)) } + ): RunGameRendering = + when (renderState) { + is NewGame -> { + val emptyGameScreen = GamePlayScreen() + + RunGameRendering( + gameScreen = emptyGameScreen, + namePrompt = object : ScreenOverlay { + override val content = NewGameScreen( + renderState.defaultXName, + renderState.defaultOName, + onCancel = context.eventHandler { setOutput(CanceledStart) }, + onStartGame = context.eventHandler { x, o -> state = Playing(PlayerInfo(x, o)) } + ) + } ) - ) - } + } - is Playing -> { - // context.renderChild starts takeTurnsWorkflow, or keeps it running if it was - // already going. TakeTurnsWorkflow.render is immediately called, - // and the GamePlayScreen it renders is immediately returned. - val takeTurnsScreen = context.renderChild( - takeTurnsWorkflow, - props = renderState.resume - ?.let { TakeTurnsProps.resumeGame(renderState.playerInfo, it) } - ?: TakeTurnsProps.newGame(renderState.playerInfo) - ) { stopPlaying(it) } - - simpleScreen(takeTurnsScreen) - } + is Playing -> { + // context.renderChild starts takeTurnsWorkflow, or keeps it running if it was + // already going. TakeTurnsWorkflow.render is immediately called, + // and the GamePlayScreen it renders is immediately returned. + val takeTurnsScreen = context.renderChild( + takeTurnsWorkflow, + props = renderState.resume + ?.let { TakeTurnsProps.resumeGame(renderState.playerInfo, it) } + ?: TakeTurnsProps.newGame(renderState.playerInfo) + ) { stopPlaying(it) } + + RunGameRendering(takeTurnsScreen) + } - is MaybeQuitting -> { - alertScreen( - base = GamePlayScreen(renderState.playerInfo, renderState.completedGame.lastTurn), - alert = maybeQuitScreen( - confirmQuit = context.eventHandler { - (state as? MaybeQuitting)?.let { oldState -> - state = MaybeQuittingForSure(oldState.playerInfo, oldState.completedGame) - } - }, - continuePlaying = context.eventHandler { - (state as? MaybeQuitting)?.let { oldState -> - state = Playing(oldState.playerInfo, oldState.completedGame.lastTurn) - } - } + is MaybeQuitting -> { + RunGameRendering( + gameScreen = GamePlayScreen( + renderState.playerInfo, + renderState.completedGame.lastTurn + ), + alerts = listOf( + maybeQuitScreen( + message = "Do you really want to concede the game?", + positive = "I Quit", + negative = "No", + confirmQuit = context.eventHandler { + (state as? MaybeQuitting)?.let { oldState -> + state = MaybeQuittingForSure(oldState.playerInfo, oldState.completedGame) + } + }, + continuePlaying = context.eventHandler { + (state as? MaybeQuitting)?.let { oldState -> + state = Playing(oldState.playerInfo, oldState.completedGame.lastTurn) + } + } + ) + ) ) - ) - } + } - is MaybeQuittingForSure -> { - nestedAlertsScreen( - GamePlayScreen(renderState.playerInfo, renderState.completedGame.lastTurn), - maybeQuitScreen(), - maybeQuitScreen( - message = "Really?", - positive = "Yes!!", - negative = "Sigh, no", - confirmQuit = context.eventHandler { - (state as? MaybeQuittingForSure)?.let { oldState -> - state = GameOver(oldState.playerInfo, oldState.completedGame) - } - }, - continuePlaying = context.eventHandler { - (state as? MaybeQuittingForSure)?.let { oldState -> - state = Playing(oldState.playerInfo, oldState.completedGame.lastTurn) - } - } + is MaybeQuittingForSure -> { + RunGameRendering( + gameScreen = GamePlayScreen(renderState.playerInfo, renderState.completedGame.lastTurn), + alerts = listOf( + maybeQuitScreen( + message = "Really?", + positive = "Yes!!", + negative = "Sigh, no", + confirmQuit = context.eventHandler { + (state as? MaybeQuittingForSure)?.let { oldState -> + state = GameOver(oldState.playerInfo, oldState.completedGame) + } + }, + continuePlaying = context.eventHandler { + (state as? MaybeQuittingForSure)?.let { oldState -> + state = Playing(oldState.playerInfo, oldState.completedGame.lastTurn) + } + } + ) + ) ) - ) - } + } - is GameOver -> { - if (renderState.syncState == SAVING) { - context.runningWorker(gameLog.logGame(renderState.completedGame).asWorker()) { - handleLogGame(it) + is GameOver -> { + if (renderState.syncState == SAVING) { + context.runningWorker(gameLog.logGame(renderState.completedGame).asWorker()) { + handleLogGame(it) + } } - } - GameOverScreen( - renderState, - onTrySaveAgain = context.trySaveAgain(), - onPlayAgain = context.playAgain(), - onExit = context.eventHandler { setOutput(FinishedPlaying) } - ).let(::simpleScreen) + RunGameRendering( + GameOverScreen( + renderState, + onTrySaveAgain = context.trySaveAgain(), + onPlayAgain = context.playAgain(), + onExit = context.eventHandler { setOutput(FinishedPlaying) } + ) + ) + } } - } private fun stopPlaying(game: CompletedGame) = action { val oldState = state as Playing @@ -189,43 +212,14 @@ class RealRunGameWorkflow( override fun snapshotState(state: RunGameState): Snapshot = state.toSnapshot() - private fun nestedAlertsScreen( - base: Screen, - vararg alerts: AlertScreen - ): RunGameScreen { - return AlertContainerScreen( - PanelContainerScreen(base), *alerts - ) - } - - private fun alertScreen( - base: Screen, - alert: AlertScreen - ): RunGameScreen { - return AlertContainerScreen( - PanelContainerScreen(base), alert - ) - } - - private fun subflowScreen( - base: Screen, - subflow: Screen - ): RunGameScreen { - return AlertContainerScreen(subflow.firstInPanelOver(base)) - } - - private fun simpleScreen(screen: Screen): RunGameScreen { - return AlertContainerScreen(PanelContainerScreen(screen)) - } - private fun maybeQuitScreen( - message: String = "Do you really want to concede the game?", - positive: String = "I Quit", - negative: String = "No", - confirmQuit: () -> Unit = { }, - continuePlaying: () -> Unit = { } - ): AlertScreen { - return AlertScreen( + message: String, + positive: String, + negative: String, + confirmQuit: () -> Unit, + continuePlaying: () -> Unit + ): AlertOverlay { + return AlertOverlay( buttons = mapOf( POSITIVE to positive, NEGATIVE to negative diff --git a/samples/tictactoe/common/src/main/java/com/squareup/sample/mainworkflow/TicTacToeWorkflow.kt b/samples/tictactoe/common/src/main/java/com/squareup/sample/mainworkflow/TicTacToeWorkflow.kt index be84a6e1d8..b1faac9682 100644 --- a/samples/tictactoe/common/src/main/java/com/squareup/sample/mainworkflow/TicTacToeWorkflow.kt +++ b/samples/tictactoe/common/src/main/java/com/squareup/sample/mainworkflow/TicTacToeWorkflow.kt @@ -7,10 +7,9 @@ import com.squareup.sample.authworkflow.AuthResult import com.squareup.sample.authworkflow.AuthResult.Authorized import com.squareup.sample.authworkflow.AuthResult.Canceled import com.squareup.sample.authworkflow.AuthWorkflow -import com.squareup.sample.container.panel.inPanelOver +import com.squareup.sample.container.panel.PanelOverlay +import com.squareup.sample.container.panel.ScrimScreen import com.squareup.sample.gameworkflow.GamePlayScreen -import com.squareup.sample.gameworkflow.RealRunGameWorkflow -import com.squareup.sample.gameworkflow.RunGameScreen import com.squareup.sample.gameworkflow.RunGameWorkflow import com.squareup.sample.mainworkflow.MainState.Authenticating import com.squareup.sample.mainworkflow.MainState.RunningGame @@ -21,18 +20,23 @@ import com.squareup.workflow1.WorkflowAction.Companion.noAction import com.squareup.workflow1.action import com.squareup.workflow1.renderChild import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.modal.AlertContainerScreen +import com.squareup.workflow1.ui.container.BackStackScreen +import com.squareup.workflow1.ui.container.BodyAndModalsScreen /** * Application specific root [Workflow], and demonstration of workflow composition. * We log in, and then play as many games as we want. * - * Delegates to [AuthWorkflow] and [RealRunGameWorkflow]. Responsible only for deciding + * Delegates to [AuthWorkflow] and [RunGameWorkflow]. Responsible only for deciding * what to do as each nested workflow ends. * - * We adopt [RunGameScreen] as our own rendering type because it's more demanding - * than that of [AuthWorkflow]. We normalize the latter to be consistent - * with the former. + * Note how we normalize the rendering types of the two children. In particular: + * + * - We put the [BackStackScreen] from the [AuthWorkflow] into a [PanelOverlay], along + * with any [namePrompt][com.squareup.sample.gameworkflow.RunGameRendering.namePrompt] + * from [RunGameWorkflow] + * - We add a [ScrimScreen] over our base to get the desired visual treatment under + * the [PanelOverlay] * * A [Unit] output event is emitted to signal that the workflow has ended, and the host * activity should be finished. @@ -40,7 +44,7 @@ import com.squareup.workflow1.ui.modal.AlertContainerScreen class TicTacToeWorkflow( private val authWorkflow: AuthWorkflow, private val runGameWorkflow: RunGameWorkflow -) : StatefulWorkflow() { +) : StatefulWorkflow, *>>() { override fun initialState( props: Unit, @@ -52,49 +56,56 @@ class TicTacToeWorkflow( renderProps: Unit, renderState: MainState, context: RenderContext - ): RunGameScreen = when (renderState) { - is Authenticating -> { - val authScreen = context.renderChild(authWorkflow) { handleAuthResult(it) } - val emptyGameScreen = GamePlayScreen() - - // IDE is wrong, removing them breaks the compile. - // Probably due to https://youtrack.jetbrains.com/issue/KT-32869 - @Suppress("RemoveExplicitTypeArguments") - (AlertContainerScreen( - authScreen.inPanelOver(emptyGameScreen) - )) - } + ): BodyAndModalsScreen, *> { + val bodyAndModals: BodyAndModalsScreen<*, *> = when (renderState) { + is Authenticating -> { + val authBackStack = context.renderChild(authWorkflow) { handleAuthResult(it) } + // We always show an empty GameScreen behind the PanelOverlay that + // hosts the authWorkflow's renderings because that's how the + // award winning design team wanted it to look. Yes, it's a cheat + // that TicTacToeWorkflow is aware of the GamePlayScreen type, and that + // cheat is probably the most realistic thing about this sample. + val emptyGameScreen = GamePlayScreen() - is RunningGame -> { - val childRendering = context.renderChild(runGameWorkflow) { startAuth } + BodyAndModalsScreen(emptyGameScreen, PanelOverlay(authBackStack)) + } - val panels = childRendering.beneathModals.modals + is RunningGame -> { + val gameRendering = context.renderChild(runGameWorkflow) { startAuth } - if (panels.isEmpty()) { - childRendering - } else { - // To prompt for player names, the child puts up a panel — that is, a modal view - // hosting a BackStackScreen. If they cancel that, we'd like a visual effect of - // popping back to the auth flow in that same panel. To get this effect we run - // an authWorkflow and put its BackStackScreen behind this one. - // - // We use the "fake" uniquing name to make sure authWorkflow session from the - // Authenticating state was allowed to die, so that this one will start fresh - // in its logged out state. - val stubAuthBackStack = context.renderChild(authWorkflow, "fake") { noAction() } + if (gameRendering.namePrompt == null) { + BodyAndModalsScreen(gameRendering.gameScreen, gameRendering.alerts) + } else { + // To prompt for player names, the child puts up a ScreenOverlay, which + // we replace here with a tasteful PanelOverlay. + // + // If the name prompt gets canceled, we'd like a visual effect of + // popping back to the auth flow in that same panel. To get this effect + // we: + // - run an authWorkflow + // - append namePrompt.content to that BackStackScreen + // - and put that whole thing in the PanelOverlay + // + // We use the "fake" uniquing name to make sure authWorkflow session from the + // Authenticating state was allowed to die, so that this one will start fresh + // in its logged out state. + val stubAuthBackStack = context.renderChild(authWorkflow, "fake") { noAction() } + val fullBackStack = stubAuthBackStack + + BackStackScreen(gameRendering.namePrompt.content) + val allModals = listOf(PanelOverlay(fullBackStack)) + gameRendering.alerts - val panelsMod = panels.toMutableList() - panelsMod[0] = stubAuthBackStack + panels[0] - childRendering.copy(beneathModals = childRendering.beneathModals.copy(modals = panelsMod)) + BodyAndModalsScreen(gameRendering.gameScreen, allModals) + } } } + + // Add the scrim. Dim it only if there is a panel showing. + val dim = bodyAndModals.modals.any { modal -> modal is PanelOverlay<*> } + return bodyAndModals.mapBody { body -> ScrimScreen(body, dimmed = dim) } } override fun snapshotState(state: MainState): Snapshot = state.toSnapshot() - // We continue to use the deprecated method here for one more release, to demonstrate - // that the migration mechanism works. - private val startAuth = action { state = Authenticating } private fun handleAuthResult(result: AuthResult) = action { diff --git a/samples/tictactoe/common/src/test/java/com/squareup/sample/mainworkflow/TicTacToeWorkflowTest.kt b/samples/tictactoe/common/src/test/java/com/squareup/sample/mainworkflow/TicTacToeWorkflowTest.kt index 52f46c2363..5c52aa83b9 100644 --- a/samples/tictactoe/common/src/test/java/com/squareup/sample/mainworkflow/TicTacToeWorkflowTest.kt +++ b/samples/tictactoe/common/src/test/java/com/squareup/sample/mainworkflow/TicTacToeWorkflowTest.kt @@ -3,9 +3,10 @@ package com.squareup.sample.mainworkflow import com.google.common.truth.Truth.assertThat import com.squareup.sample.authworkflow.AuthResult.Authorized import com.squareup.sample.authworkflow.AuthWorkflow -import com.squareup.sample.container.panel.PanelContainerScreen +import com.squareup.sample.container.panel.PanelOverlay +import com.squareup.sample.container.panel.ScrimScreen import com.squareup.sample.gameworkflow.GamePlayScreen -import com.squareup.sample.gameworkflow.RunGameScreen +import com.squareup.sample.gameworkflow.RunGameRendering import com.squareup.sample.gameworkflow.RunGameWorkflow import com.squareup.workflow1.Worker import com.squareup.workflow1.Workflow @@ -17,6 +18,7 @@ import com.squareup.workflow1.testing.launchForTestingFromStartWith import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.container.BackStackScreen +import com.squareup.workflow1.ui.container.BodyAndModalsScreen import org.junit.Test /** @@ -30,10 +32,11 @@ class TicTacToeWorkflowTest { awaitNextRendering() .let { screen -> assertThat(screen.panels).hasSize(1) - assertThat(screen.panels[0]).isEqualTo(S(DEFAULT_AUTH)) + val panelBody = (screen.panels[0].content as BackStackScreen<*>).top + assertThat(panelBody).isEqualTo(S(DEFAULT_AUTH)) - // This GamePlayScreen() is emitted by MainWorkflow itself. - assertThat(screen.body).isEqualTo(GamePlayScreen()) + // This GamePlayScreen() is emitted by TicTacToeWorkflow itself. + assertThat(screen.body.content).isEqualTo(GamePlayScreen()) } } } @@ -50,22 +53,18 @@ class TicTacToeWorkflowTest { awaitNextRendering() .let { screen -> assertThat(screen.panels).isEmpty() - assertThat(screen.body).isEqualTo(S(DEFAULT_RUN_GAME)) + assertThat(screen.body.content).isEqualTo(S(DEFAULT_RUN_GAME)) } } } private data class S(val value: T) : Screen - private fun runGameScreen( - body: String = DEFAULT_RUN_GAME - ) = RunGameScreen(PanelContainerScreen(S(body))) - private fun authScreen(wrapped: String = DEFAULT_AUTH) = BackStackScreen(S(wrapped)) - private val RunGameScreen.panels: List get() = beneathModals.modals.map { it.top } - private val RunGameScreen.body: Any get() = beneathModals.beneathModals.wrapped + private val BodyAndModalsScreen, *>.panels: List> + get() = modals.mapNotNull { it as? PanelOverlay<*> } private fun authWorkflow( screen: String = DEFAULT_AUTH @@ -73,7 +72,7 @@ class TicTacToeWorkflowTest { private fun runGameWorkflow( body: String = DEFAULT_RUN_GAME - ): RunGameWorkflow = Workflow.rendering(runGameScreen(body)) + ): RunGameWorkflow = Workflow.rendering(RunGameRendering(S(body))) private companion object { const val DEFAULT_AUTH = "DefaultAuthScreen" diff --git a/workflow-ui/container-common/src/main/java/com/squareup/workflow1/ui/modal/AlertContainerScreen.kt b/workflow-ui/container-common/src/main/java/com/squareup/workflow1/ui/modal/AlertContainerScreen.kt index 9bb2008e4c..8fe1dc17f2 100644 --- a/workflow-ui/container-common/src/main/java/com/squareup/workflow1/ui/modal/AlertContainerScreen.kt +++ b/workflow-ui/container-common/src/main/java/com/squareup/workflow1/ui/modal/AlertContainerScreen.kt @@ -11,16 +11,13 @@ import com.squareup.workflow1.ui.WorkflowUiExperimentalApi * @param B the type of [beneathModals] */ @WorkflowUiExperimentalApi -// Can't quite deprecate this yet, because such warnings are impossible to suppress -// in the typealias uses in the Tic Tac Toe sample. Will uncomment before merging to main. -// https://github.com/square/workflow-kotlin/issues/589 -// @Deprecated( -// "Use BodyAndModalsScreen", -// ReplaceWith( -// "BodyAndModalsScreen(beneathModals, modals)", -// "com.squareup.workflow1.ui.container.BodyAndModalsScreen" -// ) -// ) +@Deprecated( + "Use BodyAndModalsScreen and AlertOverlay", + ReplaceWith( + "BodyAndModalsScreen(beneathModals, modals)", + "com.squareup.workflow1.ui.container.BodyAndModalsScreen" + ) +) public data class AlertContainerScreen( override val beneathModals: B, override val modals: List = emptyList() diff --git a/workflow-ui/container-common/src/main/java/com/squareup/workflow1/ui/modal/AlertScreen.kt b/workflow-ui/container-common/src/main/java/com/squareup/workflow1/ui/modal/AlertScreen.kt index 53893e04e5..7a4eb6d6dd 100644 --- a/workflow-ui/container-common/src/main/java/com/squareup/workflow1/ui/modal/AlertScreen.kt +++ b/workflow-ui/container-common/src/main/java/com/squareup/workflow1/ui/modal/AlertScreen.kt @@ -8,16 +8,13 @@ import com.squareup.workflow1.ui.WorkflowUiExperimentalApi * Models a typical "You sure about that?" alert box. */ @WorkflowUiExperimentalApi -// Can't quite deprecate this yet, because such warnings are impossible to suppress -// in the typealias uses in the Tic Tac Toe sample. Will uncomment before merging to main. -// https://github.com/square/workflow-kotlin/issues/589 -// @Deprecated( -// "Use AlertOverlay", -// ReplaceWith( -// "AlertOverlay(buttons, message, title, cancelable, onEvent)", -// "com.squareup.workflow1.ui.container.AlertOverlay" -// ) -// ) +@Deprecated( + "Use AlertOverlay", + ReplaceWith( + "AlertOverlay(buttons, message, title, cancelable, onEvent)", + "com.squareup.workflow1.ui.container.AlertOverlay" + ) +) public data class AlertScreen( val buttons: Map = emptyMap(), val message: String = "", diff --git a/workflow-ui/container-common/src/main/java/com/squareup/workflow1/ui/modal/HasModals.kt b/workflow-ui/container-common/src/main/java/com/squareup/workflow1/ui/modal/HasModals.kt index b44a43b9e2..97be1bf500 100644 --- a/workflow-ui/container-common/src/main/java/com/squareup/workflow1/ui/modal/HasModals.kt +++ b/workflow-ui/container-common/src/main/java/com/squareup/workflow1/ui/modal/HasModals.kt @@ -10,10 +10,7 @@ import com.squareup.workflow1.ui.WorkflowUiExperimentalApi * like `ModalContainer` in the `workflow-ui:core-android` module. */ @WorkflowUiExperimentalApi -// Can't quite deprecate this yet, because such warnings are impossible to suppress -// in the typealias uses in the Tic Tac Toe sample. Will uncomment before merging to main. -// https://github.com/square/workflow-kotlin/issues/589 -// @Deprecated("Use BodyAndModalsScreen") +@Deprecated("Use BodyAndModalsScreen") public interface HasModals { public val beneathModals: B public val modals: List diff --git a/workflow-ui/core-android/api/core-android.api b/workflow-ui/core-android/api/core-android.api index 27f46856d3..0b2c097cf2 100644 --- a/workflow-ui/core-android/api/core-android.api +++ b/workflow-ui/core-android/api/core-android.api @@ -1,3 +1,7 @@ +public abstract interface class com/squareup/workflow1/ui/AndroidOverlay : com/squareup/workflow1/ui/container/Overlay { + public abstract fun getDialogFactory ()Lcom/squareup/workflow1/ui/container/OverlayDialogFactory; +} + public final class com/squareup/workflow1/ui/AndroidRenderWorkflowKt { public static final fun renderWorkflowIn (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Landroidx/lifecycle/SavedStateHandle;Ljava/util/List;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/StateFlow; public static final fun renderWorkflowIn (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Landroidx/lifecycle/SavedStateHandle;Ljava/util/List;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/StateFlow; @@ -210,6 +214,10 @@ public final class com/squareup/workflow1/ui/container/AlertDialogThemeResId : c public synthetic fun getDefault ()Ljava/lang/Object; } +public final class com/squareup/workflow1/ui/container/AndroidDialogBoundsKt { + public static final fun setBounds (Landroid/app/Dialog;Landroid/graphics/Rect;)V +} + public abstract interface class com/squareup/workflow1/ui/container/AndroidOverlay : com/squareup/workflow1/ui/container/Overlay { public abstract fun getDialogFactory ()Lcom/squareup/workflow1/ui/container/OverlayDialogFactory; } @@ -247,13 +255,12 @@ public final class com/squareup/workflow1/ui/container/BackStackStateKeyKt { } public final class com/squareup/workflow1/ui/container/LayeredDialogs { - public fun (Landroid/content/Context;Lkotlin/jvm/functions/Function0;)V - public fun (Landroid/view/View;)V + public fun (Landroid/content/Context;ZLkotlin/jvm/functions/Function0;)V + public fun (Landroid/view/View;Z)V public final fun getHasDialogs ()Z public final fun onRestoreInstanceState (Lcom/squareup/workflow1/ui/container/LayeredDialogs$SavedState;)V public final fun onSaveInstanceState ()Lcom/squareup/workflow1/ui/container/LayeredDialogs$SavedState; - public final fun update (Ljava/util/List;Lcom/squareup/workflow1/ui/ViewEnvironment;Lkotlin/jvm/functions/Function0;)V - public static synthetic fun update$default (Lcom/squareup/workflow1/ui/container/LayeredDialogs;Ljava/util/List;Lcom/squareup/workflow1/ui/ViewEnvironment;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V + public final fun update (Ljava/util/List;Lcom/squareup/workflow1/ui/ViewEnvironment;)V } public final class com/squareup/workflow1/ui/container/LayeredDialogs$SavedState : android/os/Parcelable { @@ -270,6 +277,27 @@ public final class com/squareup/workflow1/ui/container/LayeredDialogs$SavedState public synthetic fun newArray (I)[Ljava/lang/Object; } +public abstract class com/squareup/workflow1/ui/container/ModalScreenOverlayDialogFactory : com/squareup/workflow1/ui/container/OverlayDialogFactory { + public fun (Lkotlin/reflect/KClass;)V + public synthetic fun buildDialog (Lcom/squareup/workflow1/ui/container/Overlay;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/content/Context;)Landroid/app/Dialog; + public final fun buildDialog (Lcom/squareup/workflow1/ui/container/ScreenOverlay;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/content/Context;)Landroid/app/Dialog; + public abstract fun buildDialogWithContentView (Landroid/view/View;)Landroid/app/Dialog; + public fun getType ()Lkotlin/reflect/KClass; + public abstract fun updateBounds (Landroid/app/Dialog;Landroid/graphics/Rect;)V + public synthetic fun updateDialog (Landroid/app/Dialog;Lcom/squareup/workflow1/ui/container/Overlay;Lcom/squareup/workflow1/ui/ViewEnvironment;)V + public final fun updateDialog (Landroid/app/Dialog;Lcom/squareup/workflow1/ui/container/ScreenOverlay;Lcom/squareup/workflow1/ui/ViewEnvironment;)V +} + +public final class com/squareup/workflow1/ui/container/ModalScreenOverlayOnBackPressed : com/squareup/workflow1/ui/ViewEnvironmentKey { + public static final field INSTANCE Lcom/squareup/workflow1/ui/container/ModalScreenOverlayOnBackPressed; + public fun getDefault ()Lcom/squareup/workflow1/ui/container/ModalScreenOverlayOnBackPressed$Handler; + public synthetic fun getDefault ()Ljava/lang/Object; +} + +public abstract interface class com/squareup/workflow1/ui/container/ModalScreenOverlayOnBackPressed$Handler { + public abstract fun onBackPressed (Landroid/view/View;)Z +} + public abstract interface class com/squareup/workflow1/ui/container/OverlayDialogFactory : com/squareup/workflow1/ui/ViewRegistry$Entry { public abstract fun buildDialog (Lcom/squareup/workflow1/ui/container/Overlay;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/content/Context;)Landroid/app/Dialog; public abstract fun updateDialog (Landroid/app/Dialog;Lcom/squareup/workflow1/ui/container/Overlay;Lcom/squareup/workflow1/ui/ViewEnvironment;)V diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidOverlay.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidOverlay.kt new file mode 100644 index 0000000000..d8629b6aef --- /dev/null +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidOverlay.kt @@ -0,0 +1,9 @@ +package com.squareup.workflow1.ui + +import com.squareup.workflow1.ui.container.Overlay +import com.squareup.workflow1.ui.container.OverlayDialogFactory + +@WorkflowUiExperimentalApi +public interface AndroidOverlay> : Overlay { + public val dialogFactory: OverlayDialogFactory +} diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/AlertOverlayDialogFactory.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/AlertOverlayDialogFactory.kt index 1b01bd89e2..25bfed0acb 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/AlertOverlayDialogFactory.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/AlertOverlayDialogFactory.kt @@ -4,7 +4,8 @@ import android.app.AlertDialog import android.app.Dialog import android.content.Context import android.content.DialogInterface -import android.view.View +import android.view.View.GONE +import android.view.View.VISIBLE import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.container.AlertOverlay.Button @@ -24,7 +25,27 @@ internal object AlertOverlayDialogFactory : OverlayDialogFactory { context: Context ): AlertDialog { return AlertDialog.Builder(context, initialEnvironment[AlertDialogThemeResId]) - .create() + .create().apply { + for (button in Button.values()) { + // We want to be able to update the alert while it's showing, including to maybe + // show more buttons than were there originally. The API for Android's `AlertDialog` + // makes you think you can do that, but it actually doesn't work. So we force + // `AlertDialog.Builder` to show every possible button; then we hide them all; + // and then we manage their visibility ourselves at update time. + // + // We also don't want Android to tear down the dialog without our say so -- + // again, we might need to update the thing. But there is a dismiss call + // built in to click handers put in place by `AlertDialog`. So, when we're + // preflighting every possible button, we put garbage click handlers in place. + // Then we replace them with our own, again at update time, by setting each live + // button's click handler directly, without letting `AlertDialog` interfere. + // + // https://github.com/square/workflow-kotlin/issues/138 + // + // Why " "? An empty string means no button. + setButton(button.toId(), " ") { _, _ -> } + } + } } override fun updateDialog( @@ -32,30 +53,22 @@ internal object AlertOverlayDialogFactory : OverlayDialogFactory { rendering: AlertOverlay, environment: ViewEnvironment ) { - val alertDialog = dialog as AlertDialog + (dialog as AlertDialog).apply { + if (rendering.cancelable) { + setOnCancelListener { rendering.onEvent(Canceled) } + setCancelable(true) + } else { + setCancelable(false) + } - if (rendering.cancelable) { - alertDialog.setOnCancelListener { rendering.onEvent(Canceled) } - alertDialog.setCancelable(true) - } else { - alertDialog.setCancelable(false) - } + setMessage(rendering.message) + setTitle(rendering.title) - for (button in Button.values()) { - rendering.buttons[button] - ?.let { name -> - alertDialog.setButton(button.toId(), name) { _, _ -> - rendering.onEvent(ButtonClicked(button)) - } - } - ?: run { - alertDialog.getButton(button.toId()) - ?.visibility = View.INVISIBLE - } + // The buttons won't actually exist until the dialog is showing. + if (isShowing) updateButtonsOnShow(rendering) else setOnShowListener { + updateButtonsOnShow(rendering) + } } - - alertDialog.setMessage(rendering.message) - alertDialog.setTitle(rendering.title) } private fun Button.toId(): Int = when (this) { @@ -63,4 +76,18 @@ internal object AlertOverlayDialogFactory : OverlayDialogFactory { NEGATIVE -> DialogInterface.BUTTON_NEGATIVE NEUTRAL -> DialogInterface.BUTTON_NEUTRAL } + + private fun AlertDialog.updateButtonsOnShow(rendering: AlertOverlay) { + setOnShowListener(null) + + for (button in Button.values()) getButton(button.toId()).visibility = GONE + + for (entry in rendering.buttons.entries) { + getButton(entry.key.toId())?.apply { + setOnClickListener { rendering.onEvent(ButtonClicked(entry.key)) } + text = entry.value + visibility = VISIBLE + } + } + } } diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/AndroidDialogBounds.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/AndroidDialogBounds.kt new file mode 100644 index 0000000000..301e929ba0 --- /dev/null +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/AndroidDialogBounds.kt @@ -0,0 +1,78 @@ +package com.squareup.workflow1.ui.container + +import android.app.Dialog +import android.graphics.Rect +import android.view.Gravity +import android.view.View +import android.view.Window +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.environment +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** + * Updates the size of the [Window] of the receiving [Dialog]. + * [bounds] is expected to be in global display coordinates, + * e.g. as returned from [View.getGlobalVisibleRect]. + * + * @see ModalScreenOverlayDialogFactory.updateBounds + */ +@WorkflowUiExperimentalApi +public fun Dialog.setBounds(bounds: Rect) { + window?.let { + it.attributes = it.attributes.apply { + gravity = Gravity.TOP or Gravity.START + width = bounds.width() + height = bounds.height() + x = bounds.left + y = bounds.top + } + } +} + +@WorkflowUiExperimentalApi +internal fun D.maintainBounds( + view: View, + onBoundsChange: (D, Rect) -> Unit +) { + maintainBounds(view.environment!!, onBoundsChange) +} + +@WorkflowUiExperimentalApi +internal fun D.maintainBounds( + environment: ViewEnvironment, + onBoundsChange: (D, Rect) -> Unit +) { + maintainBounds(environment[ModalArea].bounds, onBoundsChange) +} + +@WorkflowUiExperimentalApi +internal fun D.maintainBounds( + bounds: StateFlow, + onBoundsChange: (D, Rect) -> Unit +) { + val window = requireNotNull(window) { "Dialog must be attached to a window." } + window.callback = object : Window.Callback by window.callback { + var scope: CoroutineScope? = null + + override fun onAttachedToWindow() { + scope = CoroutineScope(Dispatchers.Main.immediate).also { + bounds.onEach { b -> onBoundsChange(this@maintainBounds, b) } + .launchIn(it) + } + } + + override fun onDetachedFromWindow() { + scope?.cancel() + scope = null + } + } + + // If already attached, set the bounds eagerly. + if (window.peekDecorView()?.isAttachedToWindow == true) onBoundsChange(this, bounds.value) +} diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/BodyAndModalsContainer.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/BodyAndModalsContainer.kt index ed5011e6af..d1d2a4ffff 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/BodyAndModalsContainer.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/BodyAndModalsContainer.kt @@ -1,15 +1,16 @@ package com.squareup.workflow1.ui.container import android.content.Context +import android.graphics.Rect import android.os.Parcel import android.os.Parcelable import android.os.Parcelable.Creator -import android.os.SystemClock import android.util.AttributeSet +import android.view.KeyEvent import android.view.MotionEvent -import android.view.MotionEvent.ACTION_CANCEL import android.view.ViewGroup import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewTreeObserver.OnGlobalLayoutListener import android.widget.FrameLayout import com.squareup.workflow1.ui.ManualScreenViewFactory import com.squareup.workflow1.ui.R @@ -18,6 +19,8 @@ import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.WorkflowViewStub import com.squareup.workflow1.ui.bindShowRendering +import com.squareup.workflow1.ui.showFirstRendering +import kotlinx.coroutines.flow.MutableStateFlow @WorkflowUiExperimentalApi internal class BodyAndModalsContainer @JvmOverloads constructor( @@ -26,32 +29,90 @@ internal class BodyAndModalsContainer @JvmOverloads constructor( defStyle: Int = 0, defStyleRes: Int = 0 ) : FrameLayout(context, attributeSet, defStyle, defStyleRes) { - private val baseViewStub: WorkflowViewStub = WorkflowViewStub(context).also { addView(it, ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)) } - private val dialogs = LayeredDialogs(this) + private val dialogs = LayeredDialogs(view = this, modal = true) + // The bounds of this view in global (display) coordinates, as reported + // by getGlobalVisibleRect. + // + // Made available to managed ModalScreenOverlayDialogFactory instances + // via the ModalArea key in ViewEnvironment. When this updates their + // updateBounds methods will fire. They should resize themselves to + // avoid covering peers of this view. + private val bounds = MutableStateFlow(Rect()) + private val boundsRect = Rect() + + private val boundsListener = OnGlobalLayoutListener { + if (getGlobalVisibleRect(boundsRect) && boundsRect != bounds.value) { + bounds.value = Rect(boundsRect) + } + // Should we close the dialogs if getGlobalVisibleRect returns false? + // https://github.com/square/workflow-kotlin/issues/599 + } + + // Note similar code in DialogHolder. + private var allowEvents = true + set(value) { + val was = field + field = value + if (value != was) { + // https://stackoverflow.com/questions/2886407/dealing-with-rapid-tapping-on-buttons + // If any motion events were enqueued on the main thread, cancel them. + dispatchCancelEvent { super.dispatchTouchEvent(it) } + // When we cancel, have to warn things like RecyclerView that handle streams + // of motion events and eventually dispatch input events (click, key pressed, etc.) + // based on them. + cancelPendingInputEvents() + } + } fun update( newScreen: BodyAndModalsScreen<*, *>, viewEnvironment: ViewEnvironment ) { - baseViewStub.show(newScreen.body, viewEnvironment.withBackStackStateKeyPrefix("[base]")) - dialogs.update(newScreen.modals, viewEnvironment) { - // If a new Dialog is being shown, cancel any late breaking events that - // got queued up on the main thread while we were spinning it up. - dropLateEvents() - } + val showingModals = newScreen.modals.isNotEmpty() + + // There is a long wait from when we show a dialog until it starts blocking + // events for us. To compensate we ignore all touches while any dialogs exist. + allowEvents = !showingModals + + val baseEnv = viewEnvironment.withBackStackStateKeyPrefix("[base]") + + if (showingModals) viewEnvironment + (CoveredByModal to true) else viewEnvironment + baseViewStub.show(newScreen.body, baseEnv) + + // Allow modal dialogs to restrict themselves to cover only this view. + val dialogsEnv = + if (showingModals) viewEnvironment + (ModalArea to ModalArea(bounds)) else viewEnvironment + + dialogs.update(newScreen.modals, dialogsEnv) } - /** - * There is a long wait from when we show a dialog until it starts blocking - * events for us. To compensate we ignore all touches while any dialogs exist. - */ - override fun dispatchTouchEvent(ev: MotionEvent): Boolean { - // See also the call to [dropLateEvents] from [update]. - return dialogs.hasDialogs || super.dispatchTouchEvent(ev) + override fun onAttachedToWindow() { + super.onAttachedToWindow() + boundsListener.onGlobalLayout() + viewTreeObserver.addOnGlobalLayoutListener(boundsListener) + // Ugly, but here in case a strange parent detaches and re-attaches us. + // https://github.com/square/workflow-kotlin/issues/314 + showFirstRendering() + } + + override fun onDetachedFromWindow() { + // Don't leak the dialogs if we're suddenly yanked out of view. + // https://github.com/square/workflow-kotlin/issues/314 + dialogs.update(emptyList(), ViewEnvironment()) + viewTreeObserver.removeOnGlobalLayoutListener(boundsListener) + bounds.value = Rect() + super.onDetachedFromWindow() + } + + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + return !allowEvents || super.dispatchTouchEvent(event) + } + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + return !allowEvents || super.dispatchKeyEvent(event) } override fun onSaveInstanceState(): Parcelable { @@ -72,22 +133,6 @@ internal class BodyAndModalsContainer @JvmOverloads constructor( // the call to super. Make a no-op call. } - private fun dropLateEvents() { - // If any motion events were enqueued on the main thread, cancel them. - val now = SystemClock.uptimeMillis() - MotionEvent.obtain(now, now, ACTION_CANCEL, 0.0f, 0.0f, 0).also { cancelEvent -> - super.dispatchTouchEvent(cancelEvent) - cancelEvent.recycle() - } - - // View classes like RecyclerView handle streams of motion events - // and eventually dispatch input events (click, key pressed, etc.) - // based on them. This call warns them to clean up any such work - // that might have been in midstream, as opposed to crashing when - // we post that cancel event. - cancelPendingInputEvents() - } - private class SavedState : BaseSavedState { constructor( superState: Parcelable, diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/CoveredByModal.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/CoveredByModal.kt new file mode 100644 index 0000000000..db771dccc1 --- /dev/null +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/CoveredByModal.kt @@ -0,0 +1,13 @@ +package com.squareup.workflow1.ui.container + +import com.squareup.workflow1.ui.ViewEnvironmentKey +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi + +/** + * True in views managed by [BodyAndModalsScreen] when their events are being blocked + * by a modal [Overlay]. + */ +@WorkflowUiExperimentalApi +internal object CoveredByModal : ViewEnvironmentKey(type = Boolean::class) { + override val default: Boolean = false +} diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/DialogHolder.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/DialogHolder.kt index 7acab40018..f58214ccee 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/DialogHolder.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/DialogHolder.kt @@ -6,6 +6,9 @@ import android.os.Bundle import android.os.Parcel import android.os.Parcelable import android.os.Parcelable.Creator +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.Window import androidx.core.view.doOnAttach import androidx.core.view.doOnDetach import androidx.lifecycle.Lifecycle.Event.ON_DESTROY @@ -18,10 +21,16 @@ import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.androidx.WorkflowLifecycleOwner import com.squareup.workflow1.ui.compatible +/** + * Used by [LayeredDialogs] to keep a [Dialog] tied to its [rendering] and [environment]. + * + * @param modal if true, ignore keyboard and touch events when [CoveredByModal] is also true + */ @WorkflowUiExperimentalApi internal class DialogHolder( initialRendering: T, initialViewEnvironment: ViewEnvironment, + private val modal: Boolean, private val context: Context, private val factory: OverlayDialogFactory ) { @@ -34,8 +43,37 @@ internal class DialogHolder( private var dialogOrNull: Dialog? = null + // Note similar code in BodyAndModalsContainer + private var allowEvents = true + set(value) { + val was = field + field = value + dialogOrNull?.window?.takeIf { value != was }?.let { window -> + // https://stackoverflow.com/questions/2886407/dealing-with-rapid-tapping-on-buttons + // If any motion events were enqueued on the main thread, cancel them. + dispatchCancelEvent { window.superDispatchTouchEvent(it) } + // When we cancel, have to warn things like RecyclerView that handle streams + // of motion events and eventually dispatch input events (click, key pressed, etc.) + // based on them. + window.peekDecorView()?.cancelPendingInputEvents() + } + } + fun show(parentLifecycleOwner: LifecycleOwner?) { requireDialog().let { dialog -> + dialog.window?.let { window -> + val realWindowCallback = window.callback + window.callback = object : Window.Callback by realWindowCallback { + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + return !allowEvents || realWindowCallback.dispatchTouchEvent(event) + } + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + return !allowEvents || realWindowCallback.dispatchKeyEvent(event) + } + } + } + dialog.show() dialog.window?.decorView?.also { decorView -> // Implementations of buildDialog may set their own WorkflowLifecycleOwner on the @@ -93,6 +131,7 @@ internal class DialogHolder( @Suppress("UNCHECKED_CAST") this.rendering = rendering as T this.environment = environment + allowEvents = !modal || !environment[CoveredByModal] dialogOrNull?.let { dialog -> factory.updateDialog(dialog, this.rendering, this.environment) diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/DispatchCancelEvent.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/DispatchCancelEvent.kt new file mode 100644 index 0000000000..c8b5668818 --- /dev/null +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/DispatchCancelEvent.kt @@ -0,0 +1,13 @@ +package com.squareup.workflow1.ui.container + +import android.os.SystemClock +import android.view.MotionEvent +import android.view.MotionEvent.ACTION_CANCEL + +internal fun dispatchCancelEvent(dispatchTouchEvent: (MotionEvent) -> Unit) { + val now = SystemClock.uptimeMillis() + MotionEvent.obtain(now, now, ACTION_CANCEL, 0.0f, 0.0f, 0).let { cancelEvent -> + dispatchTouchEvent(cancelEvent) + cancelEvent.recycle() + } +} diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/LayeredDialogs.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/LayeredDialogs.kt index f9d3b06168..3bae039ae3 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/LayeredDialogs.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/LayeredDialogs.kt @@ -16,17 +16,25 @@ import com.squareup.workflow1.ui.container.DialogHolder.KeyAndBundle * to reflect lists of [Overlay]. Can be used to create custom [Overlay]-based * layouts if [BodyAndModalsScreen] or the default [View] bound to it are too restrictive. * Provides a [LifecycleOwner] per managed dialog, and view persistence support. + * + * @param modal When true, only the top-most dialog is allowed to process touch and key events */ @WorkflowUiExperimentalApi public class LayeredDialogs( private val context: Context, + private val modal: Boolean, private val getParentLifecycleOwner: () -> LifecycleOwner? ) { /** * Builds a [LayeredDialogs] which looks through [view] to find its parent * [LifecycleOwner][getParentLifecycleOwner]. + * + * @param modal When true, only the top-most dialog is allowed to process touch and key events */ - public constructor(view: View) : this(view.context, { WorkflowLifecycleOwner.get(view) }) + public constructor( + view: View, + modal: Boolean + ) : this(view.context, modal, { WorkflowLifecycleOwner.get(view) }) private var holders: List> = emptyList() @@ -45,8 +53,7 @@ public class LayeredDialogs( */ public fun update( overlays: List, - viewEnvironment: ViewEnvironment, - beforeShowing: () -> Unit = {} + viewEnvironment: ViewEnvironment ) { // Any nested back stacks have to provide saved state registries of their // own, but these things share a global namespace. To make that practical @@ -54,7 +61,10 @@ public class LayeredDialogs( // via withBackStackStateKeyPrefix. val overlayEnvironments = overlays.mapIndexed { index, _ -> - viewEnvironment.withBackStackStateKeyPrefix("[${index + 1}]") + viewEnvironment.withBackStackStateKeyPrefix("[${index + 1}]").let { + // If we're in modal config, only the top dialog should accept events. + if (modal && index < overlays.size - 1) it + (CoveredByModal to true) else it + } } // On each update we build a new list of the running dialogs, both the @@ -79,14 +89,11 @@ public class LayeredDialogs( // break if we call it to early. Need to store them somewhere else. overlay.toDialogFactory(overlayEnvironment).let { dialogFactory -> DialogHolder( - overlay, overlayEnvironment, context, dialogFactory + overlay, overlayEnvironment, modal, context, dialogFactory ).also { newHolder -> // Has the side effect of creating the dialog if necessary. newHolder.takeRendering(overlay, overlayEnvironment) - // Custom behavior from the container to be fired when we show a new dialog. - // The default modal container uses this to flush its body of partially - // processed touch events that should have been blocked by the modal. - beforeShowing() + // Show the dialog. We use the container-provided LifecycleOwner // to host an androidx ViewTreeLifecycleOwner and SavedStateRegistryOwner // for each dialog. These are generally expected these days, and absolutely diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ModalArea.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ModalArea.kt new file mode 100644 index 0000000000..5472e9912a --- /dev/null +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ModalArea.kt @@ -0,0 +1,20 @@ +package com.squareup.workflow1.ui.container + +import android.graphics.Rect +import com.squareup.workflow1.ui.ViewEnvironmentKey +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Reports the the area of the screen whose events should be blocked by any modal [Overlay]. + * Expected to be supplied by containers that support [BodyAndModalsScreen]. + */ +@WorkflowUiExperimentalApi +internal class ModalArea( + val bounds: StateFlow +) { + companion object : ViewEnvironmentKey(type = ModalArea::class) { + override val default: ModalArea = ModalArea(MutableStateFlow(Rect())) + } +} diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ModalScreenOverlayDialogFactory.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ModalScreenOverlayDialogFactory.kt new file mode 100644 index 0000000000..c1f052ed79 --- /dev/null +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ModalScreenOverlayDialogFactory.kt @@ -0,0 +1,109 @@ +package com.squareup.workflow1.ui.container + +import android.app.Dialog +import android.content.Context +import android.graphics.Rect +import android.view.KeyEvent +import android.view.KeyEvent.ACTION_UP +import android.view.KeyEvent.KEYCODE_BACK +import android.view.KeyEvent.KEYCODE_ESCAPE +import android.view.View +import android.view.Window +import android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL +import com.squareup.workflow1.ui.R +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.backPressedHandler +import com.squareup.workflow1.ui.buildView +import com.squareup.workflow1.ui.environment +import com.squareup.workflow1.ui.showRendering +import java.lang.IllegalStateException +import kotlin.reflect.KClass + +/** + * Convenient base class for building [ScreenOverlay] UIs that are compatible + * with [View.backPressedHandler], and which honor the [ModalArea] constraint + * placed in the [ViewEnvironment] by the standard [BodyAndModalsScreen] container. + * + * Ironically, [Dialog] instances are created with [FLAG_NOT_TOUCH_MODAL], to ensure + * that events outside of the bounds reported by [updateBounds] reach views in + * lower windows. See that method for details. + */ +@WorkflowUiExperimentalApi +public abstract class ModalScreenOverlayDialogFactory>( + override val type: KClass +) : OverlayDialogFactory { + + /** + * Called from [buildDialog]. Builds (but does not show) the [Dialog] to + * display a [contentView] built for a [ScreenOverlay.content]. + */ + public abstract fun buildDialogWithContentView(contentView: View): Dialog + + /** + * If the [ScreenOverlay] displayed by a [dialog] created by this + * factory is contained in a [BodyAndModalsScreen], this method will + * be called to report the bounds of the managing view. It is expected + * that such a dialog will be restricted to those bounds. + * + * Honoring this contract makes it easy to define areas of the display + * that are outside of the "shadow" of a modal dialog. Imagine an app + * with a status bar that should not be covered by modals. + * + * @see Dialog.setBounds + */ + public abstract fun updateBounds( + dialog: Dialog, + bounds: Rect + ) + + final override fun buildDialog( + initialRendering: O, + initialEnvironment: ViewEnvironment, + context: Context + ): Dialog { + val contentView = initialRendering.content.buildView(initialEnvironment, context).apply { + // If the content view has no backPressedHandler, add a no-op one to + // ensure that the `onBackPressed` call below will not leak up to handlers + // that should be blocked by this modal session. + if (backPressedHandler == null) backPressedHandler = { } + } + + return buildDialogWithContentView(contentView).also { dialog -> + val window = requireNotNull(dialog.window) { "Dialog must be attached to a window." } + + // There is no Dialog.getContentView method, and no reliable way to reverse + // engineer one (no, android.R.id.content doesn't work). So we stick the + // contentView in a tag here, where updateDialog can find it later. + window.peekDecorView()?.setTag(R.id.workflow_modal_dialog_content, contentView) + ?: throw IllegalStateException("Expected decorView to have been built.") + + val realWindowCallback = window.callback + window.callback = object : Window.Callback by realWindowCallback { + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + val isBackPress = (event.keyCode == KEYCODE_BACK || event.keyCode == KEYCODE_ESCAPE) && + event.action == ACTION_UP + + return when { + isBackPress -> contentView.environment?.get(ModalScreenOverlayOnBackPressed) + ?.onBackPressed(contentView) == true + else -> realWindowCallback.dispatchKeyEvent(event) + } + } + } + + window.setFlags(FLAG_NOT_TOUCH_MODAL, FLAG_NOT_TOUCH_MODAL) + dialog.maintainBounds(contentView) { d, b -> updateBounds(d, Rect(b)) } + } + } + + final override fun updateDialog( + dialog: Dialog, + rendering: O, + environment: ViewEnvironment + ) { + dialog.window?.peekDecorView() + ?.let { it.getTag(R.id.workflow_modal_dialog_content) as? View } + ?.showRendering(rendering.content, environment) + } +} diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ModalScreenOverlayOnBackPressed.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ModalScreenOverlayOnBackPressed.kt new file mode 100644 index 0000000000..3641e1f44e --- /dev/null +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ModalScreenOverlayOnBackPressed.kt @@ -0,0 +1,40 @@ +package com.squareup.workflow1.ui.container + +import android.view.View +import com.squareup.workflow1.ui.ViewEnvironmentKey +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.container.ModalScreenOverlayOnBackPressed.Handler +import com.squareup.workflow1.ui.onBackPressedDispatcherOwnerOrNull + +/** + * The function called to handle back button events in [Dialog][android.app.Dialog]s built + * by [ModalScreenOverlayDialogFactory]. The default implementation uses the + * [Activity][android.app.Activity]'s + * [OnBackPressedDispatcher][androidx.activity.OnBackPressedDispatcher]. + * + * This is a hook to accommodate apps that have a back button handling scheme + * that predates `OnBackPressedDispatcher`. + */ +@WorkflowUiExperimentalApi +public object ModalScreenOverlayOnBackPressed : ViewEnvironmentKey( + type = Handler::class +) { + public fun interface Handler { + /** + * Called when the device back button is pressed and a dialog built by a + * [ModalScreenOverlayDialogFactory] has window focus. + * + * @return true if the back press event was consumed + */ + public fun onBackPressed(contentView: View): Boolean + } + + override val default: Handler = Handler { view -> + view.context.onBackPressedDispatcherOwnerOrNull() + ?.onBackPressedDispatcher + ?.let { + if (it.hasEnabledCallbacks()) it.onBackPressed() + } + true + } +} 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 2667357249..0f1c293999 100644 --- a/workflow-ui/core-android/src/main/res/values/ids.xml +++ b/workflow-ui/core-android/src/main/res/values/ids.xml @@ -4,13 +4,7 @@ BackStackContainer restricts animation to child with this id, if there is one. Otherwise animates its entire body. --> - - - - - - - + @@ -21,6 +15,17 @@ + + + + + + + + diff --git a/workflow-ui/core-common/api/core-common.api b/workflow-ui/core-common/api/core-common.api index 465cd71bea..95266c518e 100644 --- a/workflow-ui/core-common/api/core-common.api +++ b/workflow-ui/core-common/api/core-common.api @@ -179,11 +179,13 @@ public final class com/squareup/workflow1/ui/container/BackStackScreenKt { } public final class com/squareup/workflow1/ui/container/BodyAndModalsScreen : com/squareup/workflow1/ui/Screen { - public fun (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/container/Overlay;)V public fun (Lcom/squareup/workflow1/ui/Screen;Ljava/util/List;)V public synthetic fun (Lcom/squareup/workflow1/ui/Screen;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lcom/squareup/workflow1/ui/Screen;[Lcom/squareup/workflow1/ui/container/Overlay;)V public final fun getBody ()Lcom/squareup/workflow1/ui/Screen; public final fun getModals ()Ljava/util/List; + public final fun mapBody (Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/ui/container/BodyAndModalsScreen; + public final fun mapModals (Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/ui/container/BodyAndModalsScreen; } public final class com/squareup/workflow1/ui/container/EnvironmentScreen : com/squareup/workflow1/ui/Compatible, com/squareup/workflow1/ui/Screen { @@ -202,3 +204,12 @@ public final class com/squareup/workflow1/ui/container/EnvironmentScreenKt { public abstract interface class com/squareup/workflow1/ui/container/Overlay { } +public abstract interface class com/squareup/workflow1/ui/container/ScreenOverlay : com/squareup/workflow1/ui/Compatible, com/squareup/workflow1/ui/container/Overlay { + public abstract fun getCompatibilityKey ()Ljava/lang/String; + public abstract fun getContent ()Lcom/squareup/workflow1/ui/Screen; +} + +public final class com/squareup/workflow1/ui/container/ScreenOverlay$DefaultImpls { + public static fun getCompatibilityKey (Lcom/squareup/workflow1/ui/container/ScreenOverlay;)Ljava/lang/String; +} + diff --git a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/AsScreen.kt b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/AsScreen.kt index 6d69d8b637..070da05761 100644 --- a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/AsScreen.kt +++ b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/AsScreen.kt @@ -4,10 +4,10 @@ package com.squareup.workflow1.ui /** * Provides backward compatibility for legacy non-[Screen] renderings based on - * `ViewFactory` and `AndroidViewRendering`. Should be used only as a stop gap - * until the wrapped [rendering] can be updated to implement [Screen]. + * `ViewFactory` and `AndroidViewRendering`, now deprecated. Should be used only + * as a stop gap until the wrapped [rendering] can be updated to implement [Screen], + * since the deprecated interfaces will soon be deleted. */ -@Deprecated("Implement Screen directly.") @WorkflowUiExperimentalApi public class AsScreen( public val rendering: W diff --git a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/container/BodyAndModalsScreen.kt b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/container/BodyAndModalsScreen.kt index b26d001e15..0e598389d7 100644 --- a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/container/BodyAndModalsScreen.kt +++ b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/container/BodyAndModalsScreen.kt @@ -15,6 +15,14 @@ public class BodyAndModalsScreen( ) : Screen { public constructor( body: B, - modal: M - ) : this(body, listOf(modal)) + vararg modals: M + ) : this(body, modals.toList()) + + public fun mapBody(transform: (B) -> S): BodyAndModalsScreen { + return BodyAndModalsScreen(transform(body), modals) + } + + public fun mapModals(transform: (M) -> O): BodyAndModalsScreen { + return BodyAndModalsScreen(body, modals.map(transform)) + } } diff --git a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/container/ScreenOverlay.kt b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/container/ScreenOverlay.kt new file mode 100644 index 0000000000..bcb60e19e9 --- /dev/null +++ b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/container/ScreenOverlay.kt @@ -0,0 +1,16 @@ +package com.squareup.workflow1.ui.container + +import com.squareup.workflow1.ui.Compatible +import com.squareup.workflow1.ui.Compatible.Companion.keyFor +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi + +/** + * An [Overlay] built around a root [content] [Screen]. + */ +@WorkflowUiExperimentalApi +public interface ScreenOverlay : Overlay, Compatible { + public val content: C + + override val compatibilityKey: String get() = keyFor(content, "ScreenOverlay") +}