Skip to content

Commit 4996dcf

Browse files
authored
Merge pull request #1349 from square/ray/shiny-compose-samples
Updated ComposeScreen idioms in samples.
2 parents cbcb12e + 8186b47 commit 4996dcf

File tree

7 files changed

+139
-72
lines changed

7 files changed

+139
-72
lines changed

samples/compose-samples/src/androidTest/java/com/squareup/sample/compose/preview/PreviewTest.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ class PreviewTest {
2424
.around(IdlingDispatcherRule)
2525

2626
@Test fun showsPreviewRendering() {
27-
composeRule.onNodeWithText(ContactDetailsRendering::class.java.simpleName, substring = true)
27+
composeRule.onNodeWithText(ContactDetailsScreen::class.java.simpleName, substring = true)
2828
.assertIsDisplayed()
29-
.assertTextContains(previewContactRendering.details.phoneNumber, substring = true)
30-
.assertTextContains(previewContactRendering.details.address, substring = true)
29+
.assertTextContains(previewContactScreen.details.phoneNumber, substring = true)
30+
.assertTextContains(previewContactScreen.details.address, substring = true)
3131
}
3232
}

samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/HelloComposeScreen.kt

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,33 @@ data class HelloComposeScreen(
1616
val onClick: () -> Unit
1717
) : ComposeScreen {
1818
@Composable override fun Content() {
19-
Text(
20-
message,
21-
modifier = Modifier
22-
.clickable(onClick = onClick)
23-
.fillMaxSize()
24-
.wrapContentSize(Alignment.Center)
25-
)
19+
// It is best to keep this method as empty as possible to avoid
20+
// capturing state from stale ComposeScreen instances,
21+
// and to keep from interfering with Compose's stability checks.
22+
// https://developer.android.com/develop/ui/compose/performance/stability
23+
Hello(this)
2624
}
2725
}
2826

