Skip to content

Commit

Permalink
made the API more elegant, lots more docs
Browse files Browse the repository at this point in the history
  • Loading branch information
zach-klippenstein committed Feb 28, 2025
1 parent 2abe340 commit 9de62ae
Show file tree
Hide file tree
Showing 4 changed files with 205 additions and 89 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -89,10 +91,13 @@ public interface BaseRenderContext<out PropsT, StateT, in OutputT> {

/**
* 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 <ChildRenderingT> renderComposable(
key: String = "",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<PropsT, OutputT, RenderingT> {

/**
* 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<PropsT, StateT, OutputT>.renderChild(
child: ComposeWorkflow<ChildPropsT, ChildOutputT, ChildRenderingT>,
props: ChildPropsT,
key: String = "",
handler: (ChildOutputT) -> WorkflowAction<PropsT, StateT, OutputT>
): 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<RenderingT?>(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<RenderingT?>,
) {
result.value = produceRendering(props, onOutput ?: {})
}

private var statefulImplCache: ComposeWorkflowWrapper? = null
final override fun asStatefulWorkflow(): StatefulWorkflow<PropsT, *, OutputT, RenderingT> =
statefulImplCache ?: ComposeWorkflowWrapper().also { statefulImplCache = it }

/**
* Exposes this [ComposeWorkflow] as a [StatefulWorkflow].
*/
private inner class ComposeWorkflowWrapper :
StatefulWorkflow<PropsT, Unit, OutputT, RenderingT>() {

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<PropsT, Unit, OutputT>() {
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<String>
}

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!")
}
)
}
}
Original file line number Diff line number Diff line change
@@ -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 <ChildPropsT, ChildOutputT, ChildRenderingT> renderChild(
fun <ChildPropsT, ChildOutputT, ChildRenderingT> renderWorkflow(
workflow: Workflow<ChildPropsT, ChildOutputT, ChildRenderingT>,
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 <ChildPropsT, ChildRenderingT> renderChild(
inline fun <ChildPropsT, ChildRenderingT> renderWorkflow(
workflow: Workflow<ChildPropsT, Nothing, ChildRenderingT>,
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 <ChildOutputT, ChildRenderingT> renderChild(
inline fun <ChildOutputT, ChildRenderingT> renderWorkflow(
workflow: Workflow<Unit, ChildOutputT, ChildRenderingT>,
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 <ChildRenderingT> renderChild(
inline fun <ChildRenderingT> renderWorkflow(
workflow: Workflow<Unit, Nothing, ChildRenderingT>,
): ChildRenderingT = renderChild(workflow, Unit, onOutput = null)

@WorkflowComposable
@Composable
fun <ChildPropsT, ChildOutputT, ChildRenderingT> renderChild(
workflow: ComposeWorkflow<ChildPropsT, ChildOutputT, ChildRenderingT>,
props: ChildPropsT,
handler: ((ChildOutputT) -> Unit)?
): ChildRenderingT {
val childRendering = remember { mutableStateOf<ChildRenderingT?>(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 <PropsT, OutputT, RenderingT> RecomposeScopeIsolator(
child: ComposeWorkflow<PropsT, OutputT, RenderingT>,
props: PropsT,
handler: ((OutputT) -> Unit)?,
result: MutableState<RenderingT>,
) {
result.value = child.Rendering(props, handler ?: {})
}
): ChildRenderingT = renderWorkflow(workflow, Unit, onOutput = null)
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ChildPropsT, ChildOutputT, ChildRenderingT> renderChild(
workflow: Workflow<ChildPropsT, ChildOutputT, ChildRenderingT>,
Expand Down

0 comments on commit 9de62ae

Please sign in to comment.