diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index d36c6ba212..bd992f0337 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -15,6 +15,7 @@ object Dependencies { const val activity = "androidx.activity:activity:1.3.0" const val activityKtx = "androidx.activity:activity-ktx:1.3.0" const val appcompat = "androidx.appcompat:appcompat:1.3.1" + const val coreKtx = "androidx.core:core-ktx:1.6.0" object Compose { const val activity = "androidx.activity:activity-compose:1.3.1" 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 index 9743298435..d99d77d3e5 100644 --- 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 @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package com.squareup.sample.container.panel import com.squareup.workflow1.ui.Screen diff --git a/samples/containers/hello-back-button/src/main/java/com/squareup/sample/hellobackbutton/AreYouSureWorkflow.kt b/samples/containers/hello-back-button/src/main/java/com/squareup/sample/hellobackbutton/AreYouSureWorkflow.kt index 2dcb3d1ab2..51ccdeadb7 100644 --- a/samples/containers/hello-back-button/src/main/java/com/squareup/sample/hellobackbutton/AreYouSureWorkflow.kt +++ b/samples/containers/hello-back-button/src/main/java/com/squareup/sample/hellobackbutton/AreYouSureWorkflow.kt @@ -11,22 +11,23 @@ import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.WorkflowAction.Companion.noAction import com.squareup.workflow1.action 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.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.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.BodyAndModalsScreen import com.squareup.workflow1.ui.toParcelable import com.squareup.workflow1.ui.toSnapshot import kotlinx.parcelize.Parcelize /** - * Wraps [HelloBackButtonWorkflow] to (sometimes) pop a confirmation dialog when the back + * Wraps [HelloBackButtonWorkflow] to (sometimes) pop a confirmation alert when the back * button is pressed. */ @OptIn(WorkflowUiExperimentalApi::class) -object AreYouSureWorkflow : StatefulWorkflow>() { +object AreYouSureWorkflow : + StatefulWorkflow>() { override fun initialState( props: Unit, snapshot: Snapshot? @@ -44,42 +45,42 @@ object AreYouSureWorkflow : StatefulWorkflow { + ): BodyAndModalsScreen<*, AlertOverlay> { val ableBakerCharlie = context.renderChild(HelloBackButtonWorkflow, Unit) { noAction() } return when (renderState) { Running -> { - AlertContainerScreen( - BackButtonScreen(ableBakerCharlie) { - // While we always provide a back button handler, by default the view code - // associated with BackButtonScreen ignores ours if the view created for the - // wrapped rendering sets a handler of its own. (Set BackButtonScreen.override - // to change this precedence.) - context.actionSink.send(maybeQuit) - } + BodyAndModalsScreen( + BackButtonScreen(ableBakerCharlie) { + // While we always provide a back button handler, by default the view code + // associated with BackButtonScreen ignores ours if the view created for the + // wrapped rendering sets a handler of its own. (Set BackButtonScreen.override + // to change this precedence.) + context.actionSink.send(maybeQuit) + } ) } Quitting -> { - val dialog = AlertScreen( - buttons = mapOf( - POSITIVE to "I'm Positive", - NEGATIVE to "Negatory" - ), - message = "Are you sure you want to do this thing?", - onEvent = { alertEvent -> - context.actionSink.send( - when (alertEvent) { - is ButtonClicked -> when (alertEvent.button) { - POSITIVE -> confirmQuit - else -> cancelQuit - } - Canceled -> cancelQuit - } - ) - } + val alert = AlertOverlay( + buttons = mapOf( + POSITIVE to "I'm Positive", + NEGATIVE to "Negatory" + ), + message = "Are you sure you want to do this thing?", + onEvent = { alertEvent -> + context.actionSink.send( + when (alertEvent) { + is ButtonClicked -> when (alertEvent.button) { + POSITIVE -> confirmQuit + else -> cancelQuit + } + Canceled -> cancelQuit + } + ) + } ) - AlertContainerScreen(ableBakerCharlie, dialog) + BodyAndModalsScreen(ableBakerCharlie, alert) } } } diff --git a/samples/containers/hello-back-button/src/main/java/com/squareup/sample/hellobackbutton/HelloBackButtonActivity.kt b/samples/containers/hello-back-button/src/main/java/com/squareup/sample/hellobackbutton/HelloBackButtonActivity.kt index a324a88f1a..fd9538a21f 100644 --- a/samples/containers/hello-back-button/src/main/java/com/squareup/sample/hellobackbutton/HelloBackButtonActivity.kt +++ b/samples/containers/hello-back-button/src/main/java/com/squareup/sample/hellobackbutton/HelloBackButtonActivity.kt @@ -1,3 +1,4 @@ +@file:Suppress("DEPRECATION") @file:OptIn(WorkflowUiExperimentalApi::class) package com.squareup.sample.hellobackbutton diff --git a/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/Component.kt b/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/Component.kt index 415c1cd5b4..8c8dcb0a38 100644 --- a/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/Component.kt +++ b/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/Component.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package com.squareup.sample.dungeon import android.content.Context.VIBRATOR_SERVICE diff --git a/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/DungeonAppWorkflow.kt b/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/DungeonAppWorkflow.kt index 5aa3973fcd..4093b0d0d8 100644 --- a/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/DungeonAppWorkflow.kt +++ b/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/DungeonAppWorkflow.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package com.squareup.sample.dungeon import com.squareup.sample.dungeon.DungeonAppWorkflow.Props diff --git a/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/GameSessionWorkflow.kt b/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/GameSessionWorkflow.kt index f655f455c7..36871fb808 100644 --- a/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/GameSessionWorkflow.kt +++ b/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/GameSessionWorkflow.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package com.squareup.sample.dungeon import android.os.Vibrator diff --git a/samples/tictactoe/app/src/main/java/com/squareup/sample/mainactivity/TicTacToeActivity.kt b/samples/tictactoe/app/src/main/java/com/squareup/sample/mainactivity/TicTacToeActivity.kt index a31890d891..aa58eb3d8d 100644 --- a/samples/tictactoe/app/src/main/java/com/squareup/sample/mainactivity/TicTacToeActivity.kt +++ b/samples/tictactoe/app/src/main/java/com/squareup/sample/mainactivity/TicTacToeActivity.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package com.squareup.sample.mainactivity import android.os.Bundle diff --git a/workflow-ui/container-android/src/main/java/com/squareup/workflow1/ui/modal/AlertContainer.kt b/workflow-ui/container-android/src/main/java/com/squareup/workflow1/ui/modal/AlertContainer.kt index e415ba0050..c534825294 100644 --- a/workflow-ui/container-android/src/main/java/com/squareup/workflow1/ui/modal/AlertContainer.kt +++ b/workflow-ui/container-android/src/main/java/com/squareup/workflow1/ui/modal/AlertContainer.kt @@ -27,6 +27,7 @@ import com.squareup.workflow1.ui.modal.AlertScreen.Event.Canceled * Renders the [AlertScreen]s of an [AlertContainerScreen] as [AlertDialog]s. */ @WorkflowUiExperimentalApi +@Deprecated("Use BodyAndModalsContainer and the built in OverlayDialogFactory for AlertOverlay") public class AlertContainer @JvmOverloads constructor( context: Context, attributeSet: AttributeSet? = null, 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 a315725e72..9bb2008e4c 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 @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package com.squareup.workflow1.ui.modal import com.squareup.workflow1.ui.Screen @@ -9,6 +11,16 @@ 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" +// ) +// ) 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 f75ccb4469..53893e04e5 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 @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package com.squareup.workflow1.ui.modal import com.squareup.workflow1.ui.WorkflowUiExperimentalApi @@ -6,6 +8,16 @@ 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" +// ) +// ) public data class AlertScreen( val buttons: Map = emptyMap(), val message: String = "", @@ -32,9 +44,9 @@ public data class AlertScreen( other as AlertScreen return buttons == other.buttons && - message == other.message && - title == other.title && - cancelable == other.cancelable + message == other.message && + title == other.title && + cancelable == other.cancelable } override fun hashCode(): Int { 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 b7505d6c33..b44a43b9e2 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,6 +10,10 @@ 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") 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 f6e2eff984..27f46856d3 100644 --- a/workflow-ui/core-android/api/core-android.api +++ b/workflow-ui/core-android/api/core-android.api @@ -11,16 +11,6 @@ public abstract interface class com/squareup/workflow1/ui/AndroidScreen : com/sq public abstract fun getViewFactory ()Lcom/squareup/workflow1/ui/ScreenViewFactory; } -public final class com/squareup/workflow1/ui/AndroidScreenKt { - public static final fun buildView (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/content/Context;Landroid/view/ViewGroup;Lkotlin/jvm/functions/Function1;)Landroid/view/View; - public static synthetic fun buildView$default (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/content/Context;Landroid/view/ViewGroup;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Landroid/view/View; -} - -public final class com/squareup/workflow1/ui/AndroidViewEnvironmentKt { - public static final fun getViewFactoryForRendering (Lcom/squareup/workflow1/ui/ViewEnvironment;Lcom/squareup/workflow1/ui/Screen;)Lcom/squareup/workflow1/ui/ScreenViewFactory; - public static final fun showFirstRendering (Landroid/view/View;)V -} - public final class com/squareup/workflow1/ui/AndroidViewRegistryKt { public static final fun buildView (Lcom/squareup/workflow1/ui/ViewRegistry;Ljava/lang/Object;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/content/Context;Landroid/view/ViewGroup;Lkotlin/jvm/functions/Function1;)Landroid/view/View; public static synthetic fun buildView$default (Lcom/squareup/workflow1/ui/ViewRegistry;Ljava/lang/Object;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/content/Context;Landroid/view/ViewGroup;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Landroid/view/View; @@ -101,6 +91,12 @@ public final class com/squareup/workflow1/ui/ScreenViewFactory$DefaultImpls { public static synthetic fun buildView$default (Lcom/squareup/workflow1/ui/ScreenViewFactory;Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/content/Context;Landroid/view/ViewGroup;ILjava/lang/Object;)Landroid/view/View; } +public final class com/squareup/workflow1/ui/ScreenViewFactoryKt { + public static final fun buildView (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/content/Context;Landroid/view/ViewGroup;Lkotlin/jvm/functions/Function1;)Landroid/view/View; + public static synthetic fun buildView$default (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/content/Context;Landroid/view/ViewGroup;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Landroid/view/View; + public static final fun showFirstRendering (Landroid/view/View;)V +} + public abstract interface class com/squareup/workflow1/ui/ScreenViewRunner { public static final field Companion Lcom/squareup/workflow1/ui/ScreenViewRunner$Companion; public abstract fun showRendering (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;)V @@ -208,6 +204,16 @@ public final class com/squareup/workflow1/ui/androidx/WorkflowLifecycleOwner$Com public static synthetic fun installOn$default (Lcom/squareup/workflow1/ui/androidx/WorkflowLifecycleOwner$Companion;Landroid/view/View;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V } +public final class com/squareup/workflow1/ui/container/AlertDialogThemeResId : com/squareup/workflow1/ui/ViewEnvironmentKey { + public static final field INSTANCE Lcom/squareup/workflow1/ui/container/AlertDialogThemeResId; + public fun getDefault ()Ljava/lang/Integer; + public synthetic fun getDefault ()Ljava/lang/Object; +} + +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; +} + public final class com/squareup/workflow1/ui/container/BackStackConfig : java/lang/Enum { public static final field Companion Lcom/squareup/workflow1/ui/container/BackStackConfig$Companion; public static final field First Lcom/squareup/workflow1/ui/container/BackStackConfig; @@ -240,6 +246,39 @@ public final class com/squareup/workflow1/ui/container/BackStackStateKeyKt { public static final fun withBackStackStateKeyPrefix (Lcom/squareup/workflow1/ui/ViewEnvironment;Ljava/lang/String;)Lcom/squareup/workflow1/ui/ViewEnvironment; } +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 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 class com/squareup/workflow1/ui/container/LayeredDialogs$SavedState : android/os/Parcelable { + public static final field CREATOR Lcom/squareup/workflow1/ui/container/LayeredDialogs$SavedState$CREATOR; + public fun (Landroid/os/Parcel;)V + public fun describeContents ()I + public fun writeToParcel (Landroid/os/Parcel;I)V +} + +public final class com/squareup/workflow1/ui/container/LayeredDialogs$SavedState$CREATOR : android/os/Parcelable$Creator { + public fun createFromParcel (Landroid/os/Parcel;)Lcom/squareup/workflow1/ui/container/LayeredDialogs$SavedState; + public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object; + public fun newArray (I)[Lcom/squareup/workflow1/ui/container/LayeredDialogs$SavedState; + public synthetic fun newArray (I)[Ljava/lang/Object; +} + +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 +} + +public final class com/squareup/workflow1/ui/container/OverlayDialogFactoryKt { + public static final fun toDialogFactory (Lcom/squareup/workflow1/ui/container/Overlay;Lcom/squareup/workflow1/ui/ViewEnvironment;)Lcom/squareup/workflow1/ui/container/OverlayDialogFactory; +} + public final class com/squareup/workflow1/ui/container/ViewStateCache : android/os/Parcelable { public static final field CREATOR Lcom/squareup/workflow1/ui/container/ViewStateCache$CREATOR; public fun ()V diff --git a/workflow-ui/core-android/build.gradle.kts b/workflow-ui/core-android/build.gradle.kts index 63fed50870..2d43596332 100644 --- a/workflow-ui/core-android/build.gradle.kts +++ b/workflow-ui/core-android/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { api(Dependencies.Kotlin.Stdlib.jdk6) implementation(Dependencies.AndroidX.activity) + implementation(Dependencies.AndroidX.coreKtx) implementation(Dependencies.AndroidX.fragment) implementation(Dependencies.AndroidX.Lifecycle.ktx) implementation(Dependencies.AndroidX.savedstate) diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidScreen.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidScreen.kt index 13a65d05ba..2d99280ee4 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidScreen.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidScreen.kt @@ -1,9 +1,5 @@ package com.squareup.workflow1.ui -import android.content.Context -import android.view.View -import android.view.ViewGroup - /** * Interface implemented by a rendering class to allow it to drive an Android UI * via an appropriate [ScreenViewFactory] implementation. @@ -31,65 +27,14 @@ import android.view.ViewGroup * reason, you can use [ViewRegistry] to bind your renderings to [ScreenViewFactory] * implementations at runtime. Also note that a [ViewRegistry] entry will override * the [viewFactory] returned by an [AndroidScreen]. + * + * @see com.squareup.workflow1.ui.container.AndroidOverlay */ @WorkflowUiExperimentalApi -public interface AndroidScreen> : Screen { +public interface AndroidScreen> : Screen { /** * Used to build instances of [android.view.View] as needed to * display renderings of this type. */ - public val viewFactory: ScreenViewFactory -} - -/** - * It is usually more convenient to use [WorkflowViewStub] or [DecorativeScreenViewFactory] - * than to call this method directly. - * - * Finds a [ScreenViewFactory] to create a [View] to display [this@buildView]. The new view - * can be updated via calls to [View.showRendering] -- that is, it is guaranteed that - * [bindShowRendering] has been called on this view. - * - * The returned view will have a - * [WorkflowLifecycleOwner][com.squareup.workflow1.ui.androidx.WorkflowLifecycleOwner] - * set on it. The returned view must EITHER: - * - * 1. Be attached at least once to ensure that the lifecycle eventually gets destroyed (because its - * parent is destroyed), or - * 2. Have its - * [WorkflowLifecycleOwner.destroyOnDetach][com.squareup.workflow1.ui.androidx.WorkflowLifecycleOwner.destroyOnDetach] - * called, which will either schedule the - * lifecycle to be destroyed if the view is attached, or destroy it immediately if it's detached. - * - * [WorkflowViewStub] takes care of this chore itself. - * - * @param initializeView Optional function invoked immediately after the [View] is - * created (that is, immediately after the call to [ScreenViewFactory.buildView]). - * [showRendering], [getRendering] and [environment] are all available when this is called. - * Defaults to a call to [View.showFirstRendering]. - * - * @throws IllegalArgumentException if no builder can be find for type [ScreenT] - * - * @throws IllegalStateException if the matching [ScreenViewFactory] fails to call - * [View.bindShowRendering] when constructing the view - */ -@WorkflowUiExperimentalApi -public fun ScreenT.buildView( - viewEnvironment: ViewEnvironment, - contextForNewView: Context, - container: ViewGroup? = null, - initializeView: View.() -> Unit = { showFirstRendering() } -): View { - val entry = viewEnvironment.getViewFactoryForRendering(this) - val viewFactory = (entry as? ScreenViewFactory) - ?: error("Require a ScreenViewFactory for $this, found $entry") - - return viewFactory.buildView( - this, viewEnvironment, contextForNewView, container - ).also { view -> - checkNotNull(view.showRenderingTag) { - "View.bindShowRendering should have been called for $view, typically by the " + - "${ScreenViewFactory::class.java.name} that created it." - } - initializeView.invoke(view) - } + public val viewFactory: ScreenViewFactory } diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidViewEnvironment.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidViewEnvironment.kt deleted file mode 100644 index f8712bda1b..0000000000 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidViewEnvironment.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.squareup.workflow1.ui - -import android.view.View -import com.squareup.workflow1.ui.container.BackStackScreen -import com.squareup.workflow1.ui.container.BackStackScreenViewFactory -import com.squareup.workflow1.ui.container.EnvironmentScreenViewFactory -import com.squareup.workflow1.ui.container.EnvironmentScreen - -/** - * It is usually more convenient to use [WorkflowViewStub] or [DecorativeScreenViewFactory] - * than to call this method directly. - * - * Returns the [ScreenViewFactory] that builds [View] instances suitable to display the given [rendering], - * via subsequent calls to [View.showRendering]. - * - * Prefers factories found via [ViewRegistry.getEntryFor]. If that returns null, falls - * back to the builder provided by the rendering's implementation of - * [AndroidScreen.viewFactory], if there is one. Note that this means that a - * compile time [AndroidScreen.viewFactory] binding can be overridden at runtime. - * - * The returned view will have a - * [WorkflowLifecycleOwner][com.squareup.workflow1.ui.androidx.WorkflowLifecycleOwner] - * set on it. The returned view must EITHER: - * - * 1. Be attached at least once to ensure that the lifecycle eventually gets destroyed (because its - * parent is destroyed), or - * 2. Have its - * [WorkflowLifecycleOwner.destroyOnDetach][com.squareup.workflow1.ui.androidx.WorkflowLifecycleOwner.destroyOnDetach] - * called, which will either schedule the - * lifecycle to be destroyed if the view is attached, or destroy it immediately if it's detached. - * - * @throws IllegalArgumentException if no builder can be find for type [ScreenT] - */ -@WorkflowUiExperimentalApi -public fun - ViewEnvironment.getViewFactoryForRendering(rendering: ScreenT): ScreenViewFactory { - @Suppress("UNCHECKED_CAST", "DEPRECATION") - return (get(ViewRegistry).getEntryFor(rendering::class) as? ScreenViewFactory) - ?: (rendering as? AndroidScreen<*>)?.viewFactory as? ScreenViewFactory - ?: (rendering as? AsScreen<*>)?.let { AsScreenViewFactory as ScreenViewFactory } - ?: (rendering as? BackStackScreen<*>)?.let { - BackStackScreenViewFactory as ScreenViewFactory - } - ?: (rendering as? NamedScreen<*>)?.let { NamedScreenViewFactory as ScreenViewFactory } - ?: (rendering as? EnvironmentScreen<*>)?.let { - EnvironmentScreenViewFactory as ScreenViewFactory - } - ?: throw IllegalArgumentException( - "A ScreenViewFactory should have been registered to display $rendering, " + - "or that class should implement AndroidScreen." - ) -} - -/** - * Default implementation for the `initializeView` argument of [Screen.buildView], - * and for [DecorativeScreenViewFactory.initializeView]. Calls [showRendering] against - * [getRendering] and [environment]. - */ -@WorkflowUiExperimentalApi -public fun View.showFirstRendering() { - showRendering(getRendering()!!, environment!!) -} diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ScreenViewFactory.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ScreenViewFactory.kt index 507658c0ca..6b9addbcb0 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ScreenViewFactory.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ScreenViewFactory.kt @@ -3,6 +3,12 @@ package com.squareup.workflow1.ui import android.content.Context import android.view.View import android.view.ViewGroup +import com.squareup.workflow1.ui.container.BackStackScreen +import com.squareup.workflow1.ui.container.BackStackScreenViewFactory +import com.squareup.workflow1.ui.container.BodyAndModalsContainer +import com.squareup.workflow1.ui.container.BodyAndModalsScreen +import com.squareup.workflow1.ui.container.EnvironmentScreen +import com.squareup.workflow1.ui.container.EnvironmentScreenViewFactory /** * Factory for [View] instances that can show renderings of type [RenderingT] : [Screen]. @@ -31,3 +37,87 @@ public interface ScreenViewFactory : ViewRegistry.Entry< container: ViewGroup? = null ): View } + +/** + * It is usually more convenient to use [WorkflowViewStub] or [DecorativeScreenViewFactory] + * than to call this method directly. + * + * Finds a [ScreenViewFactory] to create a [View] to display [this@buildView]. The new view + * can be updated via calls to [View.showRendering] -- that is, it is guaranteed that + * [bindShowRendering] has been called on this view. + * + * The returned view will have a + * [WorkflowLifecycleOwner][com.squareup.workflow1.ui.androidx.WorkflowLifecycleOwner] + * set on it. The returned view must EITHER: + * + * 1. Be attached at least once to ensure that the lifecycle eventually gets destroyed (because its + * parent is destroyed), or + * 2. Have its + * [WorkflowLifecycleOwner.destroyOnDetach][com.squareup.workflow1.ui.androidx.WorkflowLifecycleOwner.destroyOnDetach] + * called, which will either schedule the + * lifecycle to be destroyed if the view is attached, or destroy it immediately if it's detached. + * + * [WorkflowViewStub] takes care of this chore itself. + * + * @param initializeView Optional function invoked immediately after the [View] is + * created (that is, immediately after the call to [ScreenViewFactory.buildView]). + * [showRendering], [getRendering] and [environment] are all available when this is called. + * Defaults to a call to [View.showFirstRendering]. + * + * @throws IllegalArgumentException if no builder can be find for type [ScreenT] + * + * @throws IllegalStateException if the matching [ScreenViewFactory] fails to call + * [View.bindShowRendering] when constructing the view + */ +@WorkflowUiExperimentalApi +public fun ScreenT.buildView( + viewEnvironment: ViewEnvironment, + contextForNewView: Context, + container: ViewGroup? = null, + initializeView: View.() -> Unit = { showFirstRendering() } +): View { + val viewFactory = viewEnvironment.getViewFactoryForRendering(this) + + return viewFactory.buildView(this, viewEnvironment, contextForNewView, container).also { view -> + checkNotNull(view.showRenderingTag) { + "View.bindShowRendering should have been called for $view, typically by the " + + "${ScreenViewFactory::class.java.name} that created it." + } + initializeView.invoke(view) + } +} + +/** + * Default implementation for the `initializeView` argument of [Screen.buildView], + * and for [DecorativeScreenViewFactory.initializeView]. Calls [showRendering] against + * [getRendering] and [environment]. + */ +@WorkflowUiExperimentalApi +public fun View.showFirstRendering() { + showRendering(getRendering()!!, environment!!) +} + +@WorkflowUiExperimentalApi +internal fun + ViewEnvironment.getViewFactoryForRendering(rendering: ScreenT): ScreenViewFactory { + val entry = get(ViewRegistry).getEntryFor(rendering::class) + + @Suppress("UNCHECKED_CAST", "DEPRECATION") + return (entry as? ScreenViewFactory) + ?: (rendering as? AndroidScreen<*>)?.viewFactory as? ScreenViewFactory + ?: (rendering as? AsScreen<*>)?.let { AsScreenViewFactory as ScreenViewFactory } + ?: (rendering as? BackStackScreen<*>)?.let { + BackStackScreenViewFactory as ScreenViewFactory + } + ?: (rendering as? BodyAndModalsScreen<*, *>)?.let { + BodyAndModalsContainer as ScreenViewFactory + } + ?: (rendering as? NamedScreen<*>)?.let { NamedScreenViewFactory as ScreenViewFactory } + ?: (rendering as? EnvironmentScreen<*>)?.let { + EnvironmentScreenViewFactory as ScreenViewFactory + } + ?: throw IllegalArgumentException( + "A ScreenViewFactory should have been registered to display $rendering, " + + "or that class should implement AndroidScreen. Instead found $entry." + ) +} diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/AlertDialogThemeResId.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/AlertDialogThemeResId.kt new file mode 100644 index 0000000000..f450840e00 --- /dev/null +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/AlertDialogThemeResId.kt @@ -0,0 +1,13 @@ +package com.squareup.workflow1.ui.container + +import com.squareup.workflow1.ui.ViewEnvironmentKey +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi + +/** + * The default [OverlayDialogFactory] for [AlertOverlay] reads this value + * for the `@StyleRes themeResId: Int` argument of `AlertDialog.Builder()`. + */ +@WorkflowUiExperimentalApi +public object AlertDialogThemeResId : ViewEnvironmentKey(type = Int::class) { + override val default: Int = 0 +} 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 new file mode 100644 index 0000000000..1b01bd89e2 --- /dev/null +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/AlertOverlayDialogFactory.kt @@ -0,0 +1,66 @@ +package com.squareup.workflow1.ui.container + +import android.app.AlertDialog +import android.app.Dialog +import android.content.Context +import android.content.DialogInterface +import android.view.View +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.container.AlertOverlay.Button +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 + +@WorkflowUiExperimentalApi +internal object AlertOverlayDialogFactory : OverlayDialogFactory { + override val type = AlertOverlay::class + + override fun buildDialog( + initialRendering: AlertOverlay, + initialEnvironment: ViewEnvironment, + context: Context + ): AlertDialog { + return AlertDialog.Builder(context, initialEnvironment[AlertDialogThemeResId]) + .create() + } + + override fun updateDialog( + dialog: Dialog, + rendering: AlertOverlay, + environment: ViewEnvironment + ) { + val alertDialog = dialog as AlertDialog + + if (rendering.cancelable) { + alertDialog.setOnCancelListener { rendering.onEvent(Canceled) } + alertDialog.setCancelable(true) + } else { + alertDialog.setCancelable(false) + } + + 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 + } + } + + alertDialog.setMessage(rendering.message) + alertDialog.setTitle(rendering.title) + } + + private fun Button.toId(): Int = when (this) { + POSITIVE -> DialogInterface.BUTTON_POSITIVE + NEGATIVE -> DialogInterface.BUTTON_NEGATIVE + NEUTRAL -> DialogInterface.BUTTON_NEUTRAL + } +} diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/AndroidOverlay.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/AndroidOverlay.kt new file mode 100644 index 0000000000..0d5341e9e1 --- /dev/null +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/AndroidOverlay.kt @@ -0,0 +1,22 @@ +package com.squareup.workflow1.ui.container + +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi + +/** + * Interface implemented by a rendering class to allow it to drive an Android UI + * via an appropriate [OverlayDialogFactory] implementation. + * + * This is the simplest way to introduce a [Dialog][android.app.Dialog] workflow driven UI, + * but using it requires your workflows code to reside in Android modules, instead + * of pure Kotlin. If this is a problem, or you need more flexibility for any other + * reason, you can use [ViewRegistry][com.squareup.workflow1.ui.ViewRegistry] to bind + * your renderings to [OverlayDialogFactory] implementations at runtime. + * Also note that a `ViewRegistry` entry will override the [dialogFactory] returned by + * an [AndroidOverlay]. + * + * @see com.squareup.workflow1.ui.AndroidScreen + */ +@WorkflowUiExperimentalApi +public interface AndroidOverlay> : Overlay { + public val dialogFactory: OverlayDialogFactory +} 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 new file mode 100644 index 0000000000..ed5011e6af --- /dev/null +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/BodyAndModalsContainer.kt @@ -0,0 +1,134 @@ +package com.squareup.workflow1.ui.container + +import android.content.Context +import android.os.Parcel +import android.os.Parcelable +import android.os.Parcelable.Creator +import android.os.SystemClock +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.MotionEvent.ACTION_CANCEL +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.widget.FrameLayout +import com.squareup.workflow1.ui.ManualScreenViewFactory +import com.squareup.workflow1.ui.R +import com.squareup.workflow1.ui.ScreenViewFactory +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.WorkflowViewStub +import com.squareup.workflow1.ui.bindShowRendering + +@WorkflowUiExperimentalApi +internal class BodyAndModalsContainer @JvmOverloads constructor( + context: Context, + attributeSet: AttributeSet? = null, + 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) + + 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() + } + } + + /** + * 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 onSaveInstanceState(): Parcelable { + return SavedState( + superState = super.onSaveInstanceState()!!, + savedDialogs = dialogs.onSaveInstanceState() + ) + } + + override fun onRestoreInstanceState(state: Parcelable) { + (state as? SavedState) + ?.let { + dialogs.onRestoreInstanceState(state.savedDialogs) + super.onRestoreInstanceState(state.superState) + } + ?: super.onRestoreInstanceState(super.onSaveInstanceState()) + // Some other class wrote state, but we're not allowed to skip + // 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, + savedDialogs: LayeredDialogs.SavedState + ) : super(superState) { + this.savedDialogs = savedDialogs + } + + constructor(source: Parcel) : super(source) { + @Suppress("UNCHECKED_CAST") + savedDialogs = source.readParcelable(SavedState::class.java.classLoader)!! + } + + val savedDialogs: LayeredDialogs.SavedState + + override fun writeToParcel( + out: Parcel, + flags: Int + ) { + super.writeToParcel(out, flags) + out.writeParcelable(savedDialogs, flags) + } + + companion object CREATOR : Creator { + override fun createFromParcel(source: Parcel): SavedState = + SavedState(source) + + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } + + companion object : ScreenViewFactory> + by ManualScreenViewFactory( + type = BodyAndModalsScreen::class, + viewConstructor = { initialRendering, initialEnv, context, _ -> + BodyAndModalsContainer(context) + .apply { + id = R.id.workflow_body_and_modals_container + layoutParams = (LayoutParams(MATCH_PARENT, MATCH_PARENT)) + bindShowRendering(initialRendering, initialEnv, ::update) + } + } + ) +} 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 new file mode 100644 index 0000000000..7acab40018 --- /dev/null +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/DialogHolder.kt @@ -0,0 +1,150 @@ +package com.squareup.workflow1.ui.container + +import android.app.Dialog +import android.content.Context +import android.os.Bundle +import android.os.Parcel +import android.os.Parcelable +import android.os.Parcelable.Creator +import androidx.core.view.doOnAttach +import androidx.core.view.doOnDetach +import androidx.lifecycle.Lifecycle.Event.ON_DESTROY +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.OnLifecycleEvent +import com.squareup.workflow1.ui.Compatible +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.androidx.WorkflowLifecycleOwner +import com.squareup.workflow1.ui.compatible + +@WorkflowUiExperimentalApi +internal class DialogHolder( + initialRendering: T, + initialViewEnvironment: ViewEnvironment, + private val context: Context, + private val factory: OverlayDialogFactory +) { + + var rendering: T = initialRendering + private set + + var environment: ViewEnvironment = initialViewEnvironment + private set + + private var dialogOrNull: Dialog? = null + + fun show(parentLifecycleOwner: LifecycleOwner?) { + requireDialog().let { dialog -> + dialog.show() + dialog.window?.decorView?.also { decorView -> + // Implementations of buildDialog may set their own WorkflowLifecycleOwner on the + // content view, so to avoid interfering with them we also set it here. When the views + // are attached, this will become the parent lifecycle of the one from buildDialog if + // any, and so we can use our lifecycle to destroy-on-detach the dialog hierarchy. + WorkflowLifecycleOwner.installOn( + decorView, + findParentLifecycle = { parentLifecycleOwner?.lifecycle } + ) + + decorView.doOnAttach { + val lifecycle = parentLifecycleOwner?.lifecycle ?: return@doOnAttach + val onDestroy = OnDestroy { dismiss() } + + // Android makes a lot of logcat noise if it has to close the window for us. :/ + // And no, we can't call ref.dismiss() directly from the doOnDetach lambda, + // that's too late. + // https://github.com/square/workflow/issues/51 + lifecycle.addObserver(onDestroy) + + // Note that we are careful not to make the doOnDetach call unless + // we actually get attached. It is common for the dialog to be dismissed + // before it is ever shown, so doOnDetach would never fire and we'd leak the + // onDestroy lambda. + decorView.doOnDetach { lifecycle.removeObserver(onDestroy) } + } + } + } + } + + fun dismiss() { + // The dialog's views are about to be detached, and when that happens we want to transition + // the dialog view's lifecycle to a terminal state even though the parent is probably still + // alive. + dialogOrNull?.let { dialog -> + dialog.window?.decorView?.let(WorkflowLifecycleOwner::get)?.destroyOnDetach() + dialog.dismiss() + } + } + + fun canTakeRendering(rendering: Overlay): Boolean { + return compatible(this.rendering, rendering) + } + + fun takeRendering( + rendering: Overlay, + environment: ViewEnvironment + ) { + check(canTakeRendering(rendering)) { + "Expected $this to be able to show rendering $rendering, but that did not match " + + "previous rendering ${this.rendering}." + } + + @Suppress("UNCHECKED_CAST") + this.rendering = rendering as T + this.environment = environment + + dialogOrNull?.let { dialog -> + factory.updateDialog(dialog, this.rendering, this.environment) + } + } + + internal fun save(): KeyAndBundle? { + val saved = dialogOrNull?.window?.saveHierarchyState() ?: return null + return KeyAndBundle(Compatible.keyFor(rendering), saved) + } + + internal fun restore(keyAndBundle: KeyAndBundle) { + if (Compatible.keyFor(rendering) == keyAndBundle.compatibilityKey) { + requireDialog().window?.restoreHierarchyState(keyAndBundle.bundle) + } + } + + private fun requireDialog(): Dialog { + return dialogOrNull ?: factory.buildDialog(rendering, environment, context) + .also { + dialogOrNull = it + takeRendering(rendering, environment) + } + } + + internal data class KeyAndBundle( + internal val compatibilityKey: String, + internal val bundle: Bundle + ) : Parcelable { + override fun describeContents(): Int = 0 + + override fun writeToParcel( + parcel: Parcel, + flags: Int + ) { + parcel.writeString(compatibilityKey) + parcel.writeBundle(bundle) + } + + companion object CREATOR : Creator { + override fun createFromParcel(parcel: Parcel): KeyAndBundle { + val key = parcel.readString()!! + val bundle = parcel.readBundle(KeyAndBundle::class.java.classLoader)!! + return KeyAndBundle(key, bundle) + } + + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } + + private class OnDestroy(private val block: () -> Unit) : LifecycleObserver { + @OnLifecycleEvent(ON_DESTROY) + fun onDestroy() = block() + } +} 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 new file mode 100644 index 0000000000..f9d3b06168 --- /dev/null +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/LayeredDialogs.kt @@ -0,0 +1,146 @@ +package com.squareup.workflow1.ui.container + +import android.content.Context +import android.os.Parcel +import android.os.Parcelable +import android.os.Parcelable.Creator +import android.view.View +import androidx.lifecycle.LifecycleOwner +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.androidx.WorkflowLifecycleOwner +import com.squareup.workflow1.ui.container.DialogHolder.KeyAndBundle + +/** + * Does the bulk of the work of maintaining a set of [Dialog][android.app.Dialog]s + * 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. + */ +@WorkflowUiExperimentalApi +public class LayeredDialogs( + private val context: Context, + private val getParentLifecycleOwner: () -> LifecycleOwner? +) { + /** + * Builds a [LayeredDialogs] which looks through [view] to find its parent + * [LifecycleOwner][getParentLifecycleOwner]. + */ + public constructor(view: View) : this(view.context, { WorkflowLifecycleOwner.get(view) }) + + private var holders: List> = emptyList() + + /** True when any dialogs are visible, or becoming visible. */ + public val hasDialogs: Boolean = holders.isNotEmpty() + + /** + * Updates the managed set of [Dialog][android.app.Dialog] instances to reflect + * [overlays]. Opens new dialogs, updates existing ones, and dismisses those + * that match no member of that list. + * + * Each dialog has its own [WorkflowLifecycleOwner], which starts when the dialog + * is shown, and is destroyed when it is dismissed. Views nested in a managed dialog + * can use [ViewTreeLifecycleOwner][androidx.lifecycle.ViewTreeLifecycleOwner] as + * usual. + */ + public fun update( + overlays: List, + viewEnvironment: ViewEnvironment, + beforeShowing: () -> Unit = {} + ) { + // Any nested back stacks have to provide saved state registries of their + // own, but these things share a global namespace. To make that practical + // we add uniquing strings to the ViewEnvironment for each dialog, + // via withBackStackStateKeyPrefix. + + val overlayEnvironments = overlays.mapIndexed { index, _ -> + viewEnvironment.withBackStackStateKeyPrefix("[${index + 1}]") + } + + // On each update we build a new list of the running dialogs, both the + // existing ones and any new ones. We need this so that we can compare + // it with the previous list, and see what dialogs close. + val newHolders = mutableListOf>() + + for ((i, overlay) in overlays.withIndex()) { + val overlayEnvironment = overlayEnvironments[i] + + newHolders += if (i < holders.size && holders[i].canTakeRendering(overlay)) { + // There is already a dialog at this index, and it is compatible + // with the new Overlay at that index. Just update it. + holders[i].also { it.takeRendering(overlay, overlayEnvironment) } + } 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(overlayEnvironment).let { dialogFactory -> + DialogHolder( + overlay, overlayEnvironment, 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 + // required by Compose. + newHolder.show(getParentLifecycleOwner()) + } + } + } + } + + (holders - newHolders.toSet()).forEach { it.dismiss() } + holders = newHolders + // TODO Smarter diffing, and Z order. Maybe just hide and show everything on every update? + } + + /** To be called from a container view's [View.onSaveInstanceState]. */ + public fun onSaveInstanceState(): SavedState { + return SavedState(holders.mapNotNull { it.save() }) + } + + /** To be called from a container view's [View.onRestoreInstanceState]. */ + public fun onRestoreInstanceState(state: SavedState) { + if (state.dialogBundles.size == holders.size) { + state.dialogBundles.zip(holders) { viewState, holder -> holder.restore(viewState) } + } + } + + public class SavedState : Parcelable { + internal val dialogBundles: List + + internal constructor(dialogBundles: List) { + this.dialogBundles = dialogBundles + } + + public constructor(source: Parcel) { + dialogBundles = mutableListOf().apply { + source.readTypedList(this, KeyAndBundle) + } + } + + override fun describeContents(): Int = 0 + + override fun writeToParcel( + out: Parcel, + flags: Int + ) { + out.writeTypedList(dialogBundles) + } + + public companion object CREATOR : Creator { + override fun createFromParcel(source: Parcel): SavedState = + SavedState(source) + + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } +} diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/OverlayDialogFactory.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/OverlayDialogFactory.kt new file mode 100644 index 0000000000..1f5d11fba8 --- /dev/null +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/OverlayDialogFactory.kt @@ -0,0 +1,51 @@ +package com.squareup.workflow1.ui.container + +import android.app.Dialog +import android.content.Context +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.ViewRegistry +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi + +/** + * Factory for [Dialog] instances that can show renderings of type [RenderingT] : [Overlay]. + * + * It's simplest to have your rendering classes implement [AndroidOverlay] to associate + * them with appropriate an appropriate [OverlayDialogFactory]. For more flexibility, and to + * avoid coupling your workflow directly to the Android runtime, see [ViewRegistry]. + */ +@WorkflowUiExperimentalApi +public interface OverlayDialogFactory : ViewRegistry.Entry { + /** Build a [Dialog], but do not show it. */ + public fun buildDialog( + initialRendering: RenderingT, + initialEnvironment: ViewEnvironment, + context: Context + ): Dialog + + /** + * Update a [dialog] previously built by [buildDialog] to reflect [rendering] and + * [environment]. Bear in mind that this method may be called frequently, without + * [rendering] or [environment] having changed from the previous call. + */ + public fun updateDialog( + dialog: Dialog, + rendering: RenderingT, + environment: ViewEnvironment + ) +} + +@WorkflowUiExperimentalApi +public fun T.toDialogFactory( + viewEnvironment: ViewEnvironment +): OverlayDialogFactory { + val entry = viewEnvironment[ViewRegistry].getEntryFor(this::class) + + @Suppress("UNCHECKED_CAST") + return entry as? OverlayDialogFactory + ?: (this as? AndroidOverlay<*>)?.dialogFactory as? OverlayDialogFactory + ?: (this as? AlertOverlay)?.let { AlertOverlayDialogFactory as OverlayDialogFactory } + ?: throw IllegalArgumentException( + "An OverlayDialogFactory should have been registered to display $this, " + + "or that class should implement AndroidOverlay. Instead found $entry." + ) +} 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 996b3b662e..2667357249 100644 --- a/workflow-ui/core-android/src/main/res/values/ids.xml +++ b/workflow-ui/core-android/src/main/res/values/ids.xml @@ -9,6 +9,8 @@ + + diff --git a/workflow-ui/core-android/src/test/java/com/squareup/workflow1/ui/AndroidViewEnvironmentTest.kt b/workflow-ui/core-android/src/test/java/com/squareup/workflow1/ui/ScreenViewFactoryTest.kt similarity index 97% rename from workflow-ui/core-android/src/test/java/com/squareup/workflow1/ui/AndroidViewEnvironmentTest.kt rename to workflow-ui/core-android/src/test/java/com/squareup/workflow1/ui/ScreenViewFactoryTest.kt index c5b5b49e05..50c546b20d 100644 --- a/workflow-ui/core-android/src/test/java/com/squareup/workflow1/ui/AndroidViewEnvironmentTest.kt +++ b/workflow-ui/core-android/src/test/java/com/squareup/workflow1/ui/ScreenViewFactoryTest.kt @@ -14,7 +14,7 @@ import org.mockito.kotlin.mock import kotlin.reflect.KClass import kotlin.test.assertFailsWith -internal class AndroidViewEnvironmentTest { +internal class ScreenViewFactoryTest { @OptIn(WorkflowUiExperimentalApi::class) @Test fun missingBindingMessage_isUseful() { @@ -35,7 +35,8 @@ internal class AndroidViewEnvironmentTest { } assertThat(error.message).isEqualTo( "A ScreenViewFactory should have been registered to display " + - "FooScreen, or that class should implement AndroidScreen." + "FooScreen, or that class should implement AndroidScreen. " + + "Instead found null." ) } diff --git a/workflow-ui/core-common/api/core-common.api b/workflow-ui/core-common/api/core-common.api index 93a706b240..465cd71bea 100644 --- a/workflow-ui/core-common/api/core-common.api +++ b/workflow-ui/core-common/api/core-common.api @@ -112,6 +112,52 @@ public final class com/squareup/workflow1/ui/ViewRegistryKt { public abstract interface annotation class com/squareup/workflow1/ui/WorkflowUiExperimentalApi : java/lang/annotation/Annotation { } +public final class com/squareup/workflow1/ui/container/AlertOverlay : com/squareup/workflow1/ui/container/Overlay { + public fun (Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)V + public synthetic fun (Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/util/Map; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Z + public final fun component5 ()Lkotlin/jvm/functions/Function1; + public final fun copy (Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/ui/container/AlertOverlay; + public static synthetic fun copy$default (Lcom/squareup/workflow1/ui/container/AlertOverlay;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/squareup/workflow1/ui/container/AlertOverlay; + public fun equals (Ljava/lang/Object;)Z + public final fun getButtons ()Ljava/util/Map; + public final fun getCancelable ()Z + public final fun getMessage ()Ljava/lang/String; + public final fun getOnEvent ()Lkotlin/jvm/functions/Function1; + public final fun getTitle ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/squareup/workflow1/ui/container/AlertOverlay$Button : java/lang/Enum { + public static final field NEGATIVE Lcom/squareup/workflow1/ui/container/AlertOverlay$Button; + public static final field NEUTRAL Lcom/squareup/workflow1/ui/container/AlertOverlay$Button; + public static final field POSITIVE Lcom/squareup/workflow1/ui/container/AlertOverlay$Button; + public static fun valueOf (Ljava/lang/String;)Lcom/squareup/workflow1/ui/container/AlertOverlay$Button; + public static fun values ()[Lcom/squareup/workflow1/ui/container/AlertOverlay$Button; +} + +public abstract class com/squareup/workflow1/ui/container/AlertOverlay$Event { +} + +public final class com/squareup/workflow1/ui/container/AlertOverlay$Event$ButtonClicked : com/squareup/workflow1/ui/container/AlertOverlay$Event { + public fun (Lcom/squareup/workflow1/ui/container/AlertOverlay$Button;)V + public final fun component1 ()Lcom/squareup/workflow1/ui/container/AlertOverlay$Button; + public final fun copy (Lcom/squareup/workflow1/ui/container/AlertOverlay$Button;)Lcom/squareup/workflow1/ui/container/AlertOverlay$Event$ButtonClicked; + public static synthetic fun copy$default (Lcom/squareup/workflow1/ui/container/AlertOverlay$Event$ButtonClicked;Lcom/squareup/workflow1/ui/container/AlertOverlay$Button;ILjava/lang/Object;)Lcom/squareup/workflow1/ui/container/AlertOverlay$Event$ButtonClicked; + public fun equals (Ljava/lang/Object;)Z + public final fun getButton ()Lcom/squareup/workflow1/ui/container/AlertOverlay$Button; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/squareup/workflow1/ui/container/AlertOverlay$Event$Canceled : com/squareup/workflow1/ui/container/AlertOverlay$Event { + public static final field INSTANCE Lcom/squareup/workflow1/ui/container/AlertOverlay$Event$Canceled; +} + public final class com/squareup/workflow1/ui/container/BackStackScreen : com/squareup/workflow1/ui/Screen { public fun (Lcom/squareup/workflow1/ui/Screen;Ljava/util/List;)V public fun (Lcom/squareup/workflow1/ui/Screen;[Lcom/squareup/workflow1/ui/Screen;)V @@ -132,6 +178,14 @@ public final class com/squareup/workflow1/ui/container/BackStackScreenKt { public static final fun toBackStackScreenOrNull (Ljava/util/List;)Lcom/squareup/workflow1/ui/container/BackStackScreen; } +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 final fun getBody ()Lcom/squareup/workflow1/ui/Screen; + public final fun getModals ()Ljava/util/List; +} + public final class com/squareup/workflow1/ui/container/EnvironmentScreen : com/squareup/workflow1/ui/Compatible, com/squareup/workflow1/ui/Screen { public fun getCompatibilityKey ()Ljava/lang/String; public final fun getScreen ()Lcom/squareup/workflow1/ui/Screen; @@ -145,3 +199,6 @@ public final class com/squareup/workflow1/ui/container/EnvironmentScreenKt { public static final fun withRegistry (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewRegistry;)Lcom/squareup/workflow1/ui/container/EnvironmentScreen; } +public abstract interface class com/squareup/workflow1/ui/container/Overlay { +} + diff --git a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/container/AlertOverlay.kt b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/container/AlertOverlay.kt new file mode 100644 index 0000000000..2dae78ba68 --- /dev/null +++ b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/container/AlertOverlay.kt @@ -0,0 +1,47 @@ +package com.squareup.workflow1.ui.container + +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi + +/** + * Models a typical "You sure about that?" alert box. + */ +@WorkflowUiExperimentalApi +public data class AlertOverlay( + val buttons: Map = emptyMap(), + val message: String = "", + val title: String = "", + val cancelable: Boolean = true, + val onEvent: (Event) -> Unit +) : Overlay { + public enum class Button { + POSITIVE, + NEGATIVE, + NEUTRAL + } + + public sealed class Event { + public data class ButtonClicked(val button: Button) : Event() + + public object Canceled : Event() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AlertOverlay + + return buttons == other.buttons && + message == other.message && + title == other.title && + cancelable == other.cancelable + } + + override fun hashCode(): Int { + var result = buttons.hashCode() + result = 31 * result + message.hashCode() + result = 31 * result + title.hashCode() + result = 31 * result + cancelable.hashCode() + return result + } +} 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 new file mode 100644 index 0000000000..b26d001e15 --- /dev/null +++ b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/container/BodyAndModalsScreen.kt @@ -0,0 +1,20 @@ +package com.squareup.workflow1.ui.container + +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi + +/** + * A screen that may stack a number of modal [Overlay]s over a body. + * While modals are present, the body is expected to ignore any + * input events -- touch, keyboard, etc. + */ +@WorkflowUiExperimentalApi +public class BodyAndModalsScreen( + public val body: B, + public val modals: List = emptyList() +) : Screen { + public constructor( + body: B, + modal: M + ) : this(body, listOf(modal)) +} diff --git a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/container/Overlay.kt b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/container/Overlay.kt new file mode 100644 index 0000000000..a8311ec637 --- /dev/null +++ b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/container/Overlay.kt @@ -0,0 +1,18 @@ +package com.squareup.workflow1.ui.container + +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi + +/** + * Marker interface implemented by window-like renderings that map to a layer above + * a base [Screen][com.squareup.workflow1.ui.Screen]. + * + * Note that an Overlay is not necessarily a modal window, though that is how + * they are used in [BodyAndModalsScreen]. An Overlay can be any part of the UI + * that visually floats in a layer above the main UI, or above other Overlays. + * Possible examples include alerts, drawers, and tooltips. + * + * Whether or not an Overlay's presence indicates that events are blocked from lower + * layers is a separate concern. + */ +@WorkflowUiExperimentalApi +public interface Overlay