diff --git a/samples/containers/thingy/build.gradle.kts b/samples/containers/thingy/build.gradle.kts new file mode 100644 index 0000000000..84b4a6a5cd --- /dev/null +++ b/samples/containers/thingy/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + id("com.android.application") + id("kotlin-android") + id("android-sample-app") + id("android-ui-tests") + id("kotlin-parcelize") +} + +android { + defaultConfig { + applicationId = "com.squareup.sample.thingy" + } + namespace = "com.squareup.sample.thingy" +} + +dependencies { + debugImplementation(libs.squareup.leakcanary.android) + + implementation(libs.androidx.activity.ktx) + + implementation(project(":samples:containers:android")) + implementation(project(":workflow-ui:core-android")) + implementation(project(":workflow-ui:core-common")) +} diff --git a/samples/containers/thingy/lint-baseline.xml b/samples/containers/thingy/lint-baseline.xml new file mode 100644 index 0000000000..a1c902b19e --- /dev/null +++ b/samples/containers/thingy/lint-baseline.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + diff --git a/samples/containers/thingy/src/main/AndroidManifest.xml b/samples/containers/thingy/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..a682370fd9 --- /dev/null +++ b/samples/containers/thingy/src/main/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/ActionQueue.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/ActionQueue.kt new file mode 100644 index 0000000000..96c677e2c6 --- /dev/null +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/ActionQueue.kt @@ -0,0 +1,80 @@ +package com.squareup.sample.thingy + +import com.squareup.workflow1.NullableInitBox +import com.squareup.workflow1.Updater +import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.action + +internal typealias StateTransformation = (MutableList) -> Unit + +internal class ActionQueue { + + private val lock = Any() + + private val stateTransformations = mutableListOf() + private val outputEmissions = mutableListOf() + + fun enqueueStateTransformation(transformation: StateTransformation) { + synchronized(lock) { + stateTransformations += transformation + } + } + + fun enqueueOutputEmission(value: Any?) { + synchronized(lock) { + outputEmissions += value + } + } + + /** + * @param onNextEmitOutputAction Called when the returned action is applied if there are more + * outputs to emit. This callback should send another action into the sink to consume those + * outputs. + */ + fun consumeToAction(onNextEmitOutputAction: () -> Unit): WorkflowAction<*, *, *> = + action(name = { "ActionQueue.consumeToAction()" }) { + consume(onNextEmitOutputAction) + } + + fun consumeActionsToStack(stack: MutableList) { + val transformations = synchronized(lock) { + stateTransformations.toList().also { + stateTransformations.clear() + } + } + transformations.forEach { + it(stack) + } + } + + private fun Updater.consume( + onNextEmitOutputAction: () -> Unit + ) { + var transformations: List + var output = NullableInitBox() + var hasMoreOutputs = false + + // The workflow runtime guarantees serialization of WorkflowActions, so we only need to guard + // the actual reading of the lists in this class. + synchronized(lock) { + transformations = stateTransformations.toList() + stateTransformations.clear() + + if (outputEmissions.isNotEmpty()) { + // Can't use removeFirst on JVM, it resolves to too-new JVM method. + output = NullableInitBox(outputEmissions.removeAt(0)) + hasMoreOutputs = outputEmissions.isNotEmpty() + } + } + + if (output.isInitialized) { + setOutput(output) + } + + state = state.transformStack(transformations) + + if (hasMoreOutputs) { + onNextEmitOutputAction() + } + } +} diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackDispatcher.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackDispatcher.kt new file mode 100644 index 0000000000..53522df5f6 --- /dev/null +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackDispatcher.kt @@ -0,0 +1,109 @@ +package com.squareup.sample.thingy + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Runnable +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.currentCoroutineContext +import kotlin.coroutines.CoroutineContext + +// TODO this is rough sketch, there are races +internal class BackStackDispatcher : CoroutineDispatcher() { + + private val lock = Any() + private val tasks = mutableListOf() + private var capturingTasks = false + private var delegate: CoroutineDispatcher? = null + private var onIdle: (() -> Unit)? = null + + /** + * Runs [block] then immediately runs all dispatched tasks before returning. + */ + fun runThenDispatchImmediately(block: () -> Unit) { + synchronized(lock) { + check(!capturingTasks) { "Cannot capture again" } + capturingTasks = true + } + try { + block() + } finally { + // Drain tasks before clearing capturing tasks so any tasks that dispatch are also captured. + drainTasks() + synchronized(lock) { + capturingTasks = false + } + // Run one last time in case tasks were enqueued while clearing the capture flag. + drainTasks() + } + } + + /** + * Suspends this coroutine indefinitely and dispatches any tasks to the current dispatcher. + * [onIdle] is called after processing tasks when there are no more tasks to process. + */ + @OptIn(ExperimentalStdlibApi::class) + suspend fun runDispatch(onIdle: () -> Unit): Nothing { + val delegate = currentCoroutineContext()[CoroutineDispatcher] ?: Dispatchers.Default + synchronized(lock) { + check(this.delegate == null) { "Expected runDispatch to only be called once concurrently" } + this.delegate = delegate + this.onIdle = onIdle + } + + try { + awaitCancellation() + } finally { + synchronized(lock) { + this.delegate = null + this.onIdle = null + } + } + } + + override fun dispatch( + context: CoroutineContext, + block: Runnable + ) { + var isCapturing: Boolean + var isFirstTask: Boolean + var delegate: CoroutineDispatcher? + var onIdle: (() -> Unit)? + + synchronized(lock) { + tasks += block + isFirstTask = tasks.size == 1 + isCapturing = this.capturingTasks + delegate = this.delegate + onIdle = this.onIdle + } + + if (!isCapturing && delegate != null && onIdle != null && isFirstTask) { + delegate!!.dispatch(context) { + // Only run onIdle if work was actually done. + if (drainTasks()) { + onIdle!!() + } + } + } + } + + /** + * Returns true if any tasks were executed. + */ + private fun drainTasks(): Boolean { + var didAnything = false + var task = getNextTask() + while (task != null) { + didAnything = true + task.run() + task = getNextTask() + } + return didAnything + } + + private fun getNextTask(): Runnable? { + synchronized(lock) { + return tasks.removeFirstOrNull() + } + } +} diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackFactory.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackFactory.kt new file mode 100644 index 0000000000..61b95c3968 --- /dev/null +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackFactory.kt @@ -0,0 +1,65 @@ +package com.squareup.sample.thingy + +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.navigation.BackStackScreen +import com.squareup.workflow1.ui.navigation.toBackStackScreen +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext + +public fun interface BackStackFactory { + + /** + * Responsible for converting a list of [Screen]s into a [BackStackScreen]. This function *must* + * handle the case where [screens] is empty, since [BackStackScreen] must always have at least + * one screen. It *should* handle the case where [isTopIdle] is true, which indicates that the + * top (last) screen in [screens] is doing some work that may eventually show another screen. + * + * @see toBackStackScreen + */ + fun createBackStack( + screens: List, + isTopIdle: Boolean + ): BackStackScreen + + companion object { + internal val ThrowOnIdle + get() = showLoadingScreen { + error("No BackStackFactory provided") + } + + /** + * Returns a [BackStackFactory] that shows a [loading screen][createLoadingScreen] when + * [BackStackWorkflow.runBackStack] has not shown anything yet or when a workflow's output + * handler is idle (not showing an active screen). + */ + fun showLoadingScreen( + name: String = "", + createLoadingScreen: () -> Screen + ): BackStackFactory = BackStackFactory { screens, isTopIdle -> + val mutableScreens = screens.toMutableList() + if (mutableScreens.isEmpty() || isTopIdle) { + mutableScreens += createLoadingScreen() + } + mutableScreens.toBackStackScreen(name) + } + } +} + +/** + * Returns a [CoroutineContext.Element] that will store this [BackStackFactory] in a + * [CoroutineContext] to later be retrieved by [backStackFactory]. + */ +public fun BackStackFactory.asContextElement(): CoroutineContext.Element = + BackStackFactoryContextElement(this) + +/** + * Looks for a [BackStackFactory] stored the current context via [asContextElement]. + */ +public val CoroutineContext.backStackFactory: BackStackFactory? + get() = this[BackStackFactoryContextElement]?.factory + +private class BackStackFactoryContextElement( + val factory: BackStackFactory +) : AbstractCoroutineContextElement(Key) { + companion object Key : CoroutineContext.Key +} diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackFrame.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackFrame.kt new file mode 100644 index 0000000000..1865cfd25d --- /dev/null +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackFrame.kt @@ -0,0 +1,18 @@ +package com.squareup.sample.thingy + +import com.squareup.workflow1.StatefulWorkflow.RenderContext +import com.squareup.workflow1.ui.Screen + +internal interface BackStackFrame { + val node: BackStackNode + + val isIdle: Boolean + get() = false + + fun withIdle(): BackStackFrame = object : BackStackFrame by this { + override val isIdle: Boolean + get() = true + } + + fun render(context: RenderContext): Screen +} diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackNode.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackNode.kt new file mode 100644 index 0000000000..d93ff29f62 --- /dev/null +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackNode.kt @@ -0,0 +1,229 @@ +package com.squareup.sample.thingy + +import com.squareup.workflow1.StatefulWorkflow.RenderContext +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.ui.Screen +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.concurrent.atomics.AtomicInt +import kotlin.concurrent.atomics.ExperimentalAtomicApi + +@OptIn(ExperimentalAtomicApi::class) +internal class BackStackNode( + private val actionQueue: ActionQueue, + private val key: String, + parentJob: Job, + private val dispatcher: BackStackDispatcher, + private val onCancel: () -> Unit, + private val onEmitOutputAction: () -> Unit +) : BackStackScope, BackStackScreenScope { + + private val result = CompletableDeferred(parent = parentJob) + private val workerScope = CoroutineScope(result + dispatcher) + + private val activeChildLock = Any() + + /** All access must be guarded by [activeChildLock]. */ + private var activeChild: BackStackNode? = null + + private var childWorkflowKeyCounter = AtomicInt(0) + + private fun createNewChildKey(): String { + val id = childWorkflowKeyCounter.fetchAndAdd(1) + return "$key.$id" + } + + /** + * Tracks how many calls to [launch] are currently running. All access must be guarded by + * [activeChildLock]. + * + * The node is idle when this value > 0 and [activeChild] is null. + */ + private var workers = 0 + + private val isIdle: Boolean + get() = synchronized(activeChildLock) { + workers > 0 && activeChild == null + } + + override suspend fun showWorkflow( + workflow: Workflow, + props: Flow, + onOutput: suspend BackStackWorkflowScope.(output: ChildOutputT) -> R + ): R = showNode { workflowNode -> + props.map { props -> + object : BackStackFrame { + override val node: BackStackNode + get() = workflowNode + + override fun render(context: RenderContext): Screen = + context.renderChild( + child = workflow, + key = workflowNode.key, + props = props, + handler = { output -> + dispatcher.runThenDispatchImmediately { + workflowNode.launch { + val scope = object : + BackStackWorkflowScope, + BackStackScope by workflowNode, + CoroutineScope by this { + + override suspend fun cancelWorkflow(): Nothing { + workflowNode.onCancel() + currentCoroutineContext().ensureActive() + error( + "cancelWorkflow() called from a coroutine that was not a child of the " + + "BackStackWorkflowScope" + ) + } + } + onOutput(scope, output) + } + } + @Suppress("UNCHECKED_CAST") + actionQueue.consumeToAction(onEmitOutputAction) as + WorkflowAction + } + ) + } + } + } + + override suspend fun showScreen( + screenFactory: BackStackScreenScope.() -> Screen + ): R = showNode { screenNode -> + flow { + @Suppress("UNCHECKED_CAST") + val screen = screenFactory(screenNode as BackStackScreenScope) + emit(object : BackStackFrame { + override val node: BackStackNode + get() = screenNode + + override fun render(context: RenderContext): Screen = screen + }) + } + } + + override fun launch(block: suspend CoroutineScope.() -> Unit) { + workerScope.launch { + synchronized(activeChildLock) { workers++ } + updateFrame() + + try { + // Need a child scope here to wait for any coroutines launched inside block to finish before + // decrementing workers. + coroutineScope { + block() + } + } finally { + synchronized(activeChildLock) { workers-- } + updateFrame() + } + } + } + + private suspend fun showNode( + block: CoroutineScope.(BackStackNode) -> Flow + ): R = withContext(dispatcher) { + val childJob = coroutineContext.job + val childNode = BackStackNode( + actionQueue = actionQueue, + key = createNewChildKey(), + parentJob = childJob, + dispatcher = dispatcher, + onCancel = result::cancelChildren, + onEmitOutputAction = onEmitOutputAction, + ) + + withActiveChild(childNode) { + val frames = block(childNode) + showFrames(childNode, frames, frameScope = this) { + @Suppress("UNCHECKED_CAST") + childNode.result.await() as R + } + } + } + + private suspend inline fun showFrames( + childNode: BackStackNode, + frames: Flow, + frameScope: CoroutineScope, + crossinline block: suspend () -> R + ): R { + try { + frames + .onEach { newFrame -> + childNode.updateFrame { newFrame } + } + .launchIn(frameScope) + + return block() + } finally { + // Remove this node's frame. + childNode.updateFrame { null } + } + } + + override fun continueWith(value: Any?) { + if (!result.complete(value)) { + error("Tried to finish with $value but already finished") + } + } + + override fun cancelScreen() { + onCancel() + } + + private suspend inline fun withActiveChild( + child: BackStackNode, + block: () -> R + ): R { + val oldChild = synchronized(activeChildLock) { + activeChild.also { activeChild = child } + } + oldChild?.result?.cancelAndJoin() + + try { + return block() + } finally { + synchronized(activeChildLock) { + // If we're being canceled by another withActiveChild call, don't overwrite the new child. + if (activeChild === child) { + activeChild = null + } + } + } + } + + private fun updateFrame( + update: ((BackStackFrame?) -> BackStackFrame?)? = null + ) { + val isIdle = isIdle + actionQueue.enqueueStateTransformation { frames -> + val index = frames.indexOfFirst { it.node === this } + val previousFrame = if (index == -1) null else frames[index] + val newFrame = if (update != null) update(previousFrame) else previousFrame + if (newFrame == null) { + frames.removeAt(index) + } else { + frames[index] = if (isIdle) newFrame.withIdle() else newFrame + } + } + } +} diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackState.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackState.kt new file mode 100644 index 0000000000..d262020e48 --- /dev/null +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackState.kt @@ -0,0 +1,147 @@ +package com.squareup.sample.thingy + +import com.squareup.workflow1.StatefulWorkflow.RenderContext +import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.navigation.BackStackScreen +import kotlinx.coroutines.flow.MutableStateFlow + +/* +TODO: Design for coalescing state updates/output emissions and dispatching + -------------------------------------------------------------------- + + Currently when something shows a thing, it creates a WorkflowAction and sends it to the action + sink directly. onOutput does one trick to capture any emitOutput called from the UNDISPATCHED + launch, but this is brittle and only handles some cases. And since these actions are sent in + multiple places—i.e. replacing a workflow sends two actions: one to remove the frame when + cancelled and one to add the new frame. + + To fix this, there is a two-part solution. + + First, actions should never be sent directly. Instead, we need a special queue object that + accepts two things: + - (State) -> State functions. These are used by show*Impl calls to update the stack. + - Output values. These are sent only by emitOutput. + + This queue can then be used to collect all queued state transformations into a single action, + along with the first-emitted output, if any. The BackStackWorkflowImpl can produce an action for + the queue whenever it needs to: + 1. In initialState, after launching the coroutine, to get the initial state for the first render + call and return from initialState. + 2. In onPropsUpdated, to collect any state changes effected by pushing the new props value into + the flow and return it from onPropsUpdated. + 3. In a workflow's outputHandler, to collect the immediate set of updates generated by the + output handler and return an action to bubble up the action cascade. + + The second part is a special CoroutineDispatcher, similar to WorkStealingDispatcher, that can be + drained at any time. The API (in addition to basic CoroutineDispatcher stuff) should look + something like this: + + internal class BackStackDispatcher: CoroutineDispatcher() { + … + + /** + * Suspends indefinitely, handling any dispatch calls that aren't inside a [runThenDrain] by + * dispatching to the dispatcher from the current context. After processing at least one task, + * when there are no more tasks enqueued, calls [onIdle]. + */ + suspend fun run(onIdle: () -> Unit) + + /** + * Runs [block] such that any tasks that are dispatched to this dispatcher by [block] are not + * dispatched like normal, but collected into a special queue and all ran after [block] returns + * but before this function returns. I.e. any coroutine work started by [block] is guaranteed to + * be have been run and the dispatcher will be idle when this function returns. + */ + fun runThenDrain(block: () -> Unit) + } + + This dispatcher can then be used inside the BackStackWorkflowImpl functions mentioned above to + ensure all coroutines run before collecting state transformations. E.g. + + override fun initialState(…): BackStackState { + dispatcher.runThenDrain { + scope.launch { runBackStack(…) } + } + val initialStack = actionQueue.consumeAllStateTransformations() + … + return BackStackState(frames = initialStack, dispatcher = dispatcher, actions = actionQueue) + } + + Inside render, `run` can be used to support normal dispatching: + + context.runningSideEffect { + state.dispatcher.run(onIdle = { + // Only returns >1 action if multiple emitOutput calls happened. All state transformations + // will always be in the first one. Returns an empty list if no state transforms or outputs + // were enqueued. + val actions = state.actionQueue.consumeAsActions() + actions.forEach { + context.actionSink.send(it) + } + }) + } + + To ensure all show*Impl calls get processed by this idle handler, they need to always internally + make sure they're running on the special dispatcher. + + All this dispatcher/action queue coordination should be encapsulated inside the BackStackState. +*/ + +// Impl note: Does some casting to avoid dealing with generics everywhere, since this is internal- +// only. +internal class BackStackState( + private val stack: List, + private val props: MutableStateFlow, + private val backStackFactory: BackStackFactory, + private val actionQueue: ActionQueue, + private val dispatcher: BackStackDispatcher, +) { + + fun setProps(props: Any?): BackStackState { + dispatcher.runThenDispatchImmediately { + this.props.value = props + } + + val mutableStack = stack.toMutableList() + actionQueue.consumeActionsToStack(mutableStack) + return copy(stack = mutableStack) + } + + fun renderOn(context: RenderContext): BackStackScreen { + context.runningSideEffect("TODO") { + dispatcher.runDispatch(onIdle = { + sendActionToSink(context) + }) + } + + val renderings = stack.map { frame -> + frame.render(context) + } + return backStackFactory.createBackStack(renderings, isTopIdle = false) + } + + fun transformStack(transformations: List): BackStackState { + val mutableStack = stack.toMutableList() + transformations.forEach { + it(mutableStack) + } + return copy(stack = mutableStack) + } + + private fun sendActionToSink(context: RenderContext) { + @Suppress("UNCHECKED_CAST") + context.actionSink.send( + actionQueue.consumeToAction { sendActionToSink(context) } as + WorkflowAction + ) + } + + private fun copy(stack: List = this.stack) = BackStackState( + stack = stack, + props = props, + backStackFactory = backStackFactory, + actionQueue = actionQueue, + dispatcher = dispatcher, + ) +} diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt new file mode 100644 index 0000000000..b6d8c13b97 --- /dev/null +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt @@ -0,0 +1,361 @@ +package com.squareup.sample.thingy + +import com.squareup.workflow1.StatefulWorkflow +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.navigation.BackStackScreen +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext + +/** + * Creates a [BackStackWorkflow]. + * + * @param getBackStackFactory See [BackStackWorkflow.getBackStackFactory]. If null, the default + * implementation is used. + * @param runBackStack See [BackStackWorkflow.runBackStack]. + */ +public inline fun backStackWorkflow( + noinline getBackStackFactory: ((CoroutineContext) -> BackStackFactory)? = null, + crossinline runBackStack: suspend BackStackScope.( + props: StateFlow, + emitOutput: (OutputT) -> Unit + ) -> Unit +): Workflow> = + object : BackStackWorkflow() { + override suspend fun BackStackScope.runBackStack( + props: StateFlow, + emitOutput: (OutputT) -> Unit + ) { + runBackStack(props, emitOutput) + } + + override fun getBackStackFactory(coroutineContext: CoroutineContext): BackStackFactory = + if (getBackStackFactory != null) { + getBackStackFactory(coroutineContext) + } else { + super.getBackStackFactory(coroutineContext) + } + } + +/** + * Returns a [Workflow] that renders a [BackStackScreen] whose frames are controlled by the code + * in [runBackStack]. + * + * [runBackStack] can show renderings and render child workflows, as well as emit outputs to this + * workflow's parent. See the docs on that method for more info. + */ +public abstract class BackStackWorkflow : + Workflow> { + + /** + * Show renderings by calling [BackStackScope.showScreen]. Show child workflows by calling + * [BackStackScope.showWorkflow]. Emit outputs by calling [emitOutput]. + * + * # Showing a screen + * + * ``` + * backStackWorkflow { _, _ -> + * // Suspends until continueWith is called. + * val result = showScreen { + * MyScreenClass( + * // Returns "finished" from showScreen. + * onDoneClicked = { continueWith("finished") }, + * ) + * } + * } + * ``` + * + * # Showing a workflow + * + * ``` + * backStackWorkflow { _, _ -> + * // Suspends until an onOutput lambda returns a value. + * val result = showWorkflow( + * childWorkflow, + * props = flowOf(childProps) + * onOutput = { output -> + * // Returns "finished: …" from showWorkflow. + * return@showWorkflow "finished: $output" + * } + * ) + * } + * ``` + * + * # Emitting output + * + * The second parameter to the [launchRunBackStack] function is an [emitOutput] function that will send + * whatever you pass to it to this workflow's parent as an output. + * ``` + * backStackWorkflow { _, emitOutput -> + * showWorkflow( + * childWorkflow, + * props = flowOf(childProps) + * onOutput = { output -> + * // Forward the output to parent. + * emitOutput(output) + * } + * ) + * } + * ``` + * + * # Nested vs serial calls + * + * The backstack is represented by _nesting_ `showWorkflow` calls. Consider this example: + * ``` + * backStackWorkflow { _, _ -> + * showWorkflow(child1) { + * showWorkflow(child2) { + * showWorkflow(child3) { + * // goBack() + * } + * } + * } + * } + * ``` + * This eventually represents a backstack of `[child1, child2, child3]`. `child2` will be pushed + * onto the stack when `child1` emits an output, and `child3` pushed when `child2` emits. The + * lambdas for `child2` and `child3` can call `goBack` to pop the stack and cancel the lambdas that + * called their `showWorkflow`, until the next output is emitted. + * + * Contrast with calls in series: + * ``` + * backStackWorkflow { _, _ -> + * showWorkflow(child1) { finishWith(Unit) } + * showWorkflow(child2) { finishWith(Unit) } + * showWorkflow(child3) { } + * } + * ``` + * `child1` will be shown immediately, but when it emits an output, instead of pushing `child2` onto + * the stack, `child1` will be removed from the stack and replaced with `child2`. + * + * These can be combined: + * ``` + * backStackWorkflow { _, _ -> + * showWorkflow(child1) { + * showWorkflow(child2) { + * // goBack(), or + * finishWith(Unit) + * } + * showWorkflow(child3) { + * // goBack() + * } + * } + * } + * ``` + * This code will show `child1` immediately, then when it emits an output show `child2`. When + * `child2` emits an output, it can decide to call `goBack` to show `child1` again, or call + * `finishWith` to replace itself with `child3`. `child3` can also call `goBack` to show `child` + * again. + * + * To push another screen on the backstack from a non-workflow screen, [launch] a coroutine: + * ``` + * backStackScreen { _, _ -> + * showScreen { + * MyScreen( + * onEvent = { + * launch { + * showWorkflow(…) + * } + * } + * } + * } + * } + * ``` + * + * # Cancelling screens + * + * Calling [BackStackScope.showScreen] or [BackStackScope.showWorkflow] suspends the caller until + * that workflow/screen produces a result. They handle coroutine cancellation too: if the calling + * coroutine is cancelled while they're showing, they are removed from the backstack. + * + * This can be used to, for example, update a screen based on a flow: + * ``` + * backStackWorkflow { props, _ -> + * props.collectLatest { prop -> + * showScreen { + * MyScreen(message = prop) + * } + * } + * } + * ``` + * This example shows the props received from the parent to the user via `MyScreen`. Every time + * the parent passes a new props, the `showScreen` call is cancelled and called again with the + * new props, replacing the old instance of `MyScreen` in the backstack with a new one. Since + * both instances of `MyScreen` are compatible, this is not a navigation event but just updates + * the `MyScreen` view factory. + * + * # Factoring out code + * + * You don't have to keep all the logic for your backstack in a single function. You can pull out + * functions, just make them extensions on [BackStackParentScope] to get access to `showScreen` + * and `showRendering` calls. + * + * E.g. here's a helper that performs some suspending task and shows a retry screen if it fails: + * ``` + * suspend fun BackStackParentScope.userRetriable( + * action: suspend () -> R + * ): R { + * var result = runCatching { action() } + * // runCatching can catch CancellationException, so check. + * ensureActive() + * + * while (result.isFailure) { + * showScreen { + * RetryScreen( + * message = "Failed: ${result.exceptionOrNull()}", + * onRetryClicked = { continueWith(Unit) }, + * onCancelClicked = { goBack() } + * ) + * } + * + * // Try again. + * result = runCatching { action() } + * ensureActive() + * } + * + * // We only leave the loop when action succeeded. + * return result.getOrThrow() + * } + * ``` + */ + abstract suspend fun BackStackScope.runBackStack( + props: StateFlow, + emitOutput: (OutputT) -> Unit + ) + + /** + * Return a [BackStackFactory] used to convert the stack of screens produced by this workflow to + * a [BackStackScreen]. + * + * The default implementation tries to find a [BackStackFactory] passed to the workflow runtime + * via its [CoroutineScope], and if that fails, returns an implementation that will throw whenever + * the stack is empty or the top screen is idle. + */ + open fun getBackStackFactory(coroutineContext: CoroutineContext): BackStackFactory = + coroutineContext.backStackFactory ?: BackStackFactory.ThrowOnIdle + + final override fun asStatefulWorkflow(): + StatefulWorkflow> = + BackStackWorkflowImpl(this) +} + +@DslMarker +annotation class BackStackWorkflowDsl + +@BackStackWorkflowDsl +public sealed interface BackStackParentScope { + + /** + * Starts rendering [workflow] and pushes its rendering onto the top of the backstack. + * + * Whenever [workflow] emits an output, [onOutput] is launched into a new coroutine. If one call + * doesn't finish before another output is emitted, multiple callbacks can run concurrently. + * + * When [onOutput] returns a value, this workflow stops rendering, its rendering is removed from + * the backstack, and any running output handlers are cancelled. The calling coroutine is resumed + * with the value. + * + * When [onOutput] calls [BackStackWorkflowScope.cancelWorkflow], if this [showWorkflowImpl] call is nested in + * another, then this workflow will stop rendering, any of its still-running output handlers will + * be cancelled, and the output handler that called this [showWorkflowImpl] will be cancelled. + * If this is a top-level workflow in the [BackStackWorkflow], the whole + * [BackStackWorkflow.runBackStack] is cancelled and restarted, since "back" is only a concept + * that is relevant within a backstack, and it's not possible to know whether the parent supports + * back. What you probably want is to emit an output instead to tell the parent to go back. + * + * If the coroutine calling [showWorkflowImpl] is cancelled, the workflow stops being rendered and its + * rendering will be removed from the backstack. + * + * See [BackStackWorkflow.runBackStack] for high-level documentation about how to use this method + * to implement a backstack workflow. + * + * @param props The props passed to [workflow] when rendering it. [showWorkflowImpl] will suspend + * until the first value is emitted. Consider transforming the [BackStackWorkflow.runBackStack] + * props [StateFlow] or using [flowOf]. + */ + suspend fun showWorkflow( + workflow: Workflow, + // TODO revert this back to a single value – can use the same trick to update props as for + // emitting new screens. + props: Flow, + onOutput: suspend BackStackWorkflowScope.(output: ChildOutputT) -> R + ): R + + /** + * Shows the screen produced by [screenFactory]. Suspends untilBackStackNestedScope.goBack] is + * called. + * + * If the coroutine calling [showScreen] is cancelled, the rendering will be removed from the + * backstack. + * + * See [BackStackWorkflow.runBackStack] for high-level documentation about how to use this method + * to implement a backstack workflow. + */ + suspend fun showScreen( + screenFactory: BackStackScreenScope.() -> Screen + ): R +} + +@BackStackWorkflowDsl +public interface BackStackScope : BackStackParentScope + +/** + * Scope receiver used for all [showWorkflow] calls. This has all the capabilities of + * [BackStackScope] with the additional ability to [go back][cancelWorkflow] to its outer workflow. + */ +@BackStackWorkflowDsl +public interface BackStackWorkflowScope : BackStackScope, CoroutineScope { + + /** + * Removes all workflows started by the parent workflow's handler that invoked this [showWorkflowImpl] + * from the stack, and cancels that parent output handler coroutine (and thus all child workflow + * coroutines as well). + */ + suspend fun cancelWorkflow(): Nothing +} + +/** + * Scope receiver used for all [showScreen] calls. This has all the capabilities of + * [BackStackScope] with the additional ability to [go back][cancelScreen] to its outer workflow and + * to return from [showScreen] by calling [continueWith]. + */ +@BackStackWorkflowDsl +public interface BackStackScreenScope : BackStackScope { + /** + * Causes [showScreen] to return with [value]. + */ + fun continueWith(value: R) + + fun cancelScreen() + + fun launch(block: suspend CoroutineScope.() -> Unit) +} + +public suspend inline fun BackStackParentScope.showWorkflow( + workflow: Workflow, + props: ChildPropsT, + noinline onOutput: suspend BackStackWorkflowScope.(output: ChildOutputT) -> R +): R = showWorkflow(workflow, props = flowOf(props), onOutput = onOutput) + +public suspend inline fun BackStackParentScope.showWorkflow( + workflow: Workflow, + noinline onOutput: suspend BackStackWorkflowScope.(output: ChildOutputT) -> R +): R = showWorkflow(workflow, props = flowOf(Unit), onOutput) + +public suspend inline fun BackStackParentScope.showWorkflow( + workflow: Workflow, + props: Flow, +): Nothing = showWorkflow(workflow, props = props) { error("Cannot call") } + +public suspend inline fun BackStackParentScope.showWorkflow( + workflow: Workflow, + props: ChildPropsT, +): Nothing = showWorkflow(workflow, props = flowOf(props)) { error("Cannot call") } + +public suspend inline fun BackStackParentScope.showWorkflow( + workflow: Workflow, +): Nothing = showWorkflow(workflow, props = flowOf(Unit)) { error("Cannot call") } diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt new file mode 100644 index 0000000000..41d0e4c012 --- /dev/null +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt @@ -0,0 +1,102 @@ +package com.squareup.sample.thingy + +import com.squareup.workflow1.SessionWorkflow +import com.squareup.workflow1.Snapshot +import com.squareup.workflow1.WorkflowExperimentalApi +import com.squareup.workflow1.identifier +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.navigation.BackStackScreen +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.job +import kotlinx.coroutines.launch + +@OptIn(WorkflowExperimentalApi::class) +internal class BackStackWorkflowImpl( + private val workflow: BackStackWorkflow +) : SessionWorkflow< + PropsT, + BackStackState, + OutputT, + BackStackScreen + >() { + + override fun initialState( + props: PropsT, + snapshot: Snapshot?, + workflowScope: CoroutineScope + ): BackStackState { + val propsFlow = MutableStateFlow(props) + val backStackFactory = workflow.getBackStackFactory(workflowScope.coroutineContext) + val actionQueue = ActionQueue() + val dispatcher = BackStackDispatcher() + val rootJob = Job(parent = workflowScope.coroutineContext.job) + lateinit var rootNode: BackStackNode + + fun launchRootNode() { + rootNode.launch { + with(workflow) { + rootNode.runBackStack( + props = propsFlow, + emitOutput = { output -> + // Launch with dispatcher to trigger onIdle and actually enqueue an action. + workflowScope.launch(dispatcher) { + actionQueue.enqueueOutputEmission(output) + } + } + ) + } + } + } + + rootNode = BackStackNode( + actionQueue = actionQueue, + key = workflow.identifier.toString(), + parentJob = workflowScope.coroutineContext.job, + dispatcher = dispatcher, + onCancel = { + rootJob.cancelChildren() + launchRootNode() + }, + onEmitOutputAction = { + TODO("how to trigger more actions?") + } + ) + + dispatcher.runThenDispatchImmediately { + launchRootNode() + } + + val initialStack = buildList { + actionQueue.consumeActionsToStack(this) + } + + @Suppress("UNCHECKED_CAST") + return BackStackState( + stack = initialStack, + props = propsFlow as MutableStateFlow, + backStackFactory = backStackFactory, + actionQueue = actionQueue, + dispatcher = dispatcher, + ) + } + + override fun onPropsChanged( + old: PropsT, + new: PropsT, + state: BackStackState + ): BackStackState = state.setProps(new) + + override fun render( + renderProps: PropsT, + renderState: BackStackState, + context: RenderContext + ): BackStackScreen { + @Suppress("UNCHECKED_CAST") + return renderState.renderOn(context as RenderContext) + } + + override fun snapshotState(state: BackStackState): Snapshot? = null +} diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/HelloBackButtonActivity.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/HelloBackButtonActivity.kt new file mode 100644 index 0000000000..7211ad4a66 --- /dev/null +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/HelloBackButtonActivity.kt @@ -0,0 +1,68 @@ +@file:OptIn(WorkflowExperimentalRuntime::class) + +package com.squareup.sample.thingy + +import android.os.Bundle +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.viewModelScope +import com.squareup.sample.container.SampleContainers +import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn +import com.squareup.workflow1.config.AndroidRuntimeConfigTools +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.navigation.reportNavigation +import com.squareup.workflow1.ui.withRegistry +import com.squareup.workflow1.ui.workflowContentView +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import timber.log.Timber + +private val viewRegistry = SampleContainers + +class HelloBackButtonActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val model: HelloBackButtonModel by viewModels() + workflowContentView.take(lifecycle, model.renderings.map { it.withRegistry(viewRegistry) }) + + lifecycleScope.launch { + model.waitForExit() + finish() + } + } + + companion object { + init { + Timber.plant(Timber.DebugTree()) + } + } +} + +class HelloBackButtonModel(savedState: SavedStateHandle) : ViewModel() { + private val running = Job() + + val renderings: Flow by lazy { + renderWorkflowIn( + workflow = TODO("MyWorkflow()"), + scope = viewModelScope, + savedStateHandle = savedState, + runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig() + ) { + // This workflow handles the back button itself, so the activity can't. + // Instead, the workflow emits an output to signal that it's time to shut things down. + running.complete() + }.reportNavigation { + Timber.i("Navigated to %s", it) + } + } + + /** Blocks until the workflow signals that it's time to shut things down. */ + suspend fun waitForExit() = running.join() +} diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt new file mode 100644 index 0000000000..837ba0a6bb --- /dev/null +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt @@ -0,0 +1,86 @@ +package com.squareup.sample.thingy + +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.ui.Screen +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration.Companion.seconds + +enum class MyOutputs { + Back, + Done, +} + +data class RetryScreen( + val message: String, + val onRetryClicked: () -> Unit, + val onCancelClicked: () -> Unit, +) : Screen + +data object LoadingScreen : Screen + +@Suppress("NAME_SHADOWING") +class MyWorkflow( + private val child1: Workflow, + private val child2: Workflow, + private val child3: Workflow, + private val networkCall: suspend (String) -> String +) : BackStackWorkflow() { + + override suspend fun BackStackScope.runBackStack( + props: StateFlow, + emitOutput: (MyOutputs) -> Unit + ) { + // Step 1 + showWorkflow(child1) { output -> + when (output) { + "back" -> emitOutput(MyOutputs.Back) + "next" -> { + // Step 2 + val childResult = showWorkflow(child2) { output -> + // Removes child2 from the stack, cancels the output handler from step 1, and just + // leaves child1 rendering. + if (output == "back") cancelWorkflow() + output + } + + // Step 3 – make a network call, showing a retry screen if it fails. If the user cancels + // instead of retrying, we go back to showing child1. + val networkResult = networkCallWithRetry(childResult) + + // Step 4: Show a workflow for 3 seconds then finish. + launch { + delay(3.seconds) + emitOutput(MyOutputs.Done) + } + showWorkflow(child3, networkResult) + } + + else -> error("Unexpected output: $output") + } + } + } + + override fun getBackStackFactory(coroutineContext: CoroutineContext): BackStackFactory = + BackStackFactory.showLoadingScreen { LoadingScreen } + + private suspend fun BackStackParentScope.networkCallWithRetry( + request: String + ): String { + var networkResult = networkCall(request) + while (networkResult == "failure") { + showScreen { + RetryScreen( + message = networkResult, + onRetryClicked = { continueWith(Unit) }, + // Go back to showing child1. + onCancelClicked = { cancelScreen() } + ) + } + networkResult = networkCall(request) + } + return networkResult + } +} diff --git a/samples/containers/thingy/src/main/res/layout/hello_back_button_layout.xml b/samples/containers/thingy/src/main/res/layout/hello_back_button_layout.xml new file mode 100644 index 0000000000..0e8b71c480 --- /dev/null +++ b/samples/containers/thingy/src/main/res/layout/hello_back_button_layout.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/samples/containers/thingy/src/main/res/values/strings.xml b/samples/containers/thingy/src/main/res/values/strings.xml new file mode 100644 index 0000000000..6e0e2822ad --- /dev/null +++ b/samples/containers/thingy/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Hello Sal + diff --git a/samples/containers/thingy/src/main/res/values/styles.xml b/samples/containers/thingy/src/main/res/values/styles.xml new file mode 100644 index 0000000000..e2331afcc2 --- /dev/null +++ b/samples/containers/thingy/src/main/res/values/styles.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index ced4e51458..d09be320c6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -42,6 +42,7 @@ include( ":samples:containers:app-raven", ":samples:containers:android", ":samples:containers:common", + ":samples:containers:thingy", ":samples:containers:hello-back-button", ":samples:containers:poetry", ":samples:dungeon:app", diff --git a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/Thingy.kt b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/Thingy.kt new file mode 100644 index 0000000000..d715902c01 --- /dev/null +++ b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/Thingy.kt @@ -0,0 +1,3 @@ +package com.squareup.workflow1.ui.navigation + +public class Thingy : Stateful