Skip to content

Commit e5c4530

Browse files
Prototype of a stepper composable helper for making backstacks.
1 parent 9056cc7 commit e5c4530

File tree

2 files changed

+269
-0
lines changed

2 files changed

+269
-0
lines changed
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package com.squareup.workflow1.compose
2+
3+
import androidx.collection.ScatterMap
4+
import androidx.collection.mutableScatterMapOf
5+
import androidx.compose.runtime.Composable
6+
import androidx.compose.runtime.getValue
7+
import androidx.compose.runtime.mutableStateListOf
8+
import androidx.compose.runtime.mutableStateOf
9+
import androidx.compose.runtime.remember
10+
import androidx.compose.runtime.setValue
11+
import androidx.compose.runtime.snapshots.Snapshot
12+
import androidx.compose.runtime.snapshots.StateObject
13+
import androidx.compose.runtime.snapshots.StateRecord
14+
import androidx.compose.runtime.snapshots.readable
15+
import androidx.compose.runtime.snapshots.writable
16+
17+
/**
18+
* Composes [content] and returns its return value in a list.
19+
*
20+
* Every time [content] calls [Stepper.advance] its argument is passed to [advance] and [advance] is
21+
* expected to update some states that are read inside [content]. The current values of all states
22+
* changed by [advance] are saved into a frame and pushed onto the backstack along with the last
23+
* value returned by [content].
24+
*
25+
* When [Stepper.goBack] is called the last frame is popped and all the states that were written by
26+
* [advance] are restored before recomposing [content].
27+
*
28+
* @sample com.squareup.workflow1.compose.StepperDemo
29+
*/
30+
@Composable
31+
public fun <T, R> stepper(
32+
advance: (T) -> Unit,
33+
content: @Composable Stepper<T>.() -> R
34+
): List<R> {
35+
// TODO figure out how to support rememberSaveable
36+
val stepperImpl = remember { StepperImpl(advance = advance) }
37+
stepperImpl.advance = advance
38+
stepperImpl.lastRendering = content(stepperImpl)
39+
@Suppress("UNCHECKED_CAST")
40+
return stepperImpl.renderings as List<R>
41+
}
42+
43+
/**
44+
* Composes [content] and returns its return value in a list. Every time [content] calls
45+
* [Stepper.advance] the current values of all states changed by the `toState` block are
46+
* saved into a frame and pushed onto the backstack along with the last value returned by [content].
47+
* When [Stepper.goBack] is called the last frame is popped and all the states that were
48+
* written by the `toState` block are restored before recomposing [content].
49+
*
50+
* This is an overload of [stepper] that makes it easier to specify the state update function when
51+
* calling [Stepper.advance] instead of defining it ahead of time.
52+
*
53+
* @sample com.squareup.workflow1.compose.StepperInlineDemo
54+
*/
55+
@Composable
56+
public fun <R> stepper(
57+
content: @Composable Stepper<() -> Unit>.() -> R
58+
): List<R> = stepper(advance = { it() }, content = content)
59+
60+
public interface Stepper<T> {
61+
62+
/**
63+
* Pushes a new frame onto the backstack with the current state and then runs [toState].
64+
*/
65+
fun advance(toState: T)
66+
67+
/**
68+
* Pops the last frame off the backstack and restores its state.
69+
*
70+
* @return False if the stack was empty (i.e. this is a noop).
71+
*/
72+
fun goBack(): Boolean
73+
}
74+
75+
private class StepperImpl<T>(
76+
advance: (T) -> Unit
77+
) : Stepper<T> {
78+
var advance: (T) -> Unit by mutableStateOf(advance)
79+
private val savePoints = mutableStateListOf<SavePoint>()
80+
var lastRendering by mutableStateOf<Any?>(NO_RENDERING)
81+
82+
val renderings: List<Any?>
83+
get() = buildList(capacity = savePoints.size + 1) {
84+
savePoints.mapTo(this) { it.rendering }
85+
add(lastRendering)
86+
}
87+
88+
override fun advance(toState: T) {
89+
check(lastRendering !== NO_RENDERING) { "advance called before first composition" }
90+
91+
// Take an outer snapshot so all the state mutations in withState get applied atomically with
92+
// our internal state update (to savePoints).
93+
Snapshot.withMutableSnapshot {
94+
val savedRecords = mutableScatterMapOf<StateObject, StateRecord?>()
95+
val snapshot = Snapshot.takeMutableSnapshot(
96+
writeObserver = {
97+
// Don't save the value of the object yet, we want the value _before_ the write, so we
98+
// need to read it outside this inner snapshot.
99+
savedRecords[it as StateObject] = null
100+
}
101+
)
102+
try {
103+
// Record what state objects are written by the block.
104+
snapshot.enter { this.advance.invoke(toState) }
105+
106+
// Save the _current_ values of those state objects so we can restore them later.
107+
// TODO Need to think more about which state objects need to be saved and restored for a
108+
// particular frame. E.g. probably we should track all objects that were written for the
109+
// current frame, and save those as well, even if they're not written by the _next_ frame.
110+
savedRecords.forEachKey { stateObject ->
111+
savedRecords[stateObject] = stateObject.copyCurrentRecord()
112+
}
113+
114+
// This should never fail since we're already in a snapshot and no other state has been
115+
// written by this point, but check just in case.
116+
val advanceApplyResult = snapshot.apply()
117+
if (advanceApplyResult.succeeded) {
118+
// This cast is fine, we know we've assigned a non-null value to all entries.
119+
@Suppress("UNCHECKED_CAST")
120+
savePoints += SavePoint(
121+
savedRecords = savedRecords as ScatterMap<StateObject, StateRecord>,
122+
rendering = lastRendering,
123+
)
124+
}
125+
// If !succeeded, throw the standard error.
126+
advanceApplyResult.check()
127+
} finally {
128+
snapshot.dispose()
129+
}
130+
}
131+
}
132+
133+
override fun goBack(): Boolean {
134+
Snapshot.withMutableSnapshot {
135+
if (savePoints.isEmpty()) return false
136+
val toRestore = savePoints.removeAt(savePoints.lastIndex)
137+
138+
// Restore all state objects' saved values.
139+
toRestore.savedRecords.forEach { stateObject, record ->
140+
stateObject.restoreRecord(record)
141+
}
142+
143+
// Don't need to restore the last rendering, it will be computed fresh by the imminent
144+
// recomposition.
145+
}
146+
return true
147+
}
148+
149+
/**
150+
* Returns a copy of the current readable record of this state object. A copy is needed since
151+
* active records can be mutated by other snapshots.
152+
*/
153+
private fun StateObject.copyCurrentRecord(): StateRecord {
154+
val record = firstStateRecord.readable(this)
155+
// Records can be mutated in other snapshots, so create a copy.
156+
return record.create().apply { assign(record) }
157+
}
158+
159+
/**
160+
* Sets the value of this state object to a [record] that was previously copied via
161+
* [copyCurrentRecord].
162+
*/
163+
private fun StateObject.restoreRecord(record: StateRecord) {
164+
firstStateRecord.writable(this) { assign(record) }
165+
}
166+
167+
private class SavePoint(
168+
val savedRecords: ScatterMap<StateObject, StateRecord>,
169+
val rendering: Any?,
170+
)
171+
172+
companion object {
173+
val NO_RENDERING = Any()
174+
}
175+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package com.squareup.workflow1.compose
2+
3+
import androidx.compose.runtime.Composable
4+
import androidx.compose.runtime.getValue
5+
import androidx.compose.runtime.mutableStateOf
6+
import androidx.compose.runtime.saveable.rememberSaveable
7+
import androidx.compose.runtime.setValue
8+
import androidx.compose.ui.util.fastJoinToString
9+
import com.squareup.workflow1.compose.Screen.ScreenOne
10+
import com.squareup.workflow1.compose.Screen.ScreenThree
11+
import com.squareup.workflow1.compose.Screen.ScreenTwo
12+
import com.squareup.workflow1.compose.Step.ONE
13+
import com.squareup.workflow1.compose.Step.THREE
14+
import com.squareup.workflow1.compose.Step.TWO
15+
16+
internal enum class Step {
17+
ONE,
18+
TWO,
19+
THREE,
20+
}
21+
22+
internal sealed interface Screen {
23+
data class ScreenOne(
24+
val message: String,
25+
val onNextClicked: () -> Unit,
26+
) : Screen
27+
28+
data class ScreenTwo(
29+
val message: String,
30+
val onNextClicked: () -> Unit,
31+
val onBack: () -> Unit,
32+
) : Screen
33+
34+
data class ScreenThree(
35+
val message: String,
36+
val onBack: () -> Unit,
37+
) : Screen
38+
}
39+
40+
@Composable
41+
internal fun StepperDemo() {
42+
var step by rememberSaveable { mutableStateOf(ONE) }
43+
println("step=$step")
44+
45+
val stack: List<Screen> = stepper(advance = { step = it }) {
46+
when (step) {
47+
ONE -> ScreenOne(
48+
message = "Step one",
49+
onNextClicked = { advance(TWO) },
50+
)
51+
52+
TWO -> ScreenTwo(
53+
message = "Step two",
54+
onNextClicked = { advance(THREE) },
55+
onBack = { goBack() },
56+
)
57+
58+
THREE -> ScreenThree(
59+
message = "Step three",
60+
onBack = { goBack() },
61+
)
62+
}
63+
}
64+
65+
println("stack = ${stack.fastJoinToString()}")
66+
}
67+
68+
@Composable
69+
internal fun StepperInlineDemo() {
70+
var step by rememberSaveable { mutableStateOf(ONE) }
71+
println("step=$step")
72+
73+
val stack: List<Screen> = stepper {
74+
when (step) {
75+
ONE -> ScreenOne(
76+
message = "Step one",
77+
onNextClicked = { advance { step = TWO } },
78+
)
79+
80+
TWO -> ScreenTwo(
81+
message = "Step two",
82+
onNextClicked = { advance { step = THREE } },
83+
onBack = { goBack() },
84+
)
85+
86+
THREE -> ScreenThree(
87+
message = "Step three",
88+
onBack = { goBack() },
89+
)
90+
}
91+
}
92+
93+
println("stack = ${stack.fastJoinToString()}")
94+
}

0 commit comments

Comments
 (0)