Skip to content

Commit 2abe340

Browse files
more sketching the impl
1 parent c007b73 commit 2abe340

File tree

11 files changed

+303
-107
lines changed

11 files changed

+303
-107
lines changed

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

+3-35
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,9 @@
1010
package com.squareup.workflow1
1111

1212
import androidx.compose.runtime.Composable
13-
import androidx.compose.runtime.Composition
14-
import androidx.compose.runtime.CompositionLocalProvider
15-
import androidx.compose.runtime.MonotonicFrameClock
16-
import androidx.compose.runtime.Recomposer
17-
import androidx.compose.runtime.mutableStateOf
1813
import com.squareup.workflow1.WorkflowAction.Companion.noAction
14+
import com.squareup.workflow1.compose.WorkflowComposable
1915
import kotlinx.coroutines.CoroutineScope
20-
import kotlinx.coroutines.CoroutineStart
21-
import kotlinx.coroutines.launch
22-
import kotlin.coroutines.EmptyCoroutineContext
2316
import kotlin.jvm.JvmMultifileClass
2417
import kotlin.jvm.JvmName
2518
import kotlin.reflect.KType
@@ -99,37 +92,12 @@ public interface BaseRenderContext<out PropsT, StateT, in OutputT> {
9992
* invalidated, this workflow will be re-rendered and the [content] recomposed to return its new
10093
* value.
10194
*
102-
* @see ComposeWorkflow
95+
* @see com.squareup.workflow1.compose.ComposeWorkflow
10396
*/
10497
public fun <ChildRenderingT> renderComposable(
10598
key: String = "",
10699
content: @WorkflowComposable @Composable () -> ChildRenderingT
107-
): ChildRenderingT {
108-
val renderer: WorkflowComposableRenderer = TODO()
109-
val frameClock: MonotonicFrameClock = TODO()
110-
val coroutineContext = EmptyCoroutineContext + frameClock
111-
val recomposer = Recomposer(coroutineContext)
112-
val composition = Composition(UnitApplier, recomposer)
113-
114-
// TODO I think we need more than a simple UNDISPATCHED start to make this work – we have to
115-
// pump the dispatcher until the composition is finished.
116-
CoroutineScope(coroutineContext).launch(start = CoroutineStart.UNDISPATCHED) {
117-
try {
118-
recomposer.runRecomposeAndApplyChanges()
119-
} finally {
120-
composition.dispose()
121-
}
122-
}
123-
124-
val rendering = mutableStateOf<ChildRenderingT?>(null)
125-
composition.setContent {
126-
CompositionLocalProvider(LocalWorkflowComposableRenderer provides renderer) {
127-
rendering.value = content()
128-
}
129-
}
130-
@Suppress("UNCHECKED_CAST")
131-
return rendering.value as ChildRenderingT
132-
}
100+
): ChildRenderingT
133101

