Skip to content

Commit 7bbfa7f

Browse files
made the API more elegant, lots more docs
1 parent 2abe340 commit 7bbfa7f

File tree

4 files changed

+221
-89
lines changed

4 files changed

+221
-89
lines changed

workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ package com.squareup.workflow1
1111

1212
import androidx.compose.runtime.Composable
1313
import com.squareup.workflow1.WorkflowAction.Companion.noAction
14+
import com.squareup.workflow1.compose.ComposeWorkflow
1415
import com.squareup.workflow1.compose.WorkflowComposable
16+
import com.squareup.workflow1.compose.renderWorkflow
1517
import kotlinx.coroutines.CoroutineScope
1618
import kotlin.jvm.JvmMultifileClass
1719
import kotlin.jvm.JvmName
@@ -89,10 +91,13 @@ public interface BaseRenderContext<out PropsT, StateT, in OutputT> {
8991

9092
/**
9193
* Synchronously composes a [content] function and returns its rendering. Whenever [content] is
92-
* invalidated, this workflow will be re-rendered and the [content] recomposed to return its new
93-
* value.
94+
* invalidated (i.e. a compose snapshot state object is changed that was previously read by
95+
* [content] or any functions it calls), this workflow will be re-rendered and the relevant
96+
* composables will be recomposed.
9497
*
95-
* @see com.squareup.workflow1.compose.ComposeWorkflow
98+
* To render child workflows from this method, call [renderWorkflow].
99+
*
100+
* @see ComposeWorkflow
96101
*/
97102
public fun <ChildRenderingT> renderComposable(
98103
key: String = "",
Lines changed: 183 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,214 @@
11
package com.squareup.workflow1.compose
22

33
import androidx.compose.runtime.Composable
4+
import androidx.compose.runtime.MutableState
45
import androidx.compose.runtime.Stable
6+
import androidx.compose.runtime.collectAsState
7+
import androidx.compose.runtime.getValue
8+
import androidx.compose.runtime.mutableIntStateOf
9+
import androidx.compose.runtime.mutableStateOf
510
import androidx.compose.runtime.remember
11+
import androidx.compose.runtime.setValue
612
import com.squareup.workflow1.BaseRenderContext
13+
import com.squareup.workflow1.Snapshot
714
import com.squareup.workflow1.StatefulWorkflow
815
import com.squareup.workflow1.Workflow
916
import com.squareup.workflow1.WorkflowAction
17+
import com.squareup.workflow1.compose.SampleComposeWorkflow.Rendering
18+
import kotlinx.coroutines.flow.StateFlow
1019

1120
/**
12-
* A [Workflow]-like interface that participates in a workflow tree via its [Rendering] composable.
21+
* A [Workflow]-like interface that participates in a workflow tree via its [produceRendering]
22+
* composable. See the docs on [produceRendering] for more information on writing composable
23+
* workflows.
24+
*
25+
* @sample SampleComposeWorkflow
1326
*/
1427
@Stable
15-
public interface ComposeWorkflow<
28+
public abstract class ComposeWorkflow<
1629
in PropsT,
1730
out OutputT,
1831
out RenderingT
19-
> {
32+
> : Workflow<PropsT, OutputT, RenderingT> {
2033

2134
/**
2235
* The main composable of this workflow that consumes some [props] from its parent and may emit
23-
* an output via [emitOutput].
36+
* an output via [emitOutput]. Equivalent to [StatefulWorkflow.render].
2437
*
25-
* Equivalent to [StatefulWorkflow.render].
38+
* To render child workflows (composable or otherwise) from this method, call [renderWorkflow].
39+
*
40+
* Any compose snapshot state that is read in this method or any methods it calls, that is later
41+
* changed, will trigger a re-render of the workflow tree. See
42+
* [BaseRenderContext.renderComposable] for more details on how composition is tied to the
43+
* workflow lifecycle.
44+
*
45+
* @param props The [PropsT] value passed in from the parent workflow.
46+
* @param emitOutput A function that can be called to emit an [OutputT] value to the parent
47+
* workflow. Calling this method is analogous to sending an action to
48+
* [BaseRenderContext.actionSink] that calls
49+
* [setOutput][com.squareup.workflow1.WorkflowAction.Updater.setOutput]. If this function is
50+
* called from the `onOutput` callback of a [renderWorkflow], then it is equivalent to returning
51+
* an action from [BaseRenderContext.renderChild]'s `handler` parameter.
52+
*
53+
* @sample SampleComposeWorkflow.produceRendering
2654
*/
2755
@WorkflowComposable
2856
@Composable
29-
fun Rendering(
57+
protected abstract fun produceRendering(
3058
props: PropsT,
3159
emitOutput: (OutputT) -> Unit
3260
): RenderingT
33-
}
3461

35-
fun <
36-
PropsT, StateT, OutputT,
37-
ChildPropsT, ChildOutputT, ChildRenderingT
38-
> BaseRenderContext<PropsT, StateT, OutputT>.renderChild(
39-
child: ComposeWorkflow<ChildPropsT, ChildOutputT, ChildRenderingT>,
40-
props: ChildPropsT,
41-
key: String = "",
42-
handler: (ChildOutputT) -> WorkflowAction<PropsT, StateT, OutputT>
43-
): ChildRenderingT = renderComposable(key = key) {
44-
// Explicitly remember the output function since we know that actionSink is stable even though
45-
// Compose might not know that.
46-
val emitOutput: (ChildOutputT) -> Unit = remember(actionSink) {
47-
{ output ->
48-
val action = handler(output)
49-
actionSink.send(action)
62+
/**
63+
* Render this workflow as a child of another [WorkflowComposable], ensuring that the workflow's
64+
* [produceRendering] method is a separate recompose scope from the caller.
65+
*/
66+
@Composable
67+
internal fun renderWithRecomposeBoundary(
68+
props: PropsT,
69+
onOutput: ((OutputT) -> Unit)?
70+
): RenderingT {
71+
// Since this function returns a value, it can't restart without also restarting its parent.
72+
// IsolateRecomposeScope allows the subtree to restart and only restarts us if the rendering
73+
// value actually changed.
74+
val renderingState = remember { mutableStateOf<RenderingT?>(null) }
75+
RecomposeScopeIsolator(
76+
props = props,
77+
onOutput = onOutput,
78+
result = renderingState
79+
)
80+
81+
// The value is guaranteed to have been set at least once by RecomposeScopeIsolator so this cast
82+
// will never fail. Note we can't use !! since RenderingT itself might nullable, so null is
83+
// still a potentially valid rendering value.
84+
@Suppress("UNCHECKED_CAST")
85+
return renderingState.value as RenderingT
86+
}
87+
88+
/**
89+
* Creates an isolated recompose scope that separates a non-restartable caller ([render]) from
90+
* a non-restartable function call ([produceRendering]). This is accomplished simply by this
91+
* function having a [Unit] return type and being not inline.
92+
*
93+
* **It MUST have a [Unit] return type to do its job.**
94+
*/
95+
@Composable
96+
private fun RecomposeScopeIsolator(
97+
props: PropsT,
98+
onOutput: ((OutputT) -> Unit)?,
99+
result: MutableState<RenderingT?>,
100+
) {
101+
result.value = produceRendering(props, onOutput ?: {})
102+
}
103+
104+
private var statefulImplCache: ComposeWorkflowWrapper? = null
105+
final override fun asStatefulWorkflow(): StatefulWorkflow<PropsT, *, OutputT, RenderingT> =
106+
statefulImplCache ?: ComposeWorkflowWrapper().also { statefulImplCache = it }
107+
108+
/**
109+
* Exposes this [ComposeWorkflow] as a [StatefulWorkflow].
110+
*/
111+
private inner class ComposeWorkflowWrapper :
112+
StatefulWorkflow<PropsT, Unit, OutputT, RenderingT>() {
113+
114+
override fun initialState(
115+
props: PropsT,
116+
snapshot: Snapshot?
117+
) {
118+
// Noop
119+
}
120+
121+
override fun render(
122+
renderProps: PropsT,
123+
renderState: Unit,
124+
context: RenderContext
125+
): RenderingT = context.renderComposable {
126+
// Explicitly remember the output function since we know that actionSink is stable even though
127+
// Compose might not know that.
128+
val emitOutput: (OutputT) -> Unit = remember(context.actionSink) {
129+
{ output -> context.actionSink.send(OutputAction(output)) }
130+
}
131+
132+
// Since we're composing directly from renderComposable, we don't need to isolate the
133+
// recompose boundary again. This root composable is already a recompose boundary, and we
134+
// don't need to create a redundant rendering state holder.
135+
return@renderComposable produceRendering(
136+
props = renderProps,
137+
emitOutput = emitOutput
138+
)
50139
}
140+
141+
override fun snapshotState(state: Unit): Snapshot? = null
142+
143+
private inner class OutputAction(
144+
private val output: OutputT
145+
) : WorkflowAction<PropsT, Unit, OutputT>() {
146+
override fun Updater.apply() {
147+
setOutput(output)
148+
}
149+
}
150+
}
151+
}
152+
153+
private class SampleComposeWorkflow
154+
// In real code, this constructor would probably be injected by Dagger or something.
155+
constructor(
156+
private val injectedService: Service,
157+
private val child: Workflow<String, String, String>
158+
) : ComposeWorkflow<
159+
/* PropsT */ String,
160+
/* OutputT */ String,
161+
/* RenderingT */ Rendering
162+
>() {
163+
164+
// In real code, this would not be defined in the workflow itself but somewhere else in the
165+
// codebase.
166+
interface Service {
167+
val values: StateFlow<String>
51168
}
52-
child.Rendering(
53-
props = props,
54-
emitOutput = emitOutput
169+
170+
data class Rendering(
171+
val label: String,
172+
val onClick: () -> Unit
55173
)
174+
175+
@Composable
176+
override fun produceRendering(
177+
props: String,
178+
emitOutput: (String) -> Unit
179+
): Rendering {
180+
// ComposeWorkflows use native compose idioms to manage state.
181+
var clickCount by remember { mutableIntStateOf(0) }
182+
183+
// They also use native compose idioms to work with Flows and perform effects.
184+
val serviceValue by injectedService.values.collectAsState()
185+
186+
// And they can render child workflows, just like traditional workflows. This is equivalent to
187+
// calling BaseRenderContext.renderChild().
188+
val childRendering = renderWorkflow(
189+
workflow = child,
190+
props = "child props",
191+
// This is equivalent to the handler parameter on renderChild().
192+
onOutput = {
193+
emitOutput("child emitted output: $it")
194+
}
195+
)
196+
197+
return Rendering(
198+
// Reading clickCount and serviceValue here mean that when those values are changed, it will
199+
// trigger a render pass in the hosting workflow tree, which will recompose this method.
200+
label = "props=$props, " +
201+
"clickCount=$clickCount, " +
202+
"serviceValue=$serviceValue, " +
203+
"childRendering=$childRendering",
204+
onClick = {
205+
// Instead of using WorkflowAction's state property, you can just update snapshot state
206+
// objects directly.
207+
clickCount++
208+
209+
// This is equivalent to calling setOutput from a WorkflowAction.
210+
emitOutput("clicked!")
211+
}
212+
)
213+
}
56214
}
Lines changed: 28 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,98 +1,66 @@
11
package com.squareup.workflow1.compose
22

33
import androidx.compose.runtime.Composable
4-
import androidx.compose.runtime.MutableState
5-
import androidx.compose.runtime.mutableStateOf
6-
import androidx.compose.runtime.remember
74
import com.squareup.workflow1.BaseRenderContext
85
import com.squareup.workflow1.Workflow
96

107
/**
11-
* Renders a child [Workflow] from any [WorkflowComposable] (e.g. a [ComposeWorkflow.Rendering] or
12-
* [BaseRenderContext.renderComposable]) and returns its rendering.
8+
* Renders a child [Workflow] from any [WorkflowComposable] (e.g. a
9+
* [ComposeWorkflow.produceRendering] or [BaseRenderContext.renderComposable]) and returns its
10+
* rendering.
11+
*
12+
* This method supports rendering any [Workflow] type, including [ComposeWorkflow]s. If [workflow]
13+
* is a [ComposeWorkflow] then it is composed directly without a detour to the traditional workflow
14+
* system.
1315
*
1416
* @param onOutput An optional function that, if non-null, will be called when the child emits an
1517
* output. If null, the child's outputs will be ignored.
1618
*/
19+
// TODO should these be extension functions?
1720
@WorkflowComposable
1821
@Composable
19-
fun <ChildPropsT, ChildOutputT, ChildRenderingT> renderChild(
22+
fun <ChildPropsT, ChildOutputT, ChildRenderingT> renderWorkflow(
2023
workflow: Workflow<ChildPropsT, ChildOutputT, ChildRenderingT>,
2124
props: ChildPropsT,
2225
onOutput: ((ChildOutputT) -> Unit)?
23-
): ChildRenderingT {
26+
): ChildRenderingT =
27+
if (workflow is ComposeWorkflow) {
28+
// Don't need to jump out into non-workflow world if the workflow is already composable.
29+
workflow.renderWithRecomposeBoundary(props, onOutput)
30+
} else {
2431
val host = LocalWorkflowCompositionHost.current
25-
return host.renderChild(workflow, props, onOutput)
32+
host.renderChild(workflow, props, onOutput)
2633
}
2734

2835
/**
29-
* Renders a child [Workflow] from any [WorkflowComposable] (e.g. a [ComposeWorkflow.Rendering] or
30-
* [BaseRenderContext.renderComposable]) and returns its rendering.
31-
*
32-
* @param onOutput An optional function that, if non-null, will be called when the child emits an
33-
* output. If null, the child's outputs will be ignored.
36+
* Renders a child [Workflow] that has no output (`OutputT` is [Nothing]).
37+
* For more documentation see [renderWorkflow].
3438
*/
3539
@WorkflowComposable
3640
@Composable
37-
inline fun <ChildPropsT, ChildRenderingT> renderChild(
41+
inline fun <ChildPropsT, ChildRenderingT> renderWorkflow(
3842
workflow: Workflow<ChildPropsT, Nothing, ChildRenderingT>,
3943
props: ChildPropsT,
40-
): ChildRenderingT = renderChild(workflow, props, onOutput = null)
44+
): ChildRenderingT = renderWorkflow(workflow, props, onOutput = null)
4145

4246
/**
43-
* Renders a child [Workflow] from any [WorkflowComposable] (e.g. a [ComposeWorkflow.Rendering] or
44-
* [BaseRenderContext.renderComposable]) and returns its rendering.
45-
*
46-
* @param onOutput An optional function that, if non-null, will be called when the child emits an
47-
* output. If null, the child's outputs will be ignored.
47+
* Renders a child [Workflow] that has no props (`PropsT` is [Unit]).
48+
* For more documentation see [renderWorkflow].
4849
*/
4950
@WorkflowComposable
5051
@Composable
51-
inline fun <ChildOutputT, ChildRenderingT> renderChild(
52+
inline fun <ChildOutputT, ChildRenderingT> renderWorkflow(
5253
workflow: Workflow<Unit, ChildOutputT, ChildRenderingT>,
5354
noinline onOutput: ((ChildOutputT) -> Unit)?
54-
): ChildRenderingT = renderChild(workflow, props = Unit, onOutput)
55+
): ChildRenderingT = renderWorkflow(workflow, props = Unit, onOutput)
5556

5657
/**
57-
* Renders a child [Workflow] from any [WorkflowComposable] (e.g. a [ComposeWorkflow.Rendering] or
58-
* [BaseRenderContext.renderComposable]) and returns its rendering.
59-
*
60-
* @param onOutput An optional function that, if non-null, will be called when the child emits an
61-
* output. If null, the child's outputs will be ignored.
58+
* Renders a child [Workflow] that has no props or output (`PropsT` is [Unit], `OutputT` is
59+
* [Nothing]).
60+
* For more documentation see [renderWorkflow].
6261
*/
6362
@WorkflowComposable
6463
@Composable
65-
inline fun <ChildRenderingT> renderChild(
64+
inline fun <ChildRenderingT> renderWorkflow(
6665
workflow: Workflow<Unit, Nothing, ChildRenderingT>,
67-
): ChildRenderingT = renderChild(workflow, Unit, onOutput = null)
68-
69-
@WorkflowComposable
70-
@Composable
71-
fun <ChildPropsT, ChildOutputT, ChildRenderingT> renderChild(
72-
workflow: ComposeWorkflow<ChildPropsT, ChildOutputT, ChildRenderingT>,
73-
props: ChildPropsT,
74-
handler: ((ChildOutputT) -> Unit)?
75-
): ChildRenderingT {
76-
val childRendering = remember { mutableStateOf<ChildRenderingT?>(null) }
77-
// Since this function returns a value, it can't restart without also restarting its parent.
78-
// IsolateRecomposeScope allows the subtree to restart and only restarts us if the rendering value
79-
// actually changed.
80-
RecomposeScopeIsolator(
81-
child = workflow,
82-
props = props,
83-
handler = handler,
84-
result = childRendering
85-
)
86-
@Suppress("UNCHECKED_CAST")
87-
return childRendering.value as ChildRenderingT
88-
}
89-
90-
@Composable
91-
private fun <PropsT, OutputT, RenderingT> RecomposeScopeIsolator(
92-
child: ComposeWorkflow<PropsT, OutputT, RenderingT>,
93-
props: PropsT,
94-
handler: ((OutputT) -> Unit)?,
95-
result: MutableState<RenderingT>,
96-
) {
97-
result.value = child.Rendering(props, handler ?: {})
98-
}
66+
): ChildRenderingT = renderWorkflow(workflow, Unit, onOutput = null)

0 commit comments

Comments
 (0)