Skip to content

Commit 40cb5ff

Browse files
Introduce renderComposable.
1 parent cbcb12e commit 40cb5ff

File tree

14 files changed

+406
-5
lines changed

14 files changed

+406
-5
lines changed

samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/NestedRenderingsActivity.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import androidx.compose.ui.graphics.Color
1010
import androidx.lifecycle.SavedStateHandle
1111
import androidx.lifecycle.ViewModel
1212
import androidx.lifecycle.viewModelScope
13+
import com.squareup.workflow1.SimpleLoggingWorkflowInterceptor
1314
import com.squareup.workflow1.WorkflowExperimentalRuntime
1415
import com.squareup.workflow1.config.AndroidRuntimeConfigTools
1516
import com.squareup.workflow1.mapRendering
@@ -47,7 +48,8 @@ class NestedRenderingsActivity : AppCompatActivity() {
4748
workflow = RecursiveWorkflow.mapRendering { it.withEnvironment(viewEnvironment) },
4849
scope = viewModelScope,
4950
savedStateHandle = savedState,
50-
runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig()
51+
runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig(),
52+
interceptors = listOf(SimpleLoggingWorkflowInterceptor())
5153
)
5254
}
5355
}

settings.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ pluginManagement {
77
google()
88
// For binary compatibility validator.
99
maven { url = uri("https://kotlin.bintray.com/kotlinx") }
10+
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
1011
}
1112
includeBuild("build-logic")
1213
}

workflow-core/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import com.squareup.workflow1.buildsrc.iosWithSimulatorArm64
33
plugins {
44
id("kotlin-multiplatform")
55
id("published")
6+
id("org.jetbrains.compose") version "1.7.3"
67
}
78