27+
/**
28+
* @param modifier even though we use the default [Modifier] when calling
29+
* from [HelloComposeScreen.Content], a habit of accepting this param from the
30+
* Composable itself is handy for screenshot tests and previews.
31+
*/
32+
@Composable
33+
private fun Hello(
34+
screen: HelloComposeScreen,
35+
modifier: Modifier = Modifier
36+
) {
37+
Text(
38+
screen.message,
39+
modifier = modifier
40+
.clickable(onClick = screen.onClick)
41+
.fillMaxSize()
42+
.wrapContentSize(Alignment.Center)
43+
)
44+
}
45+
2946
@Preview(heightDp = 150, showBackground = true)
3047
@Composable
3148
private fun HelloPreview() {

samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingActivity.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ import com.squareup.workflow1.ui.workflowContentView
2020
import kotlinx.coroutines.flow.StateFlow
2121

2222
/**
23-
* A workflow that returns an anonymous `ComposeRendering`.
23+
* A workflow that returns an anonymous
24+
* [ComposeScreen][com.squareup.workflow1.ui.compose.ComposeScreen].
2425
*/
2526
class InlineRenderingActivity : AppCompatActivity() {
2627
override fun onCreate(savedInstanceState: Bundle?) {

samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingWorkflow.kt

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,20 +39,28 @@ object InlineRenderingWorkflow : StatefulWorkflow<Unit, Int, Nothing, Screen>()
3939
): ComposeScreen {
4040
val onClick = context.eventHandler("increment") { state += 1 }
4141
return ComposeScreen {
42-
Box {
43-
Button(onClick = onClick) {
44-
Text("Counter: ")
45-
AnimatedCounter(renderState) { counterValue ->
46-
Text(counterValue.toString())
47-
}
48-
}
49-
}
42+
Content(renderState, onClick)
5043
}
5144
}
5245

5346
override fun snapshotState(state: Int): Snapshot = Snapshot.of(state)
5447
}
5548

49+
@Composable
50+
private fun Content(
51+
count: Int,
52+
onClick: () -> Unit
53+
) {
54+
Box {
55+
Button(onClick = onClick) {
56+
Text("Counter: ")
57+
AnimatedCounter(count) { counterValue ->
58+
Text(counterValue.toString())
59+
}
60+
}
61+
}
62+
}
63+
5664
@Composable
5765
fun InlineRenderingWorkflowRendering() {
5866
val rendering by InlineRenderingWorkflow.renderAsState(

samples/compose-samples/src/main/java/com/squareup/sample/compose/preview/PreviewActivity.kt

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@ class PreviewActivity : AppCompatActivity() {
3333
}
3434
}
3535

36-
val previewContactRendering = ContactRendering(
36+
val previewContactScreen = ContactScreen(
3737
name = "Dim Tonnelly",
38-
details = ContactDetailsRendering(
38+
details = ContactDetailsScreen(
3939
phoneNumber = "555-555-5555",
4040
address = "1234 Apgar Lane"
4141
)
@@ -46,27 +46,30 @@ val previewContactRendering = ContactRendering(
4646
fun PreviewApp() {
4747
MaterialTheme {
4848
Surface {
49-
previewContactRendering.Preview()
49+
previewContactScreen.Preview()
5050
}
5151
}
5252
}
5353

54-
data class ContactRendering(
54+
data class ContactScreen(
5555
val name: String,
56-
val details: ContactDetailsRendering
56+
val details: ContactDetailsScreen
5757
) : ComposeScreen {
5858
@Composable override fun Content() {
59-
ContactDetails(this)
59+
Contact(this)
6060
}
6161
}
6262

63-
data class ContactDetailsRendering(
63+
// Note, not a ComposeScreen and has no view binding of any kind,
64+
// which would normally be a runtime error. We're demonstrating that
65+
// the preview is able to stub out the WorkflowRendering call below.
66+
data class ContactDetailsScreen(
6467
val phoneNumber: String,
6568
val address: String
6669
) : Screen
6770

6871
@Composable
69-
private fun ContactDetails(rendering: ContactRendering) {
72+
private fun Contact(screen: ContactScreen) {
7073
Card(
7174
modifier = Modifier
7275
.padding(8.dp)
@@ -76,9 +79,9 @@ private fun ContactDetails(rendering: ContactRendering) {
7679
modifier = Modifier.padding(16.dp),
7780
verticalArrangement = spacedBy(8.dp),
7881
) {
79-
Text(rendering.name, style = MaterialTheme.typography.body1)
82+
Text(screen.name, style = MaterialTheme.typography.body1)
8083
WorkflowRendering(
81-
rendering = rendering.details,
84+
rendering = screen.details,
8285
modifier = Modifier
8386
.aspectRatio(1f)
8487
.border(0.dp, Color.LightGray)

workflow-ui/compose/README.md

Lines changed: 59 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -157,20 +157,30 @@ renderWorkflowIn(
157157

158158
#### Defining Compose-based UI factories
159159

160-
The most straightforward and common way to tie a `Screen` rendering type to a `@Composable` function is to implement [`ComposeScreen`](https://github.com/square/workflow-kotlin/blob/9bfd5119fabd0a3dfbc25bf7d93e52c7b31bb4cd/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeScreen.kt), the Compose-friendly analog to [`AndroidScreen`](https://github.com/square/workflow-kotlin/blob/v1.12.1-beta06/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidScreen.kt).
160+
The most straightforward and common way to tie a `Screen` rendering type to a `@Composable` function
161+
is to implement [`ComposeScreen`](https://github.com/square/workflow-kotlin/blob/9bfd5119fabd0a3dfbc25bf7d93e52c7b31bb4cd/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeScreen.kt), the Compose-friendly analog to [`AndroidScreen`](https://github.com/square/workflow-kotlin/blob/v1.12.1-beta06/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidScreen.kt).
161162

162163
```kotlin
163-
data class HelloScreen(
164-
val message: String,
165-
val onClick: () -> Unit
166-
) : ComposeScreen {
167-
168-
@Composable override fun Content(viewEnvironment: ViewEnvironment) {
169-
Button(onClick) {
170-
Text(message)
171-
}
172-
}
173-
}
164+
import java.nio.file.WatchEvent.Modifier
165+
166+
data class HelloScreen(
167+
val message: String,
168+
val onClick: () -> Unit
169+
) : ComposeScreen {
170+
@Composable override fun Content() {
171+
Hello(this)
172+
}
173+
}
174+
175+
@Composable
176+
private fun Hello(
177+
screen: HelloScreen,
178+
modifier: Modifier = Modifier
179+
) {
180+
Button(screen.onClick, modifier) {
181+
Text(message)
182+
}
183+
}
174184
```
175185

176186
`ComposeScreen` is a convenience that automates creating a `ScreenComposableFactory` implementation responsible for expressing, say, `HelloScreen` instances by calling `HelloScreen.Content()`.
@@ -186,10 +196,10 @@ data class ContactScreen(
186196
): Screen
187197
```
188198
```kotlin
189-
val contactUiFactory = ScreenComposableFactory<ContactScreen> { rendering, viewEnvironment ->
199+
val contactUiFactory = ScreenComposableFactory<ContactScreen> { screen ->
190200
Column {
191-
Text(rendering.name)
192-
Text(rendering.phoneNumber)
201+
Text(screen.name)
202+
Text(screen.phoneNumber)
193203
}
194204
}
195205

@@ -215,7 +225,6 @@ Aka, `WorkflowViewStub` — Compose Edition! The idea of “view stub” is nons
215225
```kotlin
216226
@Composable fun WorkflowRendering(
217227
rendering: Screen,
218-
viewEnvironment: ViewEnvironment,
219228
modifier: Modifier = Modifier
220229
)
221230
```
@@ -230,13 +239,12 @@ data class ContactScreen(
230239
val details: Screen
231240
): Screen
232241

233-
val contactUiFactory = ScreenComposableFactory<ContactScreen> { rendering, viewEnvironment ->
242+
val contactUiFactory = ScreenComposableFactory<ContactScreen> { screen ->
234243
Column {
235-
Text(rendering.name)
244+
Text(screen.name)
236245

237246
WorkflowRendering(
238-
rendering.details,
239-
viewEnvironment,
247+
screen.details,
240248
Modifier.fillMaxWidth()
241249
)
242250
}
@@ -307,36 +315,51 @@ Here’s an example:
307315
```kotlin
308316
@Composable fun App(rootWorkflow: Workflow<...>) {
309317
var rootProps by remember { mutableStateOf(...) }
310-
val viewEnvironment = ...
311318

312319
val rootRendering by rootWorkflow.renderAsState(
313320
props = rootProps
314321
) { output ->
315322
handleOutput(output)
316323
}
317324

318-
WorkflowRendering(rootRendering, viewEnvironment)
325+
WorkflowRendering(rootRendering)
319326
}
320327
```
321328

322329
----
323330

324331
## Potential risk: Data model
325332

326-
Passing both the rendering and view environment down as parameters through the entire UI tree means that every time a rendering updates, we’ll recompose a lot of composables. This is how Workflow was designed, and because compose does some automatic deduping we’ll automatically avoid recomposing the leaves of the UI for a particular view factory unless the data for those bits of ui actually change. However, any time a leaf rendering changes, we’ll also be recomposing all the parent view factories just in order to propagate that leaf to its composable. That means we’re not able to take advantage of a lot of the other optimizations that compose tries to do both now and potentially in the future.
333+
Passing both rendering down as a parameter through the entire UI tree means that
334+
every time a rendering updates, we’ll recompose a lot of composables.
335+
This is how Workflow was designed, and because compose does some automatic deduping
336+
we’ll automatically avoid recomposing the leaves of the UI for a particular view factory
337+
unless the data for those bits of ui actually change. However, any time a leaf rendering changes,
338+
we’ll also be recomposing all the parent view factories just in order to propagate that leaf to its composable.
339+
That means we’re not able to take advantage of a lot of the other optimizations that compose tries to do both now and potentially in the future.
327340

328341
In other words: “Workflow+views” < “Workflow+compose” < “data model designed specifically for compose + compose”.
329342

330-
It should be straightforward to address this issue for view environments - see the _Alternative design_ section for more information. However, it’s not clear how to solve this for renderings without abandoning our current rendering data model. Today, renderings are an immutable tree of immutable value types that require the entire tree to be recreated any time any single piece of data changes. The reason for this design is that it was the only way to safely propagate changes without adding a bunch of reactive streams to renderings everywhere. The key word in that sentence is “was”: Compose’s snapshot state system makes it possible to expose simple mutable properties and still get change notifications that will ensure that the UI stays up-to-date (For an example of how this system can be used to model complex state systems with dependencies, see [this blog post](https://dev.to/zachklipp/plumbing-data-with-derived-state-in-compose-53ka)).
331-
332-
Workflow could take advantage of this by allowing renderings to actually be mutable, so that when one Workflow deep in the tree wants to change something, it can do so independently and without requiring every rendering above it in the tree to also change. Making such a change to such a fundamental piece of Workflow design could have significant implications on other aspects of Workflow design, and doing so is very far outside the scope of this post.
333-
334-
We want to call this out because it seems like we’ll be losing out on one of Compose’s optimization tricks, but we’re not sure how much of a problem this will turn out to be in the real world. The only performance issues that we’re aware of that we’ve run into with Workflow UI so far are issues with recreating leaf views on every rerender, and that in particular _*is*_ something Compose will automatically win at, even with our current data model.
335-
336-
## Alternative design: Propagating `ViewEnvironment`s through `CompositionLocal`s
337-
338-
You’ll notice that all the APIs described above explicitly pass `ViewEnvironment` objects around. This mirrors how other Workflow UI code works, as well as the Mosaic integration. Compose has the concept of “composition local” — which is similar in spirit to `ViewEnvironment` itself (and SwiftUI’s [`Environment`](https://developer.apple.com/documentation/swiftui/environment)). So why not just pass view environments implicitly through composition locals?
339-
340-
This is what we did at first, but it made the API awkward for testing and other cases. Google advises against using composition locals in most cases for a reason. Because Workflow UI requires a `ViewRegistry` to be provided through the `ViewEnvironment`, there’s no obvious default value — what is the correct behavior when no `ViewEnvironment` local has been specified? Crashing at runtime is not ideal. We could provide an empty `ViewRegistry`, but that’s just another way to crash at runtime a few levels deeper in the call stack. Requiring explicit parameters for `ViewEnvironment` solves all these problems at the expense of a little more typing, and matches how the existing `ViewFactory` APIs work.
341-
342-
On the other hand, providing an API to access individual view environment elements from a composable that hides the actual mechanism and uses composition locals under the hood would let us take much better advantage of Compose’s fine-grained UI updates. We could ensure that, when a view environment changes, only the parts of the UI that actually care about the modified part of the environment are recomposed. However, renderings typically change an order of magnitude more frequently than view environments, so there’s probably not much point solving this problem until we’ve solved the same problem with renderings (discussed above under _Potential risk: Data model_).
343+
It’s not clear how to solve this for renderings without abandoning our current rendering data model.
344+
Today, renderings are an immutable tree of immutable value types
345+
that require the entire tree to be recreated any time any single piece of data changes.
346+
The reason for this design is that it was the only way to safely propagate changes
347+
without adding a bunch of reactive streams to renderings everywhere.
348+
349+
The key word in that sentence is “was”: Compose’s snapshot state system makes it possible
350+
to expose simple mutable properties and still get change notifications that will ensure
351+
that the UI stays up-to-date.
352+
For an example of how this system can be used to model complex state systems with dependencies,
353+
see [this blog post](https://dev.to/zachklipp/plumbing-data-with-derived-state-in-compose-53ka).
354+
355+
Workflow could take advantage of this by allowing renderings to actually be mutable,
356+
so that when one Workflow deep in the tree wants to change something, it can do so independently
357+
and without requiring every rendering above it in the tree to also change.
358+
Making such a change to such a fundamental piece of Workflow design could have significant implications
359+
on other aspects of Workflow design, and doing so is very far outside the scope of this post.
360+
361+
We want to call this out because it seems like we’ll be losing out on one of Compose’s optimization tricks,
362+
but we’re not sure how much of a problem this will turn out to be in the real world.
363+
The only performance issues that we’re aware of that we’ve run into with Workflow UI so far are issues
364+
with recreating leaf views on every rerender, and that in particular _*is*_ something Compose will automatically win at,
365+
even with our current data model.

workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeScreen.kt

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,24 +24,39 @@ import com.squareup.workflow1.ui.ViewRegistry
2424
*
2525
* Note that unlike most workflow view functions, [Content] does not take the rendering as a
2626
* parameter. Instead, the rendering is the receiver, i.e. the current value of `this`.
27+
* Despite this (perhaps unfortunate) convenience, it is best to keep your `Content()`
28+
* function as lean as possible to avoid interfering with Composes
29+
* [stability calculations](https://developer.android.com/develop/ui/compose/performance/stability).
2730
*
2831
* Example:
2932
*
3033
* data class HelloScreen(
3134
* val message: String,
3235
* val onClick: () -> Unit
3336
* ) : ComposeScreen {
37+
* @Composable override fun Content() {
38+
* Hello(this)
39+
* }
40+
* }
3441
*
35-
* @Composable override fun Content(viewEnvironment: ViewEnvironment) {
36-
* Button(onClick) {
37-
* Text(message)
42+
* @Composable
43+
* private fun Hello(
44+
* screen: HelloScreen,
45+
* modifier: Modifier = Modifier
46+
* ) {
47+
* Button(screen.onClick, modifier) {
48+
* Text(screen.message)
3849
* }
39-
* }
4050
* }
4151
*
42-
* This is the simplest way to bridge the gap between your workflows and the UI, but using it
43-
* requires your workflows code to reside in Android modules and depend upon the Compose runtime,
44-
* instead of being pure Kotlin. If this is a problem, or you need more flexibility for any other
52+
* (Note that the example includes a `modifier` parameter that is not used by
53+
* the `HelloScreen` itself. We recommend this approach to simplify
54+
* previews and snapshot tests.)
55+
*
56+
* [ComposeScreen] is the simplest way to bridge the gap between your workflows and the UI,
57+
* but using it requires your workflows code to reside in Android modules
58+
* and depend upon the Compose runtime, instead of being pure Kotlin.
59+
* If this is a problem, or you need more flexibility for any other
4560
* reason, you can use [ViewRegistry] to bind your renderings to [ScreenComposableFactory]
4661
* implementations at runtime.
4762
*

0 commit comments

Comments
 (0)