From 9de62ae317b9d88a81998c5e2483e2604c50fbbe Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Fri, 28 Feb 2025 09:28:01 -0800 Subject: [PATCH] made the API more elegant, lots more docs --- .../squareup/workflow1/BaseRenderContext.kt | 11 +- .../workflow1/compose/ComposeWorkflow.kt | 193 +++++++++++++++--- .../workflow1/compose/WorkflowComposables.kt | 87 +++----- .../compose/WorkflowCompositionHost.kt | 3 +- 4 files changed, 205 insertions(+), 89 deletions(-) diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt index 47306c1f7..524bfda0e 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt @@ -11,7 +11,9 @@ package com.squareup.workflow1 import androidx.compose.runtime.Composable import com.squareup.workflow1.WorkflowAction.Companion.noAction +import com.squareup.workflow1.compose.ComposeWorkflow import com.squareup.workflow1.compose.WorkflowComposable +import com.squareup.workflow1.compose.renderWorkflow import kotlinx.coroutines.CoroutineScope import kotlin.jvm.JvmMultifileClass import kotlin.jvm.JvmName @@ -89,10 +91,13 @@ public interface BaseRenderContext { /** * Synchronously composes a [content] function and returns its rendering. Whenever [content] is - * invalidated, this workflow will be re-rendered and the [content] recomposed to return its new - * value. + * invalidated (i.e. a compose snapshot state object is changed that was previously read by + * [content] or any functions it calls), this workflow will be re-rendered and the relevant + * composables will be recomposed. * - * @see com.squareup.workflow1.compose.ComposeWorkflow + * To render child workflows from this method, call [renderWorkflow]. + * + * @see ComposeWorkflow */ public fun renderComposable( key: String = "", diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/ComposeWorkflow.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/ComposeWorkflow.kt index f4c054c33..16a75018d 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/ComposeWorkflow.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/ComposeWorkflow.kt @@ -1,56 +1,199 @@ package com.squareup.workflow1.compose import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import com.squareup.workflow1.BaseRenderContext +import com.squareup.workflow1.Snapshot import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.Workflow import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.compose.SampleComposeWorkflow.Rendering +import kotlinx.coroutines.flow.StateFlow /** - * A [Workflow]-like interface that participates in a workflow tree via its [Rendering] composable. + * A [Workflow]-like interface that participates in a workflow tree via its [produceRendering] + * composable. See the docs on [produceRendering] for more information on writing composable + * workflows. + * + * @sample SampleComposeWorkflow */ @Stable -public interface ComposeWorkflow< +public abstract class ComposeWorkflow< in PropsT, out OutputT, out RenderingT - > { + > : Workflow { /** * The main composable of this workflow that consumes some [props] from its parent and may emit - * an output via [emitOutput]. + * an output via [emitOutput]. Equivalent to [StatefulWorkflow.render]. * - * Equivalent to [StatefulWorkflow.render]. + * To render child workflows (composable or otherwise) from this method, call [renderWorkflow]. + * + * Any compose snapshot state that is read in this method or any methods it calls, that is later + * changed, will trigger a re-render of the workflow tree. See + * [BaseRenderContext.renderComposable] for more details on how composition is tied to the + * workflow lifecycle. + * + * @param props The [PropsT] value passed in from the parent workflow. + * @param emitOutput A function that can be called to emit an [OutputT] value to the parent + * workflow. Calling this method is analogous to sending an action to + * [BaseRenderContext.actionSink] that calls + * [setOutput][com.squareup.workflow1.WorkflowAction.Updater.setOutput]. If this function is + * called from the `onOutput` callback of a [renderWorkflow], then it is equivalent to returning + * an action from [BaseRenderContext.renderChild]'s `handler` parameter. + * + * @sample SampleComposeWorkflow.produceRendering */ @WorkflowComposable @Composable - fun Rendering( + protected abstract fun produceRendering( props: PropsT, emitOutput: (OutputT) -> Unit ): RenderingT -} -fun < - PropsT, StateT, OutputT, - ChildPropsT, ChildOutputT, ChildRenderingT - > BaseRenderContext.renderChild( - child: ComposeWorkflow, - props: ChildPropsT, - key: String = "", - handler: (ChildOutputT) -> WorkflowAction -): ChildRenderingT = renderComposable(key = key) { - // Explicitly remember the output function since we know that actionSink is stable even though - // Compose might not know that. - val emitOutput: (ChildOutputT) -> Unit = remember(actionSink) { - { output -> - val action = handler(output) - actionSink.send(action) + /** + * Render this workflow as a child of another [WorkflowComposable], ensuring that the workflow's + * [produceRendering] method is a separate recompose scope from the caller. + */ + @Composable + internal fun renderWithRecomposeBoundary( + props: PropsT, + onOutput: ((OutputT) -> Unit)? + ): RenderingT { + // Since this function returns a value, it can't restart without also restarting its parent. + // IsolateRecomposeScope allows the subtree to restart and only restarts us if the rendering + // value actually changed. + val renderingState = remember { mutableStateOf(null) } + RecomposeScopeIsolator( + props = props, + onOutput = onOutput, + result = renderingState + ) + + // The value is guaranteed to have been set at least once by RecomposeScopeIsolator so this cast + // will never fail. Note we can't use !! since RenderingT itself might nullable, so null is + // still a potentially valid rendering value. + @Suppress("UNCHECKED_CAST") + return renderingState.value as RenderingT + } + + /** + * Creates an isolated recompose scope that separates a non-restartable caller ([render]) from + * a non-restartable function call ([produceRendering]). This is accomplished simply by this + * function having a [Unit] return type and being not inline. + * + * **It MUST have a [Unit] return type to do its job.** + */ + @Composable + private fun RecomposeScopeIsolator( + props: PropsT, + onOutput: ((OutputT) -> Unit)?, + result: MutableState, + ) { + result.value = produceRendering(props, onOutput ?: {}) + } + + private var statefulImplCache: ComposeWorkflowWrapper? = null + final override fun asStatefulWorkflow(): StatefulWorkflow = + statefulImplCache ?: ComposeWorkflowWrapper().also { statefulImplCache = it } + + /** + * Exposes this [ComposeWorkflow] as a [StatefulWorkflow]. + */ + private inner class ComposeWorkflowWrapper : + StatefulWorkflow() { + + override fun initialState( + props: PropsT, + snapshot: Snapshot? + ) { + // Noop + } + + override fun render( + renderProps: PropsT, + renderState: Unit, + context: RenderContext + ): RenderingT = context.renderComposable { + // Explicitly remember the output function since we know that actionSink is stable even though + // Compose might not know that. + val emitOutput: (OutputT) -> Unit = remember(context.actionSink) { + { output -> context.actionSink.send(OutputAction(output)) } + } + + // Since we're composing directly from renderComposable, we don't need to isolate the + // recompose boundary again. This root composable is already a recompose boundary, and we + // don't need to create a redundant rendering state holder. + return@renderComposable produceRendering( + props = renderProps, + emitOutput = emitOutput + ) + } + + override fun snapshotState(state: Unit): Snapshot? = null + + private inner class OutputAction( + private val output: OutputT + ) : WorkflowAction() { + override fun Updater.apply() { + setOutput(output) + } } } - child.Rendering( - props = props, - emitOutput = emitOutput +} + +private class SampleComposeWorkflow +// In real code, this constructor would probably be injected by Dagger or something. +constructor( + private val injectedService: Service +) : ComposeWorkflow< + /* PropsT */ String, + /* OutputT */ String, + /* RenderingT */ Rendering + >() { + + // In real code, this would not be defined in the workflow itself but somewhere else in the + // codebase. + interface Service { + val values: StateFlow + } + + data class Rendering( + val label: String, + val onClick: () -> Unit ) + + @Composable + override fun produceRendering( + props: String, + emitOutput: (String) -> Unit + ): Rendering { + // ComposeWorkflows use native compose idioms to manage state. + var clickCount by remember { mutableIntStateOf(0) } + + // They also use native compose idioms to work with Flows and perform effects. + val serviceValue by injectedService.values.collectAsState() + + return Rendering( + // Reading clickCount and serviceValue here mean that when those values are changed, it will + // trigger a render pass in the hosting workflow tree, which will recompose this method. + label = "props=$props, clickCount=$clickCount, serviceValue=$serviceValue", + onClick = { + // Instead of using WorkflowAction's state property, you can just update snapshot state + // objects directly. + clickCount++ + + // This is equivalent to calling setOutput from a WorkflowAction. + emitOutput("clicked!") + } + ) + } } diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposables.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposables.kt index 9986f88df..6adf7bd11 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposables.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposables.kt @@ -1,98 +1,65 @@ package com.squareup.workflow1.compose import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import com.squareup.workflow1.BaseRenderContext import com.squareup.workflow1.Workflow /** - * Renders a child [Workflow] from any [WorkflowComposable] (e.g. a [ComposeWorkflow.Rendering] or - * [BaseRenderContext.renderComposable]) and returns its rendering. + * Renders a child [Workflow] from any [WorkflowComposable] (e.g. a + * [ComposeWorkflow.produceRendering] or [BaseRenderContext.renderComposable]) and returns its + * rendering. + * + * This method supports rendering any [Workflow] type, including [ComposeWorkflow]s. If [workflow] + * is a [ComposeWorkflow] then it is composed directly without a detour to the traditional workflow + * system. * * @param onOutput An optional function that, if non-null, will be called when the child emits an * output. If null, the child's outputs will be ignored. */ @WorkflowComposable @Composable -fun renderChild( +fun renderWorkflow( workflow: Workflow, props: ChildPropsT, onOutput: ((ChildOutputT) -> Unit)? -): ChildRenderingT { +): ChildRenderingT = + if (workflow is ComposeWorkflow) { + // Don't need to jump out into non-workflow world if the workflow is already composable. + workflow.renderWithRecomposeBoundary(props, onOutput) + } else { val host = LocalWorkflowCompositionHost.current - return host.renderChild(workflow, props, onOutput) + host.renderChild(workflow, props, onOutput) } /** - * Renders a child [Workflow] from any [WorkflowComposable] (e.g. a [ComposeWorkflow.Rendering] or - * [BaseRenderContext.renderComposable]) and returns its rendering. - * - * @param onOutput An optional function that, if non-null, will be called when the child emits an - * output. If null, the child's outputs will be ignored. + * Renders a child [Workflow] that has no output (`OutputT` is [Nothing]). + * For more documentation see [renderWorkflow]. */ @WorkflowComposable @Composable -inline fun renderChild( +inline fun renderWorkflow( workflow: Workflow, props: ChildPropsT, -): ChildRenderingT = renderChild(workflow, props, onOutput = null) +): ChildRenderingT = renderWorkflow(workflow, props, onOutput = null) /** - * Renders a child [Workflow] from any [WorkflowComposable] (e.g. a [ComposeWorkflow.Rendering] or - * [BaseRenderContext.renderComposable]) and returns its rendering. - * - * @param onOutput An optional function that, if non-null, will be called when the child emits an - * output. If null, the child's outputs will be ignored. + * Renders a child [Workflow] that has no props (`PropsT` is [Unit]). + * For more documentation see [renderWorkflow]. */ @WorkflowComposable @Composable -inline fun renderChild( +inline fun renderWorkflow( workflow: Workflow, noinline onOutput: ((ChildOutputT) -> Unit)? -): ChildRenderingT = renderChild(workflow, props = Unit, onOutput) +): ChildRenderingT = renderWorkflow(workflow, props = Unit, onOutput) /** - * Renders a child [Workflow] from any [WorkflowComposable] (e.g. a [ComposeWorkflow.Rendering] or - * [BaseRenderContext.renderComposable]) and returns its rendering. - * - * @param onOutput An optional function that, if non-null, will be called when the child emits an - * output. If null, the child's outputs will be ignored. + * Renders a child [Workflow] that has no props or output (`PropsT` is [Unit], `OutputT` is + * [Nothing]). + * For more documentation see [renderWorkflow]. */ @WorkflowComposable @Composable -inline fun renderChild( +inline fun renderWorkflow( workflow: Workflow, -): ChildRenderingT = renderChild(workflow, Unit, onOutput = null) - -@WorkflowComposable -@Composable -fun renderChild( - workflow: ComposeWorkflow, - props: ChildPropsT, - handler: ((ChildOutputT) -> Unit)? -): ChildRenderingT { - val childRendering = remember { mutableStateOf(null) } - // Since this function returns a value, it can't restart without also restarting its parent. - // IsolateRecomposeScope allows the subtree to restart and only restarts us if the rendering value - // actually changed. - RecomposeScopeIsolator( - child = workflow, - props = props, - handler = handler, - result = childRendering - ) - @Suppress("UNCHECKED_CAST") - return childRendering.value as ChildRenderingT -} - -@Composable -private fun RecomposeScopeIsolator( - child: ComposeWorkflow, - props: PropsT, - handler: ((OutputT) -> Unit)?, - result: MutableState, -) { - result.value = child.Rendering(props, handler ?: {}) -} +): ChildRenderingT = renderWorkflow(workflow, Unit, onOutput = null) diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowCompositionHost.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowCompositionHost.kt index 70a8c0444..e0b2e258b 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowCompositionHost.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowCompositionHost.kt @@ -21,8 +21,9 @@ public interface WorkflowCompositionHost { /** * Renders a child [Workflow] and returns its rendering. See the top-level composable - * [com.squareup.workflow1.compose.renderChild] for main documentation. + * [com.squareup.workflow1.compose.renderWorkflow] for main documentation. */ + @WorkflowComposable @Composable public fun renderChild( workflow: Workflow,