89
kotlin {
@@ -23,6 +24,8 @@ dependencies {
2324
commonMainApi(libs.kotlinx.coroutines.core)
2425
// For Snapshot.
2526
commonMainApi(libs.squareup.okio)
27+
commonMainApi("org.jetbrains.compose.runtime:runtime:1.7.3")
28+
commonMainApi("org.jetbrains.compose.runtime:runtime-saveable:1.7.3")
2629

2730
commonTestImplementation(libs.kotlinx.atomicfu)
2831
commonTestImplementation(libs.kotlinx.coroutines.test.common)

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88

99
package com.squareup.workflow1
1010

11+
import androidx.compose.runtime.Composable
1112
import com.squareup.workflow1.WorkflowAction.Companion.noAction
13+
import com.squareup.workflow1.compose.WorkflowComposable
1214
import kotlinx.coroutines.CoroutineScope
1315
import kotlin.jvm.JvmMultifileClass
1416
import kotlin.jvm.JvmName
@@ -86,6 +88,27 @@ public interface BaseRenderContext<PropsT, StateT, OutputT> {
8688
handler: (ChildOutputT) -> WorkflowAction<PropsT, StateT, OutputT>
8789
): ChildRenderingT
8890

91+
// /**
92+
// * Synchronously composes a [content] function and returns its rendering. Whenever [content] is
93+
// * invalidated (i.e. a compose snapshot state object is changed that was previously read by
94+
// * [content] or any functions it calls), this workflow will be re-rendered and the relevant
95+
// * composables will be recomposed.
96+
// *
97+
// * The `emitOutput` function passed to [content] should be used to trigger [WorkflowAction]s in
98+
// * this workflow via [handler]. Every invocation of `emitOutput` will result [handler]s action
99+
// * being sent to this context's [actionSink]. However, it's important for the composable never to
100+
// * send to [actionSink] directly because we need to ensure that any state writes the composable
101+
// * does invalidate their composables before sending into the [actionSink].
102+
// */
103+
// @WorkflowExperimentalApi
104+
// public fun <ChildOutputT, ChildRenderingT> renderComposable(
105+
// key: String = "",
106+
// handler: (ChildOutputT) -> WorkflowAction<PropsT, StateT, OutputT>,
107+
// content: @WorkflowComposable @Composable (
108+
// emitOutput: (ChildOutputT) -> Unit
109+
// ) -> ChildRenderingT
110+
// ): ChildRenderingT
111+
89112
/**
90113
* Ensures [sideEffect] is running with the given [key].
91114
*
@@ -209,6 +232,20 @@ public fun <PropsT, StateT, OutputT, ChildRenderingT>
209232
key: String = ""
210233
): ChildRenderingT = renderChild(child, Unit, key) { noAction() }
211234

235+
/**
236+
* TODO
237+
*/
238+
@WorkflowExperimentalApi
239+
public fun <PropsT, StateT, OutputT, ChildRenderingT>
240+
BaseRenderContext<PropsT, StateT, OutputT>.renderComposable(
241+
key: String = "",
242+
content: @WorkflowComposable @Composable () -> ChildRenderingT
243+
): ChildRenderingT = renderComposable<Nothing, ChildRenderingT>(
244+
key = key,
245+
handler = { noAction() },
246+
content = { content() }
247+
)
248+
212249
/**
213250
* Ensures a [LifecycleWorker] is running. Since [worker] can't emit anything,
214251
* it can't trigger any [WorkflowAction]s.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.squareup.workflow1.compose
2+
3+
import androidx.compose.runtime.ComposableTargetMarker
4+
import com.squareup.workflow1.WorkflowExperimentalApi
5+
import kotlin.annotation.AnnotationRetention.BINARY
6+
import kotlin.annotation.AnnotationTarget.FILE
7+
import kotlin.annotation.AnnotationTarget.FUNCTION
8+
import kotlin.annotation.AnnotationTarget.PROPERTY_GETTER
9+
import kotlin.annotation.AnnotationTarget.TYPE
10+
import kotlin.annotation.AnnotationTarget.TYPE_PARAMETER
11+
12+
/**
13+
* An annotation that can be used to mark a composable function as being expected to be use in a
14+
* composable function that is also marked or inferred to be marked as a [WorkflowComposable], i.e.
15+
* that can be called from [BaseRenderContext.renderComposable].
16+
*
17+
* Using this annotation explicitly is rarely necessary as the Compose compiler plugin will infer
18+
* the necessary equivalent annotations automatically. See
19+
* [androidx.compose.runtime.ComposableTarget] for details.
20+
*/
21+
@WorkflowExperimentalApi
22+
@ComposableTargetMarker(description = "Workflow Composable")
23+
@Target(FILE, FUNCTION, PROPERTY_GETTER, TYPE, TYPE_PARAMETER)
24+
@Retention(BINARY)
25+
public annotation class WorkflowComposable

workflow-runtime/build.gradle.kts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import com.squareup.workflow1.buildsrc.iosWithSimulatorArm64
33
plugins {
44
id("kotlin-multiplatform")
55
id("published")
6+
id("org.jetbrains.compose") version "1.7.3"
67
}
78

89
kotlin {
@@ -16,6 +17,13 @@ kotlin {
1617
if (targets == "kmp" || targets == "js") {
1718
js(IR) { browser() }
1819
}
20+
// sourceSets {
21+
// getByName("commonMain") {
22+
// dependencies {
23+
// implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
24+
// }
25+
// }
26+
// }
1927
}
2028

2129
dependencies {

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.squareup.workflow1
22

3+
import androidx.compose.runtime.Composable
34
import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor
45
import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession
56
import kotlinx.coroutines.CoroutineScope
@@ -150,5 +151,15 @@ public open class SimpleLoggingWorkflowInterceptor : WorkflowInterceptor {
150151
}
151152
}
152153
}
154+
155+
override fun <CR> onRenderComposable(
156+
key: String,
157+
content: @Composable () -> CR,
158+
proceed: (key: String, content: @Composable () -> CR) -> CR
159+
): CR = proceed(key) {
160+
logMethod("onRenderComposable", session, "key" to key, "content" to content) {
161+
content()
162+
}
163+
}
153164
}
154165
}

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package com.squareup.workflow1
22

3+
import androidx.compose.runtime.Composable
34
import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor
45
import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession
6+
import com.squareup.workflow1.compose.WorkflowComposable
57
import kotlinx.coroutines.CoroutineScope
68
import kotlinx.coroutines.Job
79
import kotlin.coroutines.CoroutineContext
@@ -322,6 +324,15 @@ public interface WorkflowInterceptor {
322324
calculation: () -> CResult
323325
) -> CResult
324326
): CResult = proceed(key, resultType, inputs, calculation)
327+
328+
public fun <CO, CR> onRenderComposable(
329+
key: String,
330+
content: @Composable (CO) -> CR,
331+
proceed: (
332+
key: String,
333+
content: @Composable (CO) -> CR
334+
) -> CR
335+
): CR = proceed(key, content)
325336
}
326337
}
327338

