Skip to content

Commit a2a6ac6

Browse files
Try harder to get a frame from the recomposer on render, regardless of the underlying dispatcher.
1 parent c327c61 commit a2a6ac6

File tree

9 files changed

+633
-191
lines changed

9 files changed

+633
-191
lines changed

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

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
package com.squareup.sample.compose.nestedrenderings
44

5+
import androidx.compose.animation.Animatable
6+
import androidx.compose.animation.core.FastOutLinearInEasing
7+
import androidx.compose.animation.core.LinearOutSlowInEasing
8+
import androidx.compose.animation.core.keyframes
9+
import androidx.compose.foundation.gestures.awaitEachGesture
10+
import androidx.compose.foundation.gestures.detectTapGestures
11+
import androidx.compose.foundation.interaction.MutableInteractionSource
12+
import androidx.compose.foundation.interaction.PressInteraction
513
import androidx.compose.foundation.layout.Arrangement.SpaceEvenly
614
import androidx.compose.foundation.layout.Column
715
import androidx.compose.foundation.layout.Row
@@ -13,12 +21,17 @@ import androidx.compose.material.Card
1321
import androidx.compose.material.Text
1422
import androidx.compose.runtime.Composable
1523
import androidx.compose.runtime.CompositionLocalProvider
24+
import androidx.compose.runtime.LaunchedEffect
1625
import androidx.compose.runtime.compositionLocalOf
26+
import androidx.compose.runtime.getValue
27+
import androidx.compose.runtime.mutableIntStateOf
1728
import androidx.compose.runtime.remember
29+
import androidx.compose.runtime.setValue
1830
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
1931
import androidx.compose.ui.Modifier
2032
import androidx.compose.ui.graphics.Color
2133
import androidx.compose.ui.graphics.compositeOver
34+
import androidx.compose.ui.input.pointer.pointerInput
2235
import androidx.compose.ui.res.dimensionResource
2336
import androidx.compose.ui.tooling.preview.Preview
2437
import com.squareup.sample.compose.R
@@ -27,6 +40,7 @@ import com.squareup.workflow1.ui.Screen
2740
import com.squareup.workflow1.ui.compose.ScreenComposableFactory
2841
import com.squareup.workflow1.ui.compose.WorkflowRendering
2942
import com.squareup.workflow1.ui.compose.tooling.Preview
43+
import kotlin.time.DurationUnit.MILLISECONDS
3044

