Skip to content

Commit 409fd96

Browse files
made the API more elegant, lots more docs
1 parent 2abe340 commit 409fd96

File tree

8 files changed

+304
-121
lines changed

8 files changed

+304
-121
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

+12-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,11 +92,17 @@ 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
*/
105+
@WorkflowExperimentalApi
97106
public fun <ChildRenderingT> renderComposable(
98107
key: String = "",
99108
content: @WorkflowComposable @Composable () -> ChildRenderingT
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,224 @@
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.WorkflowExperimentalApi
19+
import com.squareup.workflow1.compose.SampleComposeWorkflow.Rendering
20+
import kotlinx.coroutines.flow.StateFlow
1021

1122
/**
12-
* A [Workflow]-like interface that participates in a workflow tree via its [Rendering] composable.
23+
* A [Workflow]-like interface that participates in a workflow tree via its [produceRendering]
24+
* composable. See the docs on [produceRendering] for more information on writing composable
25+
* workflows.
26+
*
27+
* @sample SampleComposeWorkflow
1328
*/
29+
@WorkflowExperimentalApi
1430
@Stable
15-
public interface ComposeWorkflow<
31+
public abstract class ComposeWorkflow<
1632
in PropsT,
1733
out OutputT,
1834
out RenderingT
19-
> {
35+
> : Workflow<PropsT, OutputT, RenderingT> {
2036

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

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

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

+2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.squareup.workflow1.compose
22

33
import androidx.compose.runtime.ComposableTargetMarker
4+
import com.squareup.workflow1.WorkflowExperimentalApi
45
import kotlin.annotation.AnnotationRetention.BINARY
56
import kotlin.annotation.AnnotationTarget.FILE
67
import kotlin.annotation.AnnotationTarget.FUNCTION
@@ -17,6 +18,7 @@ import kotlin.annotation.AnnotationTarget.TYPE_PARAMETER
1718
* the necessary equivalent annotations automatically. See
1819
* [androidx.compose.runtime.ComposableTarget] for details.
1920
*/
21+
@WorkflowExperimentalApi
2022
@ComposableTargetMarker(description = "Workflow Composable")
2123
@Target(FILE, FUNCTION, PROPERTY_GETTER, TYPE, TYPE_PARAMETER)
2224
@Retention(BINARY)

0 commit comments

Comments
 (0)