@@ -459,6 +470,23 @@ private class InterceptedRenderContext<P, S, O>(
459470
}
460471
}
461472

473+
@OptIn(WorkflowExperimentalApi::class)
474+
override fun <ChildOutputT, ChildRenderingT> renderComposable(
475+
key: String,
476+
handler: (ChildOutputT) -> WorkflowAction<P, S, O>,
477+
content: @WorkflowComposable @Composable (emitOutput: (ChildOutputT) -> Unit) -> ChildRenderingT
478+
): ChildRenderingT = interceptor.onRenderComposable(
479+
key = key,
480+
content = content,
481+
proceed = { iKey, iContent ->
482+
baseRenderContext.renderComposable(
483+
key = iKey,
484+
handler = handler,
485+
content = iContent
486+
)
487+
}
488+
)
489+
462490
/**
463491
* In a block with a CoroutineScope receiver, calls to `coroutineContext` bind
464492
* to `CoroutineScope.coroutineContext` instead of `suspend val coroutineContext`.

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1+
@file:OptIn(WorkflowExperimentalApi::class)
2+
13
package com.squareup.workflow1.internal
24

5+
import androidx.compose.runtime.Composable
36
import com.squareup.workflow1.BaseRenderContext
47
import com.squareup.workflow1.RuntimeConfig
58
import com.squareup.workflow1.Sink
69
import com.squareup.workflow1.Workflow
710
import com.squareup.workflow1.WorkflowAction
11+
import com.squareup.workflow1.WorkflowExperimentalApi
812
import com.squareup.workflow1.WorkflowTracer
13+
import com.squareup.workflow1.compose.WorkflowComposable
914
import com.squareup.workflow1.identifier
1015
import kotlinx.coroutines.CoroutineScope
1116
import kotlinx.coroutines.channels.SendChannel
@@ -27,6 +32,12 @@ internal class RealRenderContext<PropsT, StateT, OutputT>(
2732
key: String,
2833
handler: (ChildOutputT) -> WorkflowAction<PropsT, StateT, OutputT>
2934
): ChildRenderingT
35+
36+
fun <ChildOutputT, ChildRenderingT> renderComposable(
37+
key: String,
38+
handler: (ChildOutputT) -> WorkflowAction<PropsT, StateT, OutputT>,
39+
content: @Composable (emitOutput: (ChildOutputT) -> Unit) -> ChildRenderingT
40+
): ChildRenderingT
3041
}
3142

3243
interface SideEffectRunner {
@@ -78,6 +89,15 @@ internal class RealRenderContext<PropsT, StateT, OutputT>(
7889
return renderer.render(child, props, key, handler)
7990
}
8091

92+
override fun <ChildOutputT, ChildRenderingT> renderComposable(
93+
key: String,
94+
handler: (ChildOutputT) -> WorkflowAction<PropsT, StateT, OutputT>,
95+
content: @WorkflowComposable @Composable (emitOutput: (ChildOutputT) -> Unit) -> ChildRenderingT
96+
): ChildRenderingT {
97+
checkNotFrozen()
98+
return renderer.renderComposable(key, handler, content)
99+
}
100+
81101
override fun runningSideEffect(
82102
key: String,
83103
sideEffect: suspend CoroutineScope.() -> Unit
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.squareup.workflow1.internal
2+
3+
import com.squareup.workflow1.WorkflowAction
4+
5+
/**
6+
* This action doesn't actually update state, but it's special-cased inside WorkflowNode to always
7+
* act like it updated state, to force a re-render and thus a recomposition.
8+
*/
9+
internal class RecomposeAction<PropsT, StateT, OutputT> : WorkflowAction<PropsT, StateT, OutputT>() {
10+
override fun Updater.apply() {
11+
// Noop
12+
}
13+
}

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

