diff --git a/samples/tictactoe/app/src/androidTest/java/com/squareup/sample/TicTacToeEspressoTest.kt b/samples/tictactoe/app/src/androidTest/java/com/squareup/sample/TicTacToeEspressoTest.kt index b3364cc098..de0128b725 100644 --- a/samples/tictactoe/app/src/androidTest/java/com/squareup/sample/TicTacToeEspressoTest.kt +++ b/samples/tictactoe/app/src/androidTest/java/com/squareup/sample/TicTacToeEspressoTest.kt @@ -112,6 +112,27 @@ class TicTacToeEspressoTest { .check(matches(isDisplayed())) } + @Test fun canGoBackFromAlert() { + inAnyView(withId(R.id.login_email)).type("foo@bar") + inAnyView(withId(R.id.login_password)).type("password") + inAnyView(withId(R.id.login_button)).perform(click()) + + inAnyView(withId(R.id.player_X)).type("Mister X") + inAnyView(withId(R.id.player_O)).type("Sister O") + inAnyView(withId(R.id.start_game)).perform(click()) + + actuallyPressBack() + inAnyView(withText("Do you really want to concede the game?")) + .check(matches(isDisplayed())) + inAnyView(withText("I QUIT")).perform(click()) + inAnyView(withText("Really?")) + .check(matches(isDisplayed())) + + actuallyPressBack() + // Click a game cell to confirm the alert went away. + clickCell(0) + } + @Test fun canGoBackInModalViewAndSeeRestoredViewState() { // Log in and hit the 2fa screen. inAnyView(withId(R.id.login_email)).type("foo@2fa") diff --git a/workflow-ui/core-android/api/core-android.api b/workflow-ui/core-android/api/core-android.api index b2266762a3..774b116628 100644 --- a/workflow-ui/core-android/api/core-android.api +++ b/workflow-ui/core-android/api/core-android.api @@ -429,16 +429,19 @@ public final class com/squareup/workflow1/ui/container/AlertDialogThemeResId : c public synthetic fun getDefault ()Ljava/lang/Object; } -public class com/squareup/workflow1/ui/container/AlertOverlayDialogFactory : com/squareup/workflow1/ui/container/OverlayDialogFactory { +public final class com/squareup/workflow1/ui/container/AlertOverlayDialogFactory : com/squareup/workflow1/ui/container/OverlayDialogFactory { public fun ()V public fun buildDialog (Lcom/squareup/workflow1/ui/container/AlertOverlay;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/content/Context;)Lcom/squareup/workflow1/ui/container/OverlayDialogHolder; public synthetic fun buildDialog (Lcom/squareup/workflow1/ui/container/Overlay;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/content/Context;)Lcom/squareup/workflow1/ui/container/OverlayDialogHolder; public fun getType ()Lkotlin/reflect/KClass; } +public final class com/squareup/workflow1/ui/container/AlertOverlayDialogFactoryKt { + public static final fun toDialogHolder (Landroid/app/AlertDialog;Lcom/squareup/workflow1/ui/ViewEnvironment;)Lcom/squareup/workflow1/ui/container/OverlayDialogHolder; +} + public final class com/squareup/workflow1/ui/container/AndroidDialogBoundsKt { public static final fun maintainBounds (Landroid/app/Dialog;Lcom/squareup/workflow1/ui/ViewEnvironment;Lkotlin/jvm/functions/Function1;)V - public static final fun maintainBounds (Landroid/app/Dialog;Lkotlinx/coroutines/flow/StateFlow;Lkotlin/jvm/functions/Function1;)V public static final fun setBounds (Landroid/app/Dialog;Landroid/graphics/Rect;)V } @@ -612,26 +615,6 @@ public final class com/squareup/workflow1/ui/container/LayeredDialogSessions$Sav public synthetic fun newArray (I)[Ljava/lang/Object; } -public abstract interface class com/squareup/workflow1/ui/container/ModalScreenOverlayBackButtonHelper { - public static final field Companion Lcom/squareup/workflow1/ui/container/ModalScreenOverlayBackButtonHelper$Companion; - public abstract fun onBackPressed (Landroid/view/View;)Z - public abstract fun onContentViewUpdate (Landroid/view/View;)V -} - -public final class com/squareup/workflow1/ui/container/ModalScreenOverlayBackButtonHelper$Companion : com/squareup/workflow1/ui/ViewEnvironmentKey { - public fun getDefault ()Lcom/squareup/workflow1/ui/container/ModalScreenOverlayBackButtonHelper; - public synthetic fun getDefault ()Ljava/lang/Object; -} - -public final class com/squareup/workflow1/ui/container/ModalScreenOverlayBackButtonHelper$DefaultImpls { - public static fun onBackPressed (Lcom/squareup/workflow1/ui/container/ModalScreenOverlayBackButtonHelper;Landroid/view/View;)Z - public static fun onContentViewUpdate (Lcom/squareup/workflow1/ui/container/ModalScreenOverlayBackButtonHelper;Landroid/view/View;)V -} - -public final class com/squareup/workflow1/ui/container/ModalScreenOverlayBackButtonHelperKt { - public static final fun plus (Lcom/squareup/workflow1/ui/ViewEnvironment;Lcom/squareup/workflow1/ui/container/ModalScreenOverlayBackButtonHelper;)Lcom/squareup/workflow1/ui/ViewEnvironment; -} - public final class com/squareup/workflow1/ui/container/OverlayArea { public static final field Companion Lcom/squareup/workflow1/ui/container/OverlayArea$Companion; public fun (Lkotlinx/coroutines/flow/StateFlow;)V @@ -673,6 +656,7 @@ public abstract interface class com/squareup/workflow1/ui/container/OverlayDialo public static final field Companion Lcom/squareup/workflow1/ui/container/OverlayDialogHolder$Companion; public abstract fun getDialog ()Landroid/app/Dialog; public abstract fun getEnvironment ()Lcom/squareup/workflow1/ui/ViewEnvironment; + public abstract fun getOnBackPressed ()Lkotlin/jvm/functions/Function0; public abstract fun getOnUpdateBounds ()Lkotlin/jvm/functions/Function1; public abstract fun getRunner ()Lkotlin/jvm/functions/Function2; } @@ -691,24 +675,24 @@ public final class com/squareup/workflow1/ui/container/OverlayDialogHolder$Compa } public final class com/squareup/workflow1/ui/container/OverlayDialogHolderKt { - public static final fun OverlayDialogHolder (Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/app/Dialog;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Lcom/squareup/workflow1/ui/container/OverlayDialogHolder; - public static synthetic fun OverlayDialogHolder$default (Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/app/Dialog;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lcom/squareup/workflow1/ui/container/OverlayDialogHolder; + public static final fun OverlayDialogHolder (Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/app/Dialog;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;)Lcom/squareup/workflow1/ui/container/OverlayDialogHolder; + public static synthetic fun OverlayDialogHolder$default (Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/app/Dialog;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lcom/squareup/workflow1/ui/container/OverlayDialogHolder; public static final fun canShow (Lcom/squareup/workflow1/ui/container/OverlayDialogHolder;Lcom/squareup/workflow1/ui/container/Overlay;)Z public static final fun getShowing (Lcom/squareup/workflow1/ui/container/OverlayDialogHolder;)Lcom/squareup/workflow1/ui/container/Overlay; public static final fun show (Lcom/squareup/workflow1/ui/container/OverlayDialogHolder;Lcom/squareup/workflow1/ui/container/Overlay;Lcom/squareup/workflow1/ui/ViewEnvironment;)V } public final class com/squareup/workflow1/ui/container/RealOverlayDialogHolder : com/squareup/workflow1/ui/container/OverlayDialogHolder { - public fun (Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/app/Dialog;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V + public fun (Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/app/Dialog;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;)V public fun getDialog ()Landroid/app/Dialog; public fun getEnvironment ()Lcom/squareup/workflow1/ui/ViewEnvironment; + public fun getOnBackPressed ()Lkotlin/jvm/functions/Function0; public fun getOnUpdateBounds ()Lkotlin/jvm/functions/Function1; public fun getRunner ()Lkotlin/jvm/functions/Function2; } public class com/squareup/workflow1/ui/container/ScreenOverlayDialogFactory : com/squareup/workflow1/ui/container/OverlayDialogFactory { public fun (Lkotlin/reflect/KClass;)V - public fun buildContent (Lcom/squareup/workflow1/ui/ScreenViewFactory;Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/content/Context;)Lcom/squareup/workflow1/ui/ScreenViewHolder; public synthetic fun buildDialog (Lcom/squareup/workflow1/ui/container/Overlay;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/content/Context;)Lcom/squareup/workflow1/ui/container/OverlayDialogHolder; public final fun buildDialog (Lcom/squareup/workflow1/ui/container/ScreenOverlay;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/content/Context;)Lcom/squareup/workflow1/ui/container/OverlayDialogHolder; public fun buildDialogWithContent (Lcom/squareup/workflow1/ui/container/ScreenOverlay;Lcom/squareup/workflow1/ui/ViewEnvironment;Lcom/squareup/workflow1/ui/ScreenViewHolder;)Lcom/squareup/workflow1/ui/container/OverlayDialogHolder; diff --git a/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/container/DialogIntegrationTest.kt b/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/container/DialogIntegrationTest.kt index 9b81a8c1fa..f88280f01b 100644 --- a/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/container/DialogIntegrationTest.kt +++ b/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/container/DialogIntegrationTest.kt @@ -1,7 +1,6 @@ package com.squareup.workflow1.ui.container import android.app.Dialog -import android.content.Context import android.view.View import android.widget.EditText import androidx.activity.ComponentActivity @@ -55,24 +54,17 @@ internal class DialogIntegrationTest { object : ScreenOverlayDialogFactory( type = DialogRendering::class ) { - override fun buildContent( - viewFactory: ScreenViewFactory, - initialContent: ContentRendering, - initialEnvironment: ViewEnvironment, - context: Context - ): ScreenViewHolder = - super.buildContent(viewFactory, initialContent, initialEnvironment, context).also { - latestContentView = it.view - } - override fun buildDialogWithContent( initialRendering: DialogRendering, initialEnvironment: ViewEnvironment, content: ScreenViewHolder - ): OverlayDialogHolder = - super.buildDialogWithContent(initialRendering, initialEnvironment, content).also { + ): OverlayDialogHolder { + latestContentView = content.view + + return super.buildDialogWithContent(initialRendering, initialEnvironment, content).also { latestDialog = it.dialog } + } } } diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ScreenViewHolder.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ScreenViewHolder.kt index fab3adcd58..700d388081 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ScreenViewHolder.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ScreenViewHolder.kt @@ -28,6 +28,9 @@ public interface ScreenViewHolder { /** * The function that is run by [show] to update [view] with a new [Screen] rendering and * [ViewEnvironment]. + * + * Prefer calling [show] to using this directly, to ensure that [Showing] is + * maintained correctly, and [showing] keeps working. */ public val runner: ScreenViewRunner @@ -103,6 +106,10 @@ public fun ScreenViewHolder.show( /** * Returns the [Screen] most recently used to update the receiver's [view][ScreenViewHolder.view] * via a call to [show]. + * + * Note that the exact type of the returned [Screen] is likely not to match that of + * the receiver's `ScreenT` type parameter, e.g. if a + * [wrapping view factory][ScreenViewFactory.forWrapper] is in use. */ @WorkflowUiExperimentalApi public val ScreenViewHolder<*>.showing: Screen 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 b7b38efa21..0479b7e60c 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 @@ -16,84 +16,98 @@ import com.squareup.workflow1.ui.container.AlertOverlay.Event.Canceled import kotlin.reflect.KClass /** - * Default [OverlayDialogFactory] for [AlertOverlay]. + * Default [OverlayDialogFactory] for [AlertOverlay], uses [AlertDialog]. + * See [AlertDialog.toDialogHolder] to use [AlertDialog] for other purposes. * - * This class is non-final for ease of customization of [AlertOverlay] handling, - * see [OverlayDialogFactoryFinder] for details. + * - To customize [AlertDialog] theming, see [AlertDialogThemeResId] + * - To customize how [AlertOverlay] is handled more generally, set up a + * custom [OverlayDialogFactoryFinder]. */ @WorkflowUiExperimentalApi -public open class AlertOverlayDialogFactory : OverlayDialogFactory { +internal class AlertOverlayDialogFactory : OverlayDialogFactory { override val type: KClass = AlertOverlay::class override fun buildDialog( initialRendering: AlertOverlay, initialEnvironment: ViewEnvironment, context: Context - ): OverlayDialogHolder { - return AlertDialog.Builder(context, initialEnvironment[AlertDialogThemeResId]) - .create().let { alertDialog -> - 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 handlers 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. - alertDialog.setButton(button.toId(), " ") { _, _ -> } - } + ): OverlayDialogHolder = + AlertDialog.Builder(context, initialEnvironment[AlertDialogThemeResId]) + .create() + .toDialogHolder(initialEnvironment) +} - OverlayDialogHolder( - initialEnvironment = initialEnvironment, - dialog = alertDialog, - onUpdateBounds = null - ) { rendering, _ -> - with(alertDialog) { - if (rendering.cancelable) { - setOnCancelListener { rendering.onEvent(Canceled) } - setCancelable(true) - } else { - setCancelable(false) - } +/** + * Wraps the receiver in in an [OverlayDialogHolder] that is able to update its + * buttons as new [AlertOverlay] renderings are received. + */ +@WorkflowUiExperimentalApi +public fun AlertDialog.toDialogHolder( + initialEnvironment: ViewEnvironment +): OverlayDialogHolder { + 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 handlers 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(), " ") { _, _ -> } + } + + return OverlayDialogHolder( + initialEnvironment = initialEnvironment, + dialog = this, + onUpdateBounds = null, + onBackPressed = null + ) { rendering, _ -> + with(this) { + if (rendering.cancelable) { + setOnCancelListener { rendering.onEvent(Canceled) } + setCancelable(true) + } else { + setCancelable(false) + } - setMessage(rendering.message) - setTitle(rendering.title) + setMessage(rendering.message) + setTitle(rendering.title) - // The buttons won't actually exist until the dialog is showing. - if (isShowing) updateButtonsOnShow(rendering) else setOnShowListener { - updateButtonsOnShow(rendering) - } - } - } + // The buttons won't actually exist until the dialog is showing. + if (isShowing) updateButtonsOnShow(rendering) else setOnShowListener { + updateButtonsOnShow(rendering) } + } } +} - private fun Button.toId(): Int = when (this) { - POSITIVE -> DialogInterface.BUTTON_POSITIVE - NEGATIVE -> DialogInterface.BUTTON_NEGATIVE - NEUTRAL -> DialogInterface.BUTTON_NEUTRAL - } +@WorkflowUiExperimentalApi +private fun Button.toId(): Int = when (this) { + POSITIVE -> DialogInterface.BUTTON_POSITIVE + NEGATIVE -> DialogInterface.BUTTON_NEGATIVE + NEUTRAL -> DialogInterface.BUTTON_NEUTRAL +} - private fun AlertDialog.updateButtonsOnShow(rendering: AlertOverlay) { - setOnShowListener(null) +@WorkflowUiExperimentalApi +private fun AlertDialog.updateButtonsOnShow(rendering: AlertOverlay) { + setOnShowListener(null) - for (button in Button.values()) getButton(button.toId()).visibility = GONE + 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 - } + 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 index 33e08e89bb..cbb7cd8dbd 100644 --- 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 @@ -43,7 +43,7 @@ internal fun D.maintainBounds( } @WorkflowUiExperimentalApi -internal fun D.maintainBounds( +private fun D.maintainBounds( bounds: StateFlow, onBoundsChange: (Rect) -> Unit ) { diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/DialogSession.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/DialogSession.kt index a928945184..a1c350f441 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/DialogSession.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/DialogSession.kt @@ -5,6 +5,9 @@ import android.os.Parcel import android.os.Parcelable import android.os.Parcelable.Creator 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.MotionEvent import android.view.Window import androidx.core.view.doOnAttach @@ -46,7 +49,7 @@ internal class DialogSession( * Wrap the given dialog holder to maintain [allowEvents] on each update. */ val holder: OverlayDialogHolder = OverlayDialogHolder( - holder.environment, holder.dialog + holder.environment, holder.dialog, holder.onUpdateBounds, holder.onBackPressed ) { overlay, environment -> allowEvents = !environment[CoveredByModal] holder.show(overlay, environment) @@ -54,6 +57,9 @@ internal class DialogSession( val savedStateRegistryKey = Compatible.keyFor(holder.showing, index.toString()) + private val KeyEvent.isBackPress: Boolean + get() = (keyCode == KEYCODE_BACK || keyCode == KEYCODE_ESCAPE) && action == ACTION_UP + fun showDialog( parentLifecycleOwner: LifecycleOwner, stateRegistryAggregator: WorkflowSavedStateRegistryAggregator @@ -68,7 +74,18 @@ internal class DialogSession( } override fun dispatchKeyEvent(event: KeyEvent): Boolean { - return !allowEvents || realWindowCallback.dispatchKeyEvent(event) + // Consume all events if we've been told to do so. + if (!allowEvents) return true + + // If there is an onBackPressed handler invoke it instead of allowing + // the normal machinery to call Dialog.onBackPressed. + if (event.isBackPress) holder.onBackPressed?.let { onBackPressed -> + onBackPressed.invoke() + return true + } + + // Allow the usual handling, including the usual call to Dialog.onBackPressed. + return realWindowCallback.dispatchKeyEvent(event) } } } diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/LayeredDialogSessions.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/LayeredDialogSessions.kt index 58ad444c9e..6ee35ac0f7 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/LayeredDialogSessions.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/LayeredDialogSessions.kt @@ -155,19 +155,13 @@ public class LayeredDialogSessions private constructor( // with the new Overlay at that index. Just update it. sessions[i].also { it.holder.show(overlay, dialogEnv) } } else { - // We need a new dialog for this overlay. Time to build it. - // We wrap our Dialog instances in DialogHolder to keep them - // paired with their current overlay rendering and environment. - // It would have been nice to keep those in tags on the Dialog's - // decor view, more consistent with what ScreenViewFactory does, - // but calling Window.getDecorView has side effects, and things - // break if we call it to early. Need to store them somewhere else. overlay.toDialogFactory(dialogEnv) .buildDialog(overlay, dialogEnv, context) .let { holder -> holder.onUpdateBounds?.let { updateBounds -> holder.dialog.maintainBounds(holder.environment) { b -> updateBounds(b) } } + DialogSession(i, holder).also { newSession -> // Prime the pump, make the first call to OverlayDialog.show to update // the new dialog to reflect the first rendering. diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ModalScreenOverlayBackButtonHelper.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ModalScreenOverlayBackButtonHelper.kt deleted file mode 100644 index b032ee687e..0000000000 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ModalScreenOverlayBackButtonHelper.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.squareup.workflow1.ui.container - -import android.view.View -import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.ViewEnvironmentKey -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.backPressedHandler -import com.squareup.workflow1.ui.onBackPressedDispatcherOwnerOrNull - -/** - * Functions called to handle back button events in [Dialog][android.app.Dialog]s built - * by [ScreenOverlayDialogFactory] for renderings of type [ModalOverlay]. - * The default implementation uses the [Activity][android.app.Activity]'s - * [OnBackPressedDispatcher][androidx.activity.OnBackPressedDispatcher], - * via [backPressedHandler]. - * - * This is a hook to allow apps that have back button handling schemes - * that predate `OnBackPressedDispatcher` to take advantage of [ScreenOverlayDialogFactory] - * without forking it. - */ -@WorkflowUiExperimentalApi -public interface ModalScreenOverlayBackButtonHelper { - /** - * Called immediately after [ScreenViewHolder.show][com.squareup.workflow1.ui.show] - * for any [Dialog][android.app.Dialog] built by a [ScreenOverlayDialogFactory] - * to show [ModalOverlay] renderings. - * - * The default implementation ensures that [back press handlers][View.backPressedHandler] - * set in lower windows are blocked. - */ - public fun onContentViewUpdate(contentView: View) { - // 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 (contentView.backPressedHandler == null) { - contentView.backPressedHandler = { } - } - } - - /** - * Called when the device back button is pressed and a modal dialog built by a - * [ScreenOverlayDialogFactory] has window focus. - * - * @return true if the back press event was consumed - */ - public fun onBackPressed(contentView: View): Boolean { - contentView.context.onBackPressedDispatcherOwnerOrNull() - ?.onBackPressedDispatcher - ?.let { - if (it.hasEnabledCallbacks()) it.onBackPressed() - } - return true - } - - public companion object : ViewEnvironmentKey( - type = ModalScreenOverlayBackButtonHelper::class - ) { - override val default: ModalScreenOverlayBackButtonHelper = - object : ModalScreenOverlayBackButtonHelper {} - } -} - -@WorkflowUiExperimentalApi -public operator fun ViewEnvironment.plus( - backButtonHelper: ModalScreenOverlayBackButtonHelper -): ViewEnvironment = this + (ModalScreenOverlayBackButtonHelper to backButtonHelper) diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/OverlayDialogFactoryFinder.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/OverlayDialogFactoryFinder.kt index a08696f6d4..ed6517688f 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/OverlayDialogFactoryFinder.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/OverlayDialogFactoryFinder.kt @@ -8,42 +8,8 @@ import com.squareup.workflow1.ui.WorkflowUiExperimentalApi /** * [ViewEnvironment] service object used by [Overlay.toDialogFactory] to find the right - * [OverlayDialogFactoryScreenViewFactory]. The default implementation makes [AndroidOverlay] + * [OverlayDialogFactory]. The default implementation makes [AndroidOverlay] * work, and provides default bindings for [AlertOverlay] and [FullScreenOverlay]. - * - * Here is how this hook could be used to provide a custom dialog to handle [FullScreenOverlay]: - * - * class MyDialogFactory : ModalScreenOverlayDialogFactory>( - * ModalScreenOverlay::class - * ) { - * override fun buildDialogWithContentView(contentView: View): Dialog { - * return super.buildDialogWithContentView(contentView).also { - * // Whatever, man, go wild. For that matter don't feel obligated to call super. - * } - * } - * } - * - * object MyFinder: OverlayDialogFactoryFinder { - * override fun getDialogFactoryForRendering( - * environment: ViewEnvironment, - * rendering: OverlayT - * ): OverlayDialogFactory { - * if (rendering is ModalScreenOverlay<*>) - * return MyDialogFactory as OverlayDialogFactory - * return super.getDialogFactoryForRendering(environment, rendering) - * } - * } - * - * class MyViewModel(savedState: SavedStateHandle) : ViewModel() { - * val renderings: StateFlow by lazy { - * val env = ViewEnvironment.EMPTY + (OverlayDialogFactoryFinder to MyFinder) - * renderWorkflowIn( - * workflow = MyRootWorkflow.mapRenderings { it.withEnvironment(env) }, - * scope = viewModelScope, - * savedStateHandle = savedState - * ) - * } - * } */ @WorkflowUiExperimentalApi public interface OverlayDialogFactoryFinder { diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/OverlayDialogHolder.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/OverlayDialogHolder.kt index 8edb6929a2..b5ba2fb978 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/OverlayDialogHolder.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/OverlayDialogHolder.kt @@ -9,6 +9,7 @@ import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.compatible import com.squareup.workflow1.ui.container.OverlayDialogHolder.Companion.InOverlay import com.squareup.workflow1.ui.container.OverlayDialogHolder.Companion.NoOverlay +import com.squareup.workflow1.ui.onBackPressedDispatcherOwnerOrNull import com.squareup.workflow1.ui.show /** @@ -27,6 +28,9 @@ public interface OverlayDialogHolder { /** * The function that is run by [show] to update [dialog] with a new [Screen] rendering and * [ViewEnvironment]. + * + * Prefer calling [show] to using this directly, to ensure that [InOverlay] is + * maintained correctly, and [showing] keeps working. */ public val runner: (rendering: OverlayT, environment: ViewEnvironment) -> Unit @@ -44,6 +48,17 @@ public interface OverlayDialogHolder { */ public val onUpdateBounds: ((Rect) -> Unit)? + /** + * Optional function to be called when the [dialog] window receives a back button event, + * instead of [Dialog.onBackPressed]. + * + * The default implementation provided by the factory function below looks for the + * [OnBackPressedDispatcherOwner][com.squareup.workflow1.ui.onBackPressedDispatcherOwnerOrNull] + * and invokes its [onBackPressed][androidx.activity.OnBackPressedDispatcher.onBackPressed] + * method. + */ + public val onBackPressed: (() -> Unit)? + public companion object { /** * Default value returned for the [InOverlay] [ViewEnvironmentKey], and therefore the @@ -93,6 +108,9 @@ public fun OverlayDialogHolder.show( /** * Returns the [Overlay] most recently used to update the receiver's * [dialog][OverlayDialogHolder.dialog] via a call to [show]. + * + * Note that the exact type of the returned [Overlay] is likely not to match that of + * the receiver's `OverlayT` type parameter, e.g. if a wrapping dialog factory is in use. */ @WorkflowUiExperimentalApi public val OverlayDialogHolder<*>.showing: Overlay @@ -103,7 +121,14 @@ public fun OverlayDialogHolder( initialEnvironment: ViewEnvironment, dialog: Dialog, onUpdateBounds: ((Rect) -> Unit)? = { dialog.setBounds(it) }, + onBackPressed: (() -> Unit)? = { + dialog.context.onBackPressedDispatcherOwnerOrNull() + ?.onBackPressedDispatcher + ?.let { + if (it.hasEnabledCallbacks()) it.onBackPressed() + } + }, runner: (rendering: OverlayT, environment: ViewEnvironment) -> Unit ): OverlayDialogHolder { - return RealOverlayDialogHolder(initialEnvironment, dialog, onUpdateBounds, runner) + return RealOverlayDialogHolder(initialEnvironment, dialog, onUpdateBounds, onBackPressed, runner) } diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/RealOverlayDialogHolder.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/RealOverlayDialogHolder.kt index eff45a4eab..2e8b941a38 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/RealOverlayDialogHolder.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/RealOverlayDialogHolder.kt @@ -10,6 +10,7 @@ internal class RealOverlayDialogHolder( initialEnvironment: ViewEnvironment, override val dialog: Dialog, override val onUpdateBounds: ((Rect) -> Unit)?, + override val onBackPressed: (() -> Unit)?, runnerFunction: (rendering: OverlayT, environment: ViewEnvironment) -> Unit ) : OverlayDialogHolder { 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 d0bd7b5a15..fde26d881a 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 @@ -4,27 +4,22 @@ import android.app.Dialog import android.content.Context import android.graphics.drawable.ColorDrawable import android.util.TypedValue -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.Window import android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.ScreenViewFactory 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.show import com.squareup.workflow1.ui.startShowing -import com.squareup.workflow1.ui.toUnwrappingViewFactory import com.squareup.workflow1.ui.toViewFactory import kotlin.reflect.KClass /** * Extensible base implementation of [OverlayDialogFactory] for [ScreenOverlay] * types. Also serves as the default factory for [FullScreenOverlay]. - * (See [OverlayDialogFactoryFinder] for guidance on customizing the presentation + * (Use a custom [OverlayDialogFactoryFinder] to customize the presentation * of [FullScreenOverlay].) * * Dialogs built by this class are compatible with @@ -81,9 +76,10 @@ import kotlin.reflect.KClass * views and windows should not fire. [ScreenOverlayDialogFactory] takes care * of that requirement by default, presuming that your app uses Jetpack * [OnBackPressedDispatcher][androidx.activity.OnBackPressedDispatcher]. If that is not - * the case, alternative handling can be provided via [ModalScreenOverlayBackButtonHelper]. + * the case, override [buildDialogWithContent] and provide an alternative `onBackPressed` + * implementation when you call [OverlayDialogHolder]. * - * It is important to note that the modal behavior described here is all is keyed to the + * It is important to note that the modal behavior described here is all keyed to the * [ModalOverlay] interface, not its parent type [Overlay]. Rendering types that declare the * latter but not the former can be used to create dialogs for non-modal windows like toasts * and tool tips. @@ -92,23 +88,9 @@ import kotlin.reflect.KClass public open class ScreenOverlayDialogFactory>( override val type: KClass ) : OverlayDialogFactory { - /** - * Use [viewFactory] to build the [content view][Dialog.setContentView] for the new - * [Dialog]. Open to allow custom processing, subclasses need not call `super`. - */ - public open fun buildContent( - viewFactory: ScreenViewFactory, - initialContent: S, - initialEnvironment: ViewEnvironment, - context: Context - ): ScreenViewHolder { - return viewFactory.startShowing(initialContent, initialEnvironment, context) - } - - /** - * Build the [Dialog] for the [content] that was just created by [buildContent]. - * Open to allow customization, typically theming, subclasses need not call `super`. + * Build the [Dialog], using [content] as its [contentView][Dialog.setContentView]. + * Open to allow customization, typically theming. Subclasses need not call `super`. * - Note that the default implementation calls the provided [Dialog.setContent] * extension for typical setup. * - Be sure to call [ScreenViewHolder.show] from [OverlayDialogHolder.runner]. @@ -118,31 +100,31 @@ public open class ScreenOverlayDialogFactory>( initialEnvironment: ViewEnvironment, content: ScreenViewHolder ): OverlayDialogHolder { - return OverlayDialogHolder( - initialEnvironment, Dialog(content.view.context).apply { setContent(content) } - ) { overlayRendering, environment -> + val dialog = Dialog(content.view.context).apply { setContent(content) } + 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 + // 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 = {} content.show(overlayRendering.content, environment) } } /** - * Locked down implementation enforces [ModalOverlay] and supports - * [ModalScreenOverlayBackButtonHelper]. Delegates to [buildContent] to create the content view - * and [buildDialogWithContent] to create the [Dialog]. + * Creates the [ScreenViewHolder] for [initialRendering.content][ScreenOverlay.content] + * and then calls [buildDialogWithContent] to create [Dialog] in an [OverlayDialogHolder]. */ final override fun buildDialog( initialRendering: O, initialEnvironment: ViewEnvironment, context: Context ): OverlayDialogHolder { - val modal = initialRendering is ModalOverlay - - val rawContentViewFactory = initialRendering.content.toViewFactory(initialEnvironment) - val contentViewFactory = - if (!modal) rawContentViewFactory else rawContentViewFactory.callBackButtonHelperOnUpdate() - - val contentViewHolder = - buildContent(contentViewFactory, initialRendering.content, initialEnvironment, context) + val contentViewHolder = initialRendering.content.toViewFactory(initialEnvironment) + .startShowing(initialRendering.content, initialEnvironment, context) return buildDialogWithContent( initialRendering, @@ -150,52 +132,12 @@ public open class ScreenOverlayDialogFactory>( contentViewHolder ).also { holder -> val window = requireNotNull(holder.dialog.window) { "Dialog must be attached to a window." } - - if (modal) { - val realWindowCallback = window.callback - window.callback = object : Window.Callback by realWindowCallback { - override fun dispatchKeyEvent(event: KeyEvent): Boolean { - val isBackPress = with(event) { - (keyCode == KEYCODE_BACK || keyCode == KEYCODE_ESCAPE) && action == ACTION_UP - } - - return when { - isBackPress -> contentViewHolder.environment[ModalScreenOverlayBackButtonHelper] - .onBackPressed(contentViewHolder.view) - else -> realWindowCallback.dispatchKeyEvent(event) - } - } - } - } - // Note that we always tell Android to make the window non-modal, regardless of our own // notion of its modality. Even a modal dialog should only block events within // the appropriate bounds, but Android makes them block everywhere. window.setFlags(FLAG_NOT_TOUCH_MODAL, FLAG_NOT_TOUCH_MODAL) } } - - /** - * Wraps the receiving [ScreenViewFactory] to ensure [ModalScreenOverlayBackButtonHelper] - * gets to post-process the content view on each update, to ensure that the back button - * handling call kicked off above from `dispatchKeyEvent` can be short circuited if it - * is not consumed by any view in the modal. - */ - private fun ScreenViewFactory.callBackButtonHelperOnUpdate() = - toUnwrappingViewFactory( - unwrap = { - // Nasty cast here and below required b/c we can't compile - // toUnwrappingViewFactory, and are forced to pretend we don't know - // that `it` really is `S`. - @Suppress("UNCHECKED_CAST") - it as S - }, - showWrapperScreen = { view, screen, environment, showUnwrappedScreen -> - @Suppress("UNCHECKED_CAST") - showUnwrappedScreen(screen as S, environment) - environment[ModalScreenOverlayBackButtonHelper].onContentViewUpdate(view) - } - ) } /**