diff --git a/workflow-ui/core-android/api/core-android.api b/workflow-ui/core-android/api/core-android.api index 86d92d9889..774b116628 100644 --- a/workflow-ui/core-android/api/core-android.api +++ b/workflow-ui/core-android/api/core-android.api @@ -429,13 +429,17 @@ 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 setBounds (Landroid/app/Dialog;Landroid/graphics/Rect;)V 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..58aac0a863 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,99 @@ 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) +} + +/** + * 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(), " ") { _, _ -> } + } - OverlayDialogHolder( - initialEnvironment = initialEnvironment, - dialog = alertDialog, - onUpdateBounds = null - ) { rendering, _ -> - with(alertDialog) { - if (rendering.cancelable) { - setOnCancelListener { rendering.onEvent(Canceled) } - setCancelable(true) - } else { - setCancelable(false) - } + return OverlayDialogHolder( + initialEnvironment = initialEnvironment, + dialog = this, + onUpdateBounds = null, + onBackPressed = { false } + ) { 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/DialogSession.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/DialogSession.kt index 954af11466..c2943873ba 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 @@ -74,8 +74,19 @@ internal class DialogSession( } override fun dispatchKeyEvent(event: KeyEvent): Boolean { - return !allowEvents || (event.isBackPress && holder.onBackPressed?.invoke() == true) || - 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. Note that we ignore + // the return value here. It's used by LayeredDialogSession for non-modals. + if (event.isBackPress) { + holder.onBackPressed?.invoke() + return true + } + + // Allow 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 520503d158..3c0e5ff6e6 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 @@ -167,6 +167,10 @@ public class LayeredDialogSessions private constructor( // 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 assume this is okay for apps that don't use OnBackPressedDispatcher + // because, well, they don't use OnBackPressedDispatcher. Remember that the + // handler put in place by `backPressedHandler` will be cleaned up when + // the decorView is disposed. object : OverlayDialogHolder by realHolder { override val runner: (rendering: Overlay, environment: ViewEnvironment) -> Unit get() { @@ -179,14 +183,29 @@ public class LayeredDialogSessions private constructor( } else { // For a non-modal, we tweak onBackPressed to hand off to the next one down. object : OverlayDialogHolder by realHolder { - override val onBackPressed: () -> Boolean + override val onBackPressed: (() -> Boolean)? get() { - val previous = - ((sessions.indexOfFirst { it.holder == this }) - 1).takeIf { it >= 0 } - ?.let { sessions[it].holder } + // No custom handler, allow Dialog.onCallBack to fire as usual. + // (See dispatchKeyEvent in DialogSession.) + val realOnBackPressed = realHolder.onBackPressed ?: return null + return { - (realHolder.onBackPressed?.invoke() == true) || - (previous?.onBackPressed?.invoke() == true) + if (!realOnBackPressed.invoke()) { + // Our handler did not consume the event. Hand it off to + // our predecessor, if there is one. + val previous = + ((sessions.indexOfFirst { it.holder == this }) - 1).takeIf { it >= 0 } + ?.let { sessions[it].holder } + + // Invoke the predecessor's handler if it has one, relying on it to + // hand off its own predecessor if needed. If the predecessor has no + // custom handler, call its Dialog.onBackPressed instead. + previous?.onBackPressed?.invoke() ?: previous?.dialog?.onBackPressed() + } + + // We always return true. Either realOnBackPressed() consumed the event, + // or else previous did, or its previous did, etc. + true } } } 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..daa3fdc7b0 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,9 +8,11 @@ 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]. * + * TODO Stale and wrong to start with + * * Here is how this hook could be used to provide a custom dialog to handle [FullScreenOverlay]: * * class MyDialogFactory : ModalScreenOverlayDialogFactory>( 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 60e4ca53bc..ec5045859d 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 @@ -49,18 +49,29 @@ public interface OverlayDialogHolder { public val onUpdateBounds: ((Rect) -> Unit)? /** - * Optional function to be called when the back button is pressed, instead of - * [Dialog.onBackPressed]. + * Optional function to be called when the [dialog] window receives a back button event. + * This hook is exposed mainly for apps that are unable to use + * [OnBackPressedDispatcherOwner][androidx.activity.OnBackPressedDispatcher] + * and [View.backPressedHandler][com.squareup.workflow1.ui.backPressedHandler]. * - * Default implementation provided by the factory function below looks for the + * If [onBackPressed] is `null`, [Dialog.onBackPressed] will be called as usual. + * Otherwise, [Dialog.onBackPressed] will not be called, and interpretation of the + * return value depends on whether this holder is driven by a [ModalOverlay] or a + * non-modal [Overlay]. + * + * - For a [ModalOverlay], the return value is ignored, and the back pressed event + * is always considered to be consumed. + * + * - For a non-modal [Overlay], [OverlayDialogHolder.onBackPressed] for the next [Dialog] + * down is called, if there is one. + * + * 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, if appropriate. + * method. */ public val onBackPressed: (() -> Boolean)? - // public val onBackPressed: - public companion object { /** * Default value returned for the [InOverlay] [ViewEnvironmentKey], and therefore the @@ -123,7 +134,7 @@ public fun OverlayDialogHolder( initialEnvironment: ViewEnvironment, dialog: Dialog, onUpdateBounds: ((Rect) -> Unit)? = { dialog.setBounds(it) }, - onBackPressed: () -> Boolean = { + onBackPressed: (() -> Boolean)? = { dialog.context.onBackPressedDispatcherOwnerOrNull() ?.onBackPressedDispatcher ?.let { 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 f277e748a9..a33d92f340 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,7 +10,7 @@ internal class RealOverlayDialogHolder( initialEnvironment: ViewEnvironment, override val dialog: Dialog, override val onUpdateBounds: ((Rect) -> Unit)?, - override val onBackPressed: () -> Boolean, + override val onBackPressed: (() -> Boolean)?, runnerFunction: (rendering: OverlayT, environment: ViewEnvironment) -> Unit ) : OverlayDialogHolder {