Lines changed: 47 additions & 2 deletions
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.ActionApplied
45
import com.squareup.workflow1.ActionProcessingResult
56
import com.squareup.workflow1.NoopWorkflowInterceptor
@@ -90,15 +91,18 @@ internal class SubtreeManager<PropsT, StateT, OutputT>(
9091
private val contextForChildren: CoroutineContext,
9192
private val emitActionToParent: (
9293
action: WorkflowAction<PropsT, StateT, OutputT>,
93-
childResult: ActionApplied<*>
94+
childResult: ActionApplied<*>?
9495
) -> ActionProcessingResult,
9596
private val runtimeConfig: RuntimeConfig,
9697
private val workflowTracer: WorkflowTracer?,
9798
private val workflowSession: WorkflowSession? = null,
9899
private val interceptor: WorkflowInterceptor = NoopWorkflowInterceptor,
99-
private val idCounter: IdCounter? = null
100+
private val idCounter: IdCounter? = null,
101+
private val requestRerender: () -> Unit = {},
102+
private val sendActionFromComposable: (WorkflowAction<PropsT,StateT,OutputT>) -> Unit
100103
) : RealRenderContext.Renderer<PropsT, StateT, OutputT> {
101104
private var children = ActiveStagingList<WorkflowChildNode<*, *, *, *, *>>()
105+
private var composables = ActiveStagingList<WorkflowComposableNode<*, *, *, *, *>>()
102106

103107
/**
104108
* Moves all the nodes that have been accumulated in the staging list to the active list, making
@@ -112,6 +116,7 @@ internal class SubtreeManager<PropsT, StateT, OutputT>(
112116
children.commitStaging { child ->
113117
child.workflowNode.cancel()
114118
}
119+
composables.commitStaging(onRemove = WorkflowComposableNode<*, *, *, *, *>::dispose)
115120
// Get rid of any snapshots that weren't applied on the first render pass.
116121
// They belong to children that were saved but not restarted.
117122
snapshotCache = null
@@ -145,6 +150,30 @@ internal class SubtreeManager<PropsT, StateT, OutputT>(
145150
return stagedChild.render(child.asStatefulWorkflow(), props)
146151
}
147152

153+
override fun <ChildOutputT, ChildRenderingT> renderComposable(
154+
key: String,
155+
handler: (ChildOutputT) -> WorkflowAction<PropsT, StateT, OutputT>,
156+
content: @Composable (emitOutput: (ChildOutputT) -> Unit) -> ChildRenderingT
157+
): ChildRenderingT {
158+
// Prevent duplicate workflows with the same key.
159+
workflowTracer.trace("CheckingUniqueMatchesComposable") {
160+
composables.forEachStaging {
161+
require(key != it.workflowKey) {
162+
"Expected keys to be unique for composable: key=\"$key\""
163+
}
164+
}
165+
}
166+
167+
val stagedComposable = workflowTracer.trace("RetainingComposables") {
168+
composables.retainOrCreate(
169+
predicate = { it.workflowKey == key },
170+
create = { createComposableNode<ChildOutputT, ChildRenderingT>(key, handler) }
171+
)
172+
}
173+
stagedComposable.setHandler(handler)
174+
return stagedComposable.render(content)
175+
}
176+
148177
/**
149178
* Uses [selector] to invoke [WorkflowNode.onNextAction] for every running child workflow this instance
150179
* is managing.
@@ -165,6 +194,7 @@ internal class SubtreeManager<PropsT, StateT, OutputT>(
165194
val snapshots = mutableMapOf<WorkflowNodeId, TreeSnapshot>()
166195
children.forEachActive { child ->
167196
val childWorkflow = child.workflow.asStatefulWorkflow()
197+
// Skip children who aren't snapshottable.
168198
snapshots[child.id] = child.workflowNode.snapshot(childWorkflow)
169199
}
170200
return snapshots
@@ -206,4 +236,19 @@ internal class SubtreeManager<PropsT, StateT, OutputT>(
206236
return WorkflowChildNode(child, handler, workflowNode)
207237
.also { node = it }
208238
}
239+
240+
private fun <ChildOutputT, ChildRenderingT> createComposableNode(
241+
key: String,
242+
handler: (ChildOutputT) -> WorkflowAction<PropsT, StateT, OutputT>,
243+
): WorkflowComposableNode<ChildOutputT, ChildRenderingT, PropsT, StateT, OutputT> {
244+
return WorkflowComposableNode<ChildOutputT, ChildRenderingT, PropsT, StateT, OutputT>(
245+
workflowKey = key,
246+
handler = handler,
247+
coroutineContext = contextForChildren,
248+
requestRerender = requestRerender,
249+
sendAction = sendActionFromComposable,
250+
).also {
251+
it.start()
252+
}
253+
}
209254
}

0 commit comments

Comments
 (0)