134102
/**
135103
* Ensures [sideEffect] is running with the given [key].

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

-68
This file was deleted.

workflow-core/src/commonMain/kotlin/com/squareup/workflow1/ComposeWorkflow.kt renamed to workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/ComposeWorkflow.kt

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1-
package com.squareup.workflow1
1+
package com.squareup.workflow1.compose
22

33
import androidx.compose.runtime.Composable
44
import androidx.compose.runtime.Stable
55
import androidx.compose.runtime.remember
6+
import com.squareup.workflow1.BaseRenderContext
7+
import com.squareup.workflow1.StatefulWorkflow
8+
import com.squareup.workflow1.Workflow
9+
import com.squareup.workflow1.WorkflowAction
610

711
/**
812
* A [Workflow]-like interface that participates in a workflow tree via its [Rendering] composable.

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package com.squareup.workflow1
1+
package com.squareup.workflow1.compose
22

33
import androidx.compose.runtime.ComposableTargetMarker
44
import kotlin.annotation.AnnotationRetention.BINARY
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package com.squareup.workflow1.compose
2+
3+
import androidx.compose.runtime.Composable
4+
import androidx.compose.runtime.MutableState
5+
import androidx.compose.runtime.mutableStateOf
6+
import androidx.compose.runtime.remember
7+
import com.squareup.workflow1.BaseRenderContext
8+
import com.squareup.workflow1.Workflow
9+
10+
/**
11+
* Renders a child [Workflow] from any [WorkflowComposable] (e.g. a [ComposeWorkflow.Rendering] or
12+
* [BaseRenderContext.renderComposable]) and returns its rendering.
13+
*
14+
* @param onOutput An optional function that, if non-null, will be called when the child emits an
15+
* output. If null, the child's outputs will be ignored.
16+
*/
17+
@WorkflowComposable
18+
@Composable
19+
fun <ChildPropsT, ChildOutputT, ChildRenderingT> renderChild(
20+
workflow: Workflow<ChildPropsT, ChildOutputT, ChildRenderingT>,
21+
props: ChildPropsT,
22+
onOutput: ((ChildOutputT) -> Unit)?
23+
): ChildRenderingT {
24+
val host = LocalWorkflowCompositionHost.current
25+
return host.renderChild(workflow, props, onOutput)
26+
}
27+
28+
/**
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.
34+
*/
35+
@WorkflowComposable
36+
@Composable
37+
inline fun <ChildPropsT, ChildRenderingT> renderChild(
38+
workflow: Workflow<ChildPropsT, Nothing, ChildRenderingT>,
39+
props: ChildPropsT,
40+
): ChildRenderingT = renderChild(workflow, props, onOutput = null)
41+
42+
/**
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.
48+
*/
49+
@WorkflowComposable
50+
@Composable
51+
inline fun <ChildOutputT, ChildRenderingT> renderChild(
52+
workflow: Workflow<Unit, ChildOutputT, ChildRenderingT>,
53+
noinline onOutput: ((ChildOutputT) -> Unit)?
54+
): ChildRenderingT = renderChild(workflow, props = Unit, onOutput)
55+
56+
/**
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.
62+
*/
63+
@WorkflowComposable
64+
@Composable
65+
inline fun <ChildRenderingT> renderChild(
66+
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+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.squareup.workflow1.compose
2+
3+
import androidx.compose.runtime.Composable
4+
import androidx.compose.runtime.ProvidableCompositionLocal
5+
import androidx.compose.runtime.Stable
6+
import androidx.compose.runtime.staticCompositionLocalOf
7+
import com.squareup.workflow1.Workflow
8+
9+
// TODO @InternalWorkflowApi
10+
public val LocalWorkflowCompositionHost: ProvidableCompositionLocal<WorkflowCompositionHost> =
11+
staticCompositionLocalOf { error("No WorkflowCompositionHost provided.") }
12+
13+
/**
14+
* Represents the owner of this [WorkflowComposable] composition.
15+
*/
16+
// TODO move these into a separate, internal-only, implementation-depended-on module to hide from
17+
// consumers by default?
18+
// TODO @InternalWorkflowApi
19+
@Stable
20+
public interface WorkflowCompositionHost {
21+
22+
/**
23+
* Renders a child [Workflow] and returns its rendering. See the top-level composable
24+
* [com.squareup.workflow1.compose.renderChild] for main documentation.
25+
*/
26+
@Composable
27+
public fun <ChildPropsT, ChildOutputT, ChildRenderingT> renderChild(
28+
workflow: Workflow<ChildPropsT, ChildOutputT, ChildRenderingT>,
29+
props: ChildPropsT,
30+
onOutput: ((ChildOutputT) -> Unit)?
31+
): ChildRenderingT
32+
}

workflow-runtime/build.gradle.kts

+7
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ kotlin {
1616
if (targets == "kmp" || targets == "js") {
1717
js(IR) { browser() }
1818
}
19+
sourceSets {
20+
getByName("commonMain") {
21+
dependencies {
22+
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
23+
}
24+
}
25+
}
1926
}
2027

2128
dependencies {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.squareup.workflow1.internal
2+
3+
import androidx.compose.runtime.CompositionContext
4+
import androidx.compose.runtime.RecomposeScope
5+
import androidx.compose.runtime.RememberObserver
6+
import com.squareup.workflow1.WorkflowAction
7+
import com.squareup.workflow1.action
8+
import kotlinx.coroutines.CoroutineScope
9+
10+
internal class ComposedWorkflowChild<ChildOutputT, ParentPropsT, ParentOutputT, ParentRenderingT>(
11+
compositeHashKey: Int,
12+
private val coroutineScope: CoroutineScope,
13+
private val compositionContext: CompositionContext,
14+
private val recomposeScope: RecomposeScope
15+
) : RememberObserver {
16+
val workflowKey: String = "composed-workflow:${compositeHashKey.toString(radix = 16)}"
17+
private var disposed = false
18+
19+
var onOutput: ((ChildOutputT) -> Unit)? = null
20+
val handler: (ChildOutputT) -> WorkflowAction<ParentPropsT, ParentOutputT, ParentRenderingT> =
21+
{ output ->
22+
action(workflowKey) {
23+
// This action is being applied to the composition host workflow, which we don't want to
24+
// update at all.
25+
// The onOutput callback instead will update any compose snapshot state required.
26+
// Technically we could probably invoke it directly from the handler, not wait until the
27+
// queued action is processed, but this ensures consistency with the rest of the workflow
28+
// runtime: the callback won't fire before other callbacks ahead in the queue.
29+
// We check disposed since a previous update may have caused a recomposition that removed
30+
// this child from composition and since it doesn't have its own channel, we have to no-op.
31+
if (!disposed) {
32+
onOutput?.invoke(output)
33+
}
34+
35+
// TODO After invoking callback, send apply notifications and check if composition has any
36+
// invalidations. Iff it does, then mark the current workflow node as needing re-render
37+
// regardless of state change.
38+
}
39+
}
40+
41+
override fun onAbandoned() {
42+
onForgotten()
43+
}
44+
45+
override fun onRemembered() {
46+
}
47+
48+
override fun onForgotten() {
49+
disposed = true
50+
TODO("notify parent that we're gone")
51+
}
52+
}

workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RealRenderContext.kt

+14
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.squareup.workflow1.internal
22

3+
import androidx.compose.runtime.Composable
34
import com.squareup.workflow1.BaseRenderContext
45
import com.squareup.workflow1.Sink
56
import com.squareup.workflow1.Workflow
@@ -22,6 +23,11 @@ internal class RealRenderContext<out PropsT, StateT, OutputT>(
2223
key: String,
2324
handler: (ChildOutputT) -> WorkflowAction<PropsT, StateT, OutputT>
2425
): ChildRenderingT
26+
27+
fun <ChildRenderingT> renderComposable(
28+
key: String,
29+
content: @Composable () -> ChildRenderingT
30+
): ChildRenderingT
2531
}
2632

2733
interface SideEffectRunner {
@@ -62,6 +68,14 @@ internal class RealRenderContext<out PropsT, StateT, OutputT>(
6268
return renderer.render(child, props, key, handler)
6369
}
6470

71+
override fun <ChildRenderingT> renderComposable(
72+
key: String,
73+
content: @Composable () -> ChildRenderingT
74+
): ChildRenderingT {
75+
checkNotFrozen()
76+
return renderer.renderComposable(key, content)
77+
}
78+
6579
override fun runningSideEffect(
6680
key: String,
6781
sideEffect: suspend CoroutineScope.() -> Unit

0 commit comments

Comments
 (0)