Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Simpler, better back button handling. #843

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
36 changes: 10 additions & 26 deletions workflow-ui/core-android/api/core-android.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> ()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
}

Expand Down Expand Up @@ -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 <init> (Lkotlinx/coroutines/flow/StateFlow;)V
Expand Down Expand Up @@ -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;
}
Expand All @@ -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 <init> (Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/app/Dialog;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V
public fun <init> (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 <init> (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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -55,24 +54,17 @@ internal class DialogIntegrationTest {
object : ScreenOverlayDialogFactory<ContentRendering, DialogRendering>(
type = DialogRendering::class
) {
override fun buildContent(
viewFactory: ScreenViewFactory<ContentRendering>,
initialContent: ContentRendering,
initialEnvironment: ViewEnvironment,
context: Context
): ScreenViewHolder<ContentRendering> =
super.buildContent(viewFactory, initialContent, initialEnvironment, context).also {
latestContentView = it.view
}

override fun buildDialogWithContent(
initialRendering: DialogRendering,
initialEnvironment: ViewEnvironment,
content: ScreenViewHolder<ContentRendering>
): OverlayDialogHolder<DialogRendering> =
super.buildDialogWithContent(initialRendering, initialEnvironment, content).also {
): OverlayDialogHolder<DialogRendering> {
latestContentView = content.view

return super.buildDialogWithContent(initialRendering, initialEnvironment, content).also {
latestDialog = it.dialog
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ public interface ScreenViewHolder<in ScreenT : Screen> {
/**
* 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<ScreenT>

Expand Down Expand Up @@ -103,6 +106,10 @@ public fun <ScreenT : Screen> ScreenViewHolder<ScreenT>.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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AlertOverlay> {
internal class AlertOverlayDialogFactory : OverlayDialogFactory<AlertOverlay> {
override val type: KClass<AlertOverlay> = AlertOverlay::class

override fun buildDialog(
initialRendering: AlertOverlay,
initialEnvironment: ViewEnvironment,
context: Context
): OverlayDialogHolder<AlertOverlay> {
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<AlertOverlay> =
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<AlertOverlay> {
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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ internal fun <D : Dialog> D.maintainBounds(
}

@WorkflowUiExperimentalApi
internal fun <D : Dialog> D.maintainBounds(
private fun <D : Dialog> D.maintainBounds(
bounds: StateFlow<Rect>,
onBoundsChange: (Rect) -> Unit
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -46,14 +49,17 @@ internal class DialogSession(
* Wrap the given dialog holder to maintain [allowEvents] on each update.
*/
val holder: OverlayDialogHolder<Overlay> = OverlayDialogHolder(
holder.environment, holder.dialog
holder.environment, holder.dialog, holder.onUpdateBounds, holder.onBackPressed
rjrjr marked this conversation as resolved.
Show resolved Hide resolved
) { overlay, environment ->
allowEvents = !environment[CoveredByModal]
holder.show(overlay, environment)
}

val savedStateRegistryKey = Compatible.keyFor(holder.showing, index.toString())

private val KeyEvent.isBackPress: Boolean
get() = (keyCode == KEYCODE_BACK || keyCode == KEYCODE_ESCAPE) && action == ACTION_UP
rjrjr marked this conversation as resolved.
Show resolved Hide resolved

fun showDialog(
parentLifecycleOwner: LifecycleOwner,
stateRegistryAggregator: WorkflowSavedStateRegistryAggregator
Expand All @@ -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)
}
}
}
Expand Down
Loading