3145
/**
3246
* Composition local of [Color] to use as the background color for a [RecursiveComposableFactory].
@@ -44,7 +58,26 @@ val RecursiveComposableFactory = ScreenComposableFactory<Rendering> { rendering
4458
.compositeOver(Color.Black)
4559
}
4660

47-
Card(backgroundColor = color) {
61+
var lastFlashedTrigger by remember { mutableIntStateOf(rendering.flashTrigger) }
62+
val flashAlpha = remember { Animatable(Color(0x00FFFFFF)) }
63+
64+
// Flash the card white when asked.
65+
LaunchedEffect(rendering.flashTrigger) {
66+
if (rendering.flashTrigger != 0) {
67+
lastFlashedTrigger = rendering.flashTrigger
68+
flashAlpha.animateTo(Color(0x00FFFFFF), animationSpec = keyframes {
69+
Color.White at (rendering.flashTime / 7).toInt(MILLISECONDS) using FastOutLinearInEasing
70+
Color(0x00FFFFFF) at rendering.flashTime.toInt(MILLISECONDS) using LinearOutSlowInEasing
71+
})
72+
}
73+
}
74+
75+
Card(
76+
backgroundColor = flashAlpha.value.compositeOver(color),
77+
modifier = Modifier.pointerInput(rendering) {
78+
detectTapGestures(onPress = { rendering.onSelfClicked() })
79+
}
80+
) {
4881
Column(
4982
Modifier
5083
.padding(dimensionResource(R.dimen.recursive_padding))
@@ -76,10 +109,14 @@ fun RecursiveViewFactoryPreview() {
76109
StringRendering("foo"),
77110
Rendering(
78111
children = listOf(StringRendering("bar")),
112+
flashTrigger = 0,
113+
onSelfClicked = {},
79114
onAddChildClicked = {},
80115
onResetClicked = {}
81116
)
82117
),
118+
flashTrigger = 0,
119+
onSelfClicked = {},
83120
onAddChildClicked = {},
84121
onResetClicked = {}
85122
),

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

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
package com.squareup.sample.compose.nestedrenderings
22

33
import androidx.compose.runtime.Composable
4+
import androidx.compose.runtime.DisposableEffect
5+
import androidx.compose.runtime.LaunchedEffect
46
import androidx.compose.runtime.getValue
7+
import androidx.compose.runtime.mutableIntStateOf
58
import androidx.compose.runtime.mutableStateOf
9+
import androidx.compose.runtime.remember
10+
import androidx.compose.runtime.rememberCoroutineScope
611
import androidx.compose.runtime.saveable.rememberSaveable
712
import androidx.compose.runtime.setValue
813
import com.squareup.sample.compose.databinding.LegacyViewBinding
@@ -14,6 +19,12 @@ import com.squareup.workflow1.compose.renderChild
1419
import com.squareup.workflow1.ui.AndroidScreen
1520
import com.squareup.workflow1.ui.Screen
1621
import com.squareup.workflow1.ui.ScreenViewFactory
22+
import kotlinx.coroutines.CoroutineDispatcher
23+
import kotlinx.coroutines.delay
24+
import kotlinx.coroutines.launch
25+
import kotlin.time.Duration
26+
import kotlin.time.Duration.Companion.ZERO
27+
import kotlin.time.Duration.Companion.seconds
1728

1829
/**
1930
* A simple workflow that produces [Rendering]s of zero or more children.
@@ -24,7 +35,7 @@ import com.squareup.workflow1.ui.ScreenViewFactory
2435
* through Composable renderings as well as adapting in both directions.
2536
*/
2637
@OptIn(WorkflowExperimentalApi::class)
27-
object RecursiveWorkflow : ComposeWorkflow<Unit, Nothing, Screen>() {
38+
object RecursiveWorkflow : ComposeWorkflow<Unit, Unit, Screen>() {
2839

2940
/**
3041
* A rendering from a [RecursiveWorkflow].
@@ -35,8 +46,11 @@ object RecursiveWorkflow : ComposeWorkflow<Unit, Nothing, Screen>() {
3546
*/
3647
data class Rendering(
3748
val children: List<Screen>,
38-
val onAddChildClicked: () -> Unit,
39-
val onResetClicked: () -> Unit
49+
val flashTrigger: Int = 0,
50+
val flashTime: Duration = ZERO,
51+
val onSelfClicked: () -> Unit = {},
52+
val onAddChildClicked: () -> Unit = {},
53+
val onResetClicked: () -> Unit = {}
4054
) : Screen
4155

4256
/**
@@ -51,16 +65,43 @@ object RecursiveWorkflow : ComposeWorkflow<Unit, Nothing, Screen>() {
5165
)
5266
}
5367

68+
@OptIn(ExperimentalStdlibApi::class)
5469
@Composable override fun produceRendering(
5570
props: Unit,
56-
emitOutput: (Nothing) -> Unit
71+
emitOutput: (Unit) -> Unit
5772
): Screen {
5873
var children by rememberSaveable { mutableStateOf(0) }
74+
var flashTrigger by remember { mutableIntStateOf(0) }
75+
val coroutineScope = rememberCoroutineScope()
76+
77+
DisposableEffect(Unit) {
78+
println("OMG coroutineScope dispatcher: ${coroutineScope.coroutineContext[CoroutineDispatcher]}")
79+
onDispose {}
80+
}
81+
82+
LaunchedEffect(Unit) {
83+
println("OMG LaunchedEffect dispatcher: ${coroutineScope.coroutineContext[CoroutineDispatcher]}")
84+
}
85+
5986
return Rendering(
6087
children = List(children) { i ->
61-
val child = renderChild(RecursiveWorkflow)
88+
val child = renderChild(RecursiveWorkflow, onOutput = {
89+
// When a child is clicked, cascade the flash up.
90+
coroutineScope.launch {
91+
delay(0.1.seconds)
92+
flashTrigger++
93+
emitOutput(Unit)
94+
}
95+
})
6296
if (i % 2 == 0) child else LegacyRendering(child)
6397
},
98+
flashTrigger = flashTrigger,
99+
flashTime = 0.5.seconds,
100+
// Trigger a cascade of flashes when clicked.
101+
onSelfClicked = {
102+
flashTrigger++
103+
emitOutput(Unit)
104+
},
64105
onAddChildClicked = { children++ },
65106
onResetClicked = { children = 0 }
66107
)

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,8 @@ public open class SimpleLoggingWorkflowInterceptor : WorkflowInterceptor {
8585
props: PropsT,
8686
onOutput: ((OutputT) -> Unit)?
8787
): RenderingT {
88-
logMethod("onRenderChild", session, "workflow" to childWorkflow, "props" to props) {
89-
return childRenderer.renderChild(childWorkflow, props, onOutput = { output ->
88+
return logMethod("onRenderChild", session, "workflow" to childWorkflow, "props" to props) {
89+
childRenderer.renderChild(childWorkflow, props, onOutput = { output ->
9090
logMethod("onOutput", session, "output" to output) {
9191
onOutput?.invoke(output)
9292
}

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

Lines changed: 53 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import androidx.compose.runtime.rememberUpdatedState
1313
import androidx.compose.runtime.saveable.LocalSaveableStateRegistry
1414
import androidx.compose.runtime.saveable.SaveableStateRegistry
1515
import androidx.compose.runtime.setValue
16-
import androidx.compose.runtime.snapshots.Snapshot
1716
import com.squareup.workflow1.ActionApplied
1817
import com.squareup.workflow1.ActionProcessingResult
1918
import com.squareup.workflow1.NoopWorkflowInterceptor
@@ -32,6 +31,7 @@ import com.squareup.workflow1.compose.WorkflowComposableRenderer
3231
import com.squareup.workflow1.identifier
3332
import com.squareup.workflow1.internal.IdCounter
3433
import com.squareup.workflow1.internal.WorkflowNodeId
34+
import com.squareup.workflow1.internal.compose.coroutines.requireSend
3535
import com.squareup.workflow1.internal.createId
3636
import com.squareup.workflow1.workflowSessionToString
3737
import kotlinx.coroutines.CoroutineName
@@ -42,6 +42,8 @@ import kotlinx.coroutines.channels.Channel
4242
import kotlinx.coroutines.selects.SelectBuilder
4343
import kotlin.coroutines.CoroutineContext
4444

45+
private const val OUTPUT_QUEUE_LIMIT = 1_000
46+
4547
@OptIn(WorkflowExperimentalApi::class)
4648
internal class ComposeWorkflowChildNode<PropsT, OutputT, RenderingT>(
4749
override val id: WorkflowNodeId,
@@ -74,7 +76,7 @@ internal class ComposeWorkflowChildNode<PropsT, OutputT, RenderingT>(
7476
private var lastProps by mutableStateOf(initialProps)
7577
private val saveableStateRegistry: SaveableStateRegistry
7678
private var snapshotCache = snapshot?.childTreeSnapshots
77-
private val nodesToSnapshot = mutableVectorOf<ComposeChildNode<*, *, *>>()
79+
private val childNodes = mutableVectorOf<ComposeChildNode<*, *, *>>()
7880

7981
private val outputsChannel = Channel<OutputT>(capacity = OUTPUT_QUEUE_LIMIT)
8082

@@ -94,11 +96,7 @@ internal class ComposeWorkflowChildNode<PropsT, OutputT, RenderingT>(
9496
* Function invoked when [onNextAction] receives an output from [outputsChannel].
9597
*/
9698
private val processOutputFromChannel: (OutputT) -> ActionProcessingResult = { output ->
97-
// Ensure any state updates performed by the output sender gets to invalidate any
98-
// compositions that read them, so we can check hasInvalidations below.
99-
// If no frame has been requested yet this will request a frame. If the dispatcher is
100-
// Main.immediate the frame will be requested synchronously.
101-
Snapshot.sendApplyNotifications()
99+
log("got output from channel: $output")
102100

103101
val applied = ActionApplied(
104102
output = WorkflowOutput(output),
@@ -109,7 +107,10 @@ internal class ComposeWorkflowChildNode<PropsT, OutputT, RenderingT>(
109107
)
110108

111109
// Invoke the parent's handler to propagate the output up the workflow tree.
112-
emitAppliedActionToParent(applied)
110+
log("sending output to parent: $applied")
111+
emitAppliedActionToParent(applied).also {
112+
log("finished sending output to parent, result was: $it")
113+
}
113114
}
114115

115116
init {
@@ -140,6 +141,7 @@ internal class ComposeWorkflowChildNode<PropsT, OutputT, RenderingT>(
140141
workflow: Workflow<PropsT, OutputT, RenderingT>,
141142
props: PropsT
142143
): RenderingT {
144+
log("rendering workflow: props=$props")
143145
workflow as ComposeWorkflow
144146
return withCompositionLocals(
145147
LocalSaveableStateRegistry provides saveableStateRegistry,
@@ -217,7 +219,13 @@ internal class ComposeWorkflowChildNode<PropsT, OutputT, RenderingT>(
217219

218220
@OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class)
219221
override fun onNextAction(selector: SelectBuilder<ActionProcessingResult>): Boolean {
220-
val empty = outputsChannel.isEmpty || outputsChannel.isClosedForReceive
222+
var empty = childNodes.fold(true) { empty, child ->
223+
// Do this separately so the compiler doesn't avoid it if empty is already false.
224+
val childEmpty = child.onNextAction(selector)
225+
empty && childEmpty
226+
}
227+
228+
empty = empty && (outputsChannel.isEmpty || outputsChannel.isClosedForReceive)
221229
with(selector) {
222230
outputsChannel.onReceive(processOutputFromChannel)
223231
}
@@ -285,15 +293,15 @@ internal class ComposeWorkflowChildNode<PropsT, OutputT, RenderingT>(
285293
}
286294

287295
private fun addChildNode(childNode: ComposeChildNode<*, *, *>) {
288-
nodesToSnapshot += childNode
296+
childNodes += childNode
289297
}
290298

291299
private fun removeChildNode(childNode: ComposeChildNode<*, *, *>) {
292-
nodesToSnapshot -= childNode
300+
childNodes -= childNode
293301
}
294302

295303
private fun createChildSnapshots(): Map<WorkflowNodeId, TreeSnapshot> = buildMap {
296-
nodesToSnapshot.forEach { child ->
304+
childNodes.forEach { child ->
297305
put(child.id, child.snapshot())
298306
}
299307
}
@@ -318,19 +326,20 @@ internal class ComposeWorkflowChildNode<PropsT, OutputT, RenderingT>(
318326
appliedActionFromChild: ActionApplied<ChildOutputT>,
319327
onOutput: ((ChildOutputT) -> Unit)?
320328
): ActionProcessingResult {
329+
log("handling child output: $appliedActionFromChild")
321330
val outputFromChild = appliedActionFromChild.output
322-
if (outputFromChild == null || onOutput == null) {
323-
// The child didn't actually emit anything or we don't care, so we don't need to
324-
// propagate anything to the parent. We halt the action cascade by simply returning
325-
// here without calling emitAppliedActionToParent.
326-
//
327-
// NOTE: SubtreeManager has an additional case for PARTIAL_TREE_RENDERING, but we
328-
// can just assume that using ComposeWorkflow at all implies that optimization.
329-
//
330-
// If our child state changed, we need to report that ours did too, as per the
331-
// comment in StatefulWorkflowNode.applyAction.
332-
return appliedActionFromChild.withOutput(null)
333-
}
331+
// if (outputFromChild == null || onOutput == null) {
332+
// // The child didn't actually emit anything or we don't care, so we don't need to
333+
// // propagate anything to the parent. We halt the action cascade by simply returning
334+
// // here without calling emitAppliedActionToParent.
335+
// //
336+
// // NOTE: SubtreeManager has an additional case for PARTIAL_TREE_RENDERING, but we
337+
// // can just assume that using ComposeWorkflow at all implies that optimization.
338+
// //
339+
// // If our child state changed, we need to report that ours did too, as per the
340+
// // comment in StatefulWorkflowNode.applyAction.
341+
// return appliedActionFromChild.withOutput(null)
342+
// }
334343

335344
// The child DID emit an output, so we need to call our handler, which will zero or
336345
// more of two things: (1) change our state, (2) emit an output.
@@ -340,18 +349,29 @@ internal class ComposeWorkflowChildNode<PropsT, OutputT, RenderingT>(
340349
// directly. But only for the first call to emitOutput – subsequent calls will need to
341350
// be handled as usual.
342351
var maybeParentResult: ActionProcessingResult? = null
343-
onEmitOutputOverride = { output ->
344-
// We can't know if our own state changed, so just propagate from the child.
345-
val applied = appliedActionFromChild.withOutput(WorkflowOutput(output))
346-
maybeParentResult = emitAppliedActionToParent(applied)
347352

348-
// Immediately allow any future emissions in the same onOutput call to pass through.
353+
if (outputFromChild != null && onOutput != null) {
354+
onEmitOutputOverride = { output ->
355+
// We can't know if our own state changed, so just propagate from the child.
356+
val applied = appliedActionFromChild.withOutput(WorkflowOutput(output))
357+
log("handler emitted output, propagating to parent…")
358+
maybeParentResult = emitAppliedActionToParent(applied)
359+
360+
// Immediately allow any future emissions in the same onOutput call to pass through.
361+
onEmitOutputOverride = null
362+
}
363+
// Ask this workflow to handle the child's output. It may write snapshot state or call
364+
// emitOutput.
365+
onOutput(outputFromChild.value)
349366
onEmitOutputOverride = null
350367
}
351-
// Ask this workflow to handle the child's output. It may write snapshot state or call
352-
// emitOutput.
353-
onOutput(outputFromChild.value)
354-
onEmitOutputOverride = null
368+
369+
if (maybeParentResult == null) {
370+
// onOutput did not call emitOutput, but we need to propagate the action cascade anyway to
371+
// check if state changed.
372+
log("handler did not emitOutput, propagating to parent anyway…")
373+
maybeParentResult = emitAppliedActionToParent(appliedActionFromChild.withOutput(null))
374+
}
355375

356376
// If maybeParentResult is not null then onOutput called emitOutput.
357377
return maybeParentResult ?: appliedActionFromChild.withOutput(null)

0 commit comments

Comments
 (0)