Skip to content

Commit 4836cbb

Browse files
made the API more elegant, lots more docs
1 parent 2abe340 commit 4836cbb

File tree

6 files changed

+238
-91
lines changed

6 files changed

+238
-91
lines changed

workflow-core/build.gradle.kts

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ dependencies {
2525
// For Snapshot.
2626
commonMainApi(libs.squareup.okio)
2727
commonMainApi("org.jetbrains.compose.runtime:runtime:1.7.3")
28+
commonMainApi("org.jetbrains.compose.runtime:runtime-saveable:1.7.3")
2829

2930
commonTestImplementation(libs.kotlinx.atomicfu)
3031
commonTestImplementation(libs.kotlinx.coroutines.test.common)

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

+11-3
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@
1010
package com.squareup.workflow1
1111

1212
import androidx.compose.runtime.Composable
13+
import androidx.compose.runtime.saveable.rememberSaveable
1314
import com.squareup.workflow1.WorkflowAction.Companion.noAction
15+
import com.squareup.workflow1.compose.ComposeWorkflow
1416
import com.squareup.workflow1.compose.WorkflowComposable
17+
import com.squareup.workflow1.compose.renderWorkflow
1518
import kotlinx.coroutines.CoroutineScope
1619
import kotlin.jvm.JvmMultifileClass
1720
import kotlin.jvm.JvmName
@@ -89,10 +92,15 @@ public interface BaseRenderContext<out PropsT, StateT, in OutputT> {
8992

9093
/**
9194
* 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.
95+
* invalidated (i.e. a compose snapshot state object is changed that was previously read by
96+
* [content] or any functions it calls), this workflow will be re-rendered and the relevant
97+
* composables will be recomposed.
9498
*
95-
* @see com.squareup.workflow1.compose.ComposeWorkflow
99+
* To render child workflows from this method, call [renderWorkflow].
100+
* Any state saved using Compose's state restoration mechanism (e.g. [rememberSaveable]) will be
101+
* saved and restored using the workflow snapshot mechanism.
102+
*
103+
* @see ComposeWorkflow
96104
*/
97105
public fun <ChildRenderingT> renderComposable(
98106
key: String = "",
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,219 @@
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.saveable.rememberSaveable
12+
import androidx.compose.runtime.setValue
613
import com.squareup.workflow1.BaseRenderContext
14+
import com.squareup.workflow1.Snapshot
715
import com.squareup.workflow1.StatefulWorkflow
816
import com.squareup.workflow1.Workflow
917
import com.squareup.workflow1.WorkflowAction
18+
import com.squareup.workflow1.compose.SampleComposeWorkflow.Rendering
19+
import kotlinx.coroutines.flow.StateFlow
1020

1121
/**
12-
* A [Workflow]-like interface that participates in a workflow tree via its [Rendering] composable.
22+
* A [Workflow]-like interface that participates in a workflow tree via its [produceRendering]
23+
* composable. See the docs on [produceRendering] for more information on writing composable
24+
* workflows.
25+
*
26+
* @sample SampleComposeWorkflow
1327
*/
1428
@Stable
15-
public interface ComposeWorkflow<
29+
public abstract class ComposeWorkflow<
1630
in PropsT,
1731
out OutputT,
1832
out RenderingT
19-
> {
33+
> : Workflow<PropsT, OutputT, RenderingT> {
2034

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

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

0 commit comments

Comments
 (0)