Skip to content
This repository was archived by the owner on Feb 5, 2021. It is now read-only.

Commit 9978a18

Browse files
Make renderAsState public, make WorkflowContainer take a ViewEnvironment.
This narrows the scope of `WorkflowContainer` to be only for showing a workflow's renderings using a `ViewEnvironment`. This elimnates the need to have separate overloads for workflows with rendering type `ComposeRendering`, and is more idiomatic – the old `WorkflowContainer` wasn't a "container" in any Compose-y sense of the word, and it was really weird that it took a content function. Now this composable is actually a container for all workflow-related composables, and so I think the name is actually appropriate (closes #22). To render a workflow without a `ViewEnvironment`, `renderAsState` is now public. This is also much more idiomatic, as it resembles APIs like `Flow<T>.collectAsState` and `Observable.subscribeAsState`.
1 parent 508029e commit 9978a18

File tree

3 files changed

+286
-257
lines changed

3 files changed

+286
-257
lines changed
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
/*
2+
* Copyright 2020 Square Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
@file:Suppress("NOTHING_TO_INLINE")
17+
18+
package com.squareup.workflow.ui.compose
19+
20+
import androidx.annotation.VisibleForTesting
21+
import androidx.compose.Composable
22+
import androidx.compose.Pivotal
23+
import androidx.compose.State
24+
import androidx.compose.onDispose
25+
import androidx.compose.remember
26+
import androidx.compose.state
27+
import androidx.ui.core.CoroutineContextAmbient
28+
import androidx.ui.savedinstancestate.Saver
29+
import androidx.ui.savedinstancestate.SaverScope
30+
import androidx.ui.savedinstancestate.UiSavedStateRegistryAmbient
31+
import androidx.ui.savedinstancestate.savedInstanceState
32+
import com.squareup.workflow.Snapshot
33+
import com.squareup.workflow.Workflow
34+
import com.squareup.workflow.diagnostic.WorkflowDiagnosticListener
35+
import com.squareup.workflow.launchWorkflowIn
36+
import kotlinx.coroutines.CoroutineScope
37+
import kotlinx.coroutines.Dispatchers
38+
import kotlinx.coroutines.cancel
39+
import kotlinx.coroutines.channels.Channel
40+
import kotlinx.coroutines.flow.consumeAsFlow
41+
import kotlinx.coroutines.flow.distinctUntilChanged
42+
import kotlinx.coroutines.flow.launchIn
43+
import kotlinx.coroutines.flow.onEach
44+
import okio.ByteString
45+
import kotlin.coroutines.CoroutineContext
46+
47+
/**
48+
* Runs this [Workflow] as long as this composable is part of the composition, and returns a
49+
* [State] object that will be updated whenever the runtime emits a new [RenderingT].
50+
*
51+
* The workflow runtime will be started when this function is first added to the composition, and
52+
* cancelled when it is removed. The first rendering will be available immediately as soon as this
53+
* function returns, as [State.value]. Composables that read this value will automatically recompose
54+
* whenever the runtime emits a new rendering.
55+
*
56+
* [Snapshot]s from the runtime will automatically be saved to the current
57+
* [UiSavedStateRegistry][androidx.ui.savedinstancestate.UiSavedStateRegistry]. When the runtime is
58+
* started, if a snapshot exists in the registry, it will be used to restore the workflows.
59+
*
60+
* @receiver The [Workflow] to run. If the value of the receiver changes to a different [Workflow]
61+
* while this function is in the composition, the runtime will be restarted with the new workflow.
62+
* @param props The [PropsT] for the root [Workflow]. Changes to this value across different
63+
* compositions will cause the root workflow to re-render with the new props.
64+
* @param onOutput A function that will be executed whenever the root [Workflow] emits an output.
65+
* @param diagnosticListener An optional [WorkflowDiagnosticListener] to start the runtime with. If
66+
* this value changes while this function is in the composition, the runtime will be restarted.
67+
*/
68+
@Composable
69+
fun <PropsT, OutputT : Any, RenderingT> Workflow<PropsT, OutputT, RenderingT>.renderAsState(
70+
props: PropsT,
71+
onOutput: (OutputT) -> Unit,
72+
diagnosticListener: WorkflowDiagnosticListener? = null
73+
): State<RenderingT> {
74+
@Suppress("DEPRECATION")
75+
val coroutineContext = CoroutineContextAmbient.current + Dispatchers.Main.immediate
76+
return renderAsStateImpl(this, props, onOutput, diagnosticListener, coroutineContext)
77+
}
78+
79+
/**
80+
* Runs this [Workflow] as long as this composable is part of the composition, and returns a
81+
* [State] object that will be updated whenever the runtime emits a new [RenderingT].
82+
*
83+
* The workflow runtime will be started when this function is first added to the composition, and
84+
* cancelled when it is removed. The first rendering will be available immediately as soon as this
85+
* function returns, as [State.value]. Composables that read this value will automatically recompose
86+
* whenever the runtime emits a new rendering.
87+
*
88+
* [Snapshot]s from the runtime will automatically be saved to the current
89+
* [UiSavedStateRegistry][androidx.ui.savedinstancestate.UiSavedStateRegistry]. When the runtime is
90+
* started, if a snapshot exists in the registry, it will be used to restore the workflows.
91+
*
92+
* @receiver The [Workflow] to run. If the value of the receiver changes to a different [Workflow]
93+
* while this function is in the composition, the runtime will be restarted with the new workflow.
94+
* @param onOutput A function that will be executed whenever the root [Workflow] emits an output.
95+
* @param diagnosticListener An optional [WorkflowDiagnosticListener] to start the runtime with. If
96+
* this value changes while this function is in the composition, the runtime will be restarted.
97+
*/
98+
@Composable
99+
inline fun <OutputT : Any, RenderingT> Workflow<Unit, OutputT, RenderingT>.renderAsState(
100+
noinline onOutput: (OutputT) -> Unit,
101+
diagnosticListener: WorkflowDiagnosticListener? = null
102+
): State<RenderingT> = renderAsState(Unit, onOutput, diagnosticListener)
103+
104+
/**
105+
* Runs this [Workflow] as long as this composable is part of the composition, and returns a
106+
* [State] object that will be updated whenever the runtime emits a new [RenderingT].
107+
*
108+
* The workflow runtime will be started when this function is first added to the composition, and
109+
* cancelled when it is removed. The first rendering will be available immediately as soon as this
110+
* function returns, as [State.value]. Composables that read this value will automatically recompose
111+
* whenever the runtime emits a new rendering.
112+
*
113+
* [Snapshot]s from the runtime will automatically be saved to the current
114+
* [UiSavedStateRegistry][androidx.ui.savedinstancestate.UiSavedStateRegistry]. When the runtime is
115+
* started, if a snapshot exists in the registry, it will be used to restore the workflows.
116+
*
117+
* @receiver The [Workflow] to run. If the value of the receiver changes to a different [Workflow]
118+
* while this function is in the composition, the runtime will be restarted with the new workflow.
119+
* @param props The [PropsT] for the root [Workflow]. Changes to this value across different
120+
* compositions will cause the root workflow to re-render with the new props.
121+
* @param diagnosticListener An optional [WorkflowDiagnosticListener] to start the runtime with. If
122+
* this value changes while this function is in the composition, the runtime will be restarted.
123+
*/
124+
@Composable
125+
inline fun <PropsT, RenderingT> Workflow<PropsT, Nothing, RenderingT>.renderAsState(
126+
props: PropsT,
127+
diagnosticListener: WorkflowDiagnosticListener? = null
128+
): State<RenderingT> = renderAsState(props, {}, diagnosticListener)
129+
130+
/**
131+
* Runs this [Workflow] as long as this composable is part of the composition, and returns a
132+
* [State] object that will be updated whenever the runtime emits a new [RenderingT].
133+
*
134+
* The workflow runtime will be started when this function is first added to the composition, and
135+
* cancelled when it is removed. The first rendering will be available immediately as soon as this
136+
* function returns, as [State.value]. Composables that read this value will automatically recompose
137+
* whenever the runtime emits a new rendering.
138+
*
139+
* [Snapshot]s from the runtime will automatically be saved to the current
140+
* [UiSavedStateRegistry][androidx.ui.savedinstancestate.UiSavedStateRegistry]. When the runtime is
141+
* started, if a snapshot exists in the registry, it will be used to restore the workflows.
142+
*
143+
* @receiver The [Workflow] to run. If the value of the receiver changes to a different [Workflow]
144+
* while this function is in the composition, the runtime will be restarted with the new workflow.
145+
* @param diagnosticListener An optional [WorkflowDiagnosticListener] to start the runtime with. If
146+
* this value changes while this function is in the composition, the runtime will be restarted.
147+
*/
148+
@Composable
149+
inline fun <RenderingT> Workflow<Unit, Nothing, RenderingT>.renderAsState(
150+
diagnosticListener: WorkflowDiagnosticListener? = null
151+
): State<RenderingT> = renderAsState(Unit, {}, diagnosticListener)
152+
153+
/**
154+
* @param snapshotKey Allows tests to pass in a custom key to use to save/restore the snapshot from
155+
* the [UiSavedStateRegistryAmbient]. If null, will use the default key based on source location.
156+
*/
157+
@Suppress("EXPERIMENTAL_API_USAGE")
158+
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
159+
@Composable internal fun <PropsT, OutputT : Any, RenderingT> renderAsStateImpl(
160+
@Pivotal workflow: Workflow<PropsT, OutputT, RenderingT>,
161+
props: PropsT,
162+
onOutput: (OutputT) -> Unit,
163+
@Pivotal diagnosticListener: WorkflowDiagnosticListener?,
164+
@Pivotal coroutineContext: CoroutineContext,
165+
snapshotKey: String? = null
166+
): State<RenderingT> {
167+
// This can be a StateFlow once coroutines is upgraded to 1.3.6.
168+
val propsChannel = remember { Channel<PropsT>(capacity = Channel.CONFLATED) }
169+
propsChannel.offer(props)
170+
171+
// Need a mutable holder for onOutput so the outputs subscriber created in the onActive block
172+
// will always be able to see the latest value.
173+
val outputCallback = remember { OutputCallback(onOutput) }
174+
outputCallback.onOutput = onOutput
175+
176+
val renderingState = state<RenderingT?> { null }
177+
val snapshotState = savedInstanceState(key = snapshotKey, saver = SnapshotSaver) { null }
178+
179+
// We can't use onActive/on(Pre)Commit because they won't run their callback until after this
180+
// function returns, and we need to run this immediately so we get the rendering synchronously.
181+
val workflowScope = remember {
182+
val coroutineScope = CoroutineScope(coroutineContext + Dispatchers.Main.immediate)
183+
val propsFlow = propsChannel.consumeAsFlow()
184+
.distinctUntilChanged()
185+
186+
launchWorkflowIn(coroutineScope, workflow, propsFlow, snapshotState.value) { session ->
187+
session.diagnosticListener = diagnosticListener
188+
189+
// Don't call onOutput directly, since out captured reference won't be changed if the
190+
// if a different argument is passed to observeWorkflow.
191+
session.outputs.onEach { outputCallback.onOutput(it) }
192+
.launchIn(this)
193+
194+
session.renderingsAndSnapshots
195+
.onEach { (rendering, snapshot) ->
196+
renderingState.value = rendering
197+
snapshotState.value = snapshot
198+
}
199+
.launchIn(this)
200+
}
201+
202+
return@remember coroutineScope
203+
}
204+
205+
onDispose {
206+
workflowScope.cancel()
207+
}
208+
209+
// The value is guaranteed to be set before returning, so this cast is fine.
210+
@Suppress("UNCHECKED_CAST")
211+
return renderingState as State<RenderingT>
212+
}
213+
214+
private object SnapshotSaver : Saver<Snapshot?, ByteArray> {
215+
override fun SaverScope.save(value: Snapshot?): ByteArray {
216+
return value?.bytes?.toByteArray() ?: ByteArray(0)
217+
}
218+
219+
override fun restore(value: ByteArray): Snapshot? {
220+
return value.takeUnless { it.isEmpty() }
221+
?.let { bytes -> Snapshot.of(ByteString.of(*bytes)) }
222+
}
223+
}
224+
225+
private class OutputCallback<OutputT>(var onOutput: (OutputT) -> Unit)

0 commit comments

Comments
 (0)