-
Notifications
You must be signed in to change notification settings - Fork 110
Prototype of a stepper composable helper for making backstacks. #1423
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
zach-klippenstein
wants to merge
2
commits into
main
Choose a base branch
from
zachklipp/backstack-helper
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+332
−0
Draft
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
218 changes: 218 additions & 0 deletions
218
workflow-ui/compose/src/main/java/com/squareup/workflow1/compose/Stepper.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,218 @@ | ||
package com.squareup.workflow1.compose | ||
|
||
import androidx.collection.ScatterMap | ||
import androidx.collection.mutableScatterMapOf | ||
import androidx.compose.runtime.Composable | ||
import androidx.compose.runtime.getValue | ||
import androidx.compose.runtime.mutableStateListOf | ||
import androidx.compose.runtime.mutableStateOf | ||
import androidx.compose.runtime.remember | ||
import androidx.compose.runtime.setValue | ||
import androidx.compose.runtime.snapshots.Snapshot | ||
import androidx.compose.runtime.snapshots.StateObject | ||
import androidx.compose.runtime.snapshots.StateRecord | ||
import androidx.compose.runtime.snapshots.readable | ||
import androidx.compose.runtime.snapshots.writable | ||
|
||
/** | ||
* Composes [content] and returns its return value in a list. | ||
* | ||
* Every time [content] calls [Stepper.advance] its argument is passed to [advance] and [advance] is | ||
* expected to update some states that are read inside [content]. The current values of all states | ||
* changed by [advance] are saved into a frame and pushed onto the backstack along with the last | ||
* value returned by [content]. | ||
* | ||
* When [Stepper.goBack] is called the last frame is popped and all the states that were written by | ||
* [advance] are restored before recomposing [content]. | ||
* | ||
* @sample com.squareup.workflow1.compose.StepperDemo | ||
*/ | ||
@Composable | ||
public fun <T, R> stepper( | ||
advance: (T) -> Unit, | ||
content: @Composable Stepper<T, R>.() -> R | ||
): List<R> { | ||
// TODO figure out how to support rememberSaveable | ||
val stepperImpl = remember { StepperImpl<T, R>(advance = advance) } | ||
stepperImpl.advance = advance | ||
stepperImpl.lastRendering = content(stepperImpl) | ||
return stepperImpl.renderings | ||
} | ||
|
||
/** | ||
* Composes [content] and returns its return value in a list. Every time [content] calls | ||
* [Stepper.advance] the current values of all states changed by the `toState` block are | ||
* saved into a frame and pushed onto the backstack along with the last value returned by [content]. | ||
* When [Stepper.goBack] is called the last frame is popped and all the states that were | ||
* written by the `toState` block are restored before recomposing [content]. | ||
* | ||
* This is an overload of [stepper] that makes it easier to specify the state update function when | ||
* calling [Stepper.advance] instead of defining it ahead of time. | ||
* | ||
* @sample com.squareup.workflow1.compose.StepperInlineDemo | ||
*/ | ||
// Impl note: Inline since this is just syntactic sugar, no reason to generate bytecode/API for it. | ||
@Suppress("NOTHING_TO_INLINE") | ||
@Composable | ||
public inline fun <R> stepper( | ||
noinline content: @Composable Stepper<() -> Unit, R>.() -> R | ||
): List<R> = stepper(advance = { it() }, content = content) | ||
|
||
public interface Stepper<T, R> { | ||
|
||
/** The (possibly empty) stack of steps that came before the current one. */ | ||
val previousSteps: List<Step<R>> | ||
|
||
/** | ||
* Pushes a new frame onto the backstack with the current state and then runs [toState]. | ||
*/ | ||
fun advance(toState: T) | ||
|
||
/** | ||
* Pops the last frame off the backstack and restores its state. | ||
* | ||
* @return False if the stack was empty (i.e. this is a noop). | ||
*/ | ||
fun goBack(): Boolean | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If this is a stack, why not call types and functions accordingly? |
||
} | ||
|
||
public interface Step<T> { | ||
/** The last rendering produced by this step. */ | ||
val rendering: T | ||
|
||
/** | ||
* Runs [block] inside a snapshot such that the step state is set to its saved values from this | ||
* step. The snapshot is read-only, so writing to any snapshot state objects will throw. | ||
*/ | ||
fun <R> peekStateFromStep(block: () -> R): R | ||
} | ||
|
||
private class StepperImpl<T, R>( | ||
advance: (T) -> Unit | ||
) : Stepper<T, R> { | ||
var advance: (T) -> Unit by mutableStateOf(advance) | ||
private val savePoints = mutableStateListOf<SavePoint>() | ||
var lastRendering by mutableStateOf<Any?>(NO_RENDERING) | ||
|
||
val renderings: List<R> | ||
get() = buildList(capacity = savePoints.size + 1) { | ||
savePoints.mapTo(this) { it.rendering } | ||
@Suppress("UNCHECKED_CAST") | ||
add(lastRendering as R) | ||
} | ||
|
||
override val previousSteps: List<Step<R>> | ||
get() = savePoints | ||
|
||
override fun advance(toState: T) { | ||
check(lastRendering !== NO_RENDERING) { "advance called before first composition" } | ||
|
||
// Take an outer snapshot so all the state mutations in withState get applied atomically with | ||
// our internal state update (to savePoints). | ||
Snapshot.withMutableSnapshot { | ||
val savedRecords = mutableScatterMapOf<StateObject, StateRecord?>() | ||
val snapshot = Snapshot.takeMutableSnapshot( | ||
writeObserver = { | ||
// Don't save the value of the object yet, we want the value _before_ the write, so we | ||
// need to read it outside this inner snapshot. | ||
savedRecords[it as StateObject] = null | ||
} | ||
) | ||
try { | ||
// Record what state objects are written by the block. | ||
snapshot.enter { this.advance.invoke(toState) } | ||
|
||
// Save the _current_ values of those state objects so we can restore them later. | ||
// TODO Need to think more about which state objects need to be saved and restored for a | ||
// particular frame. E.g. probably we should track all objects that were written for the | ||
// current frame, and save those as well, even if they're not written by the _next_ frame. | ||
savedRecords.forEachKey { stateObject -> | ||
savedRecords[stateObject] = stateObject.copyCurrentRecord() | ||
} | ||
|
||
// This should never fail since we're already in a snapshot and no other state has been | ||
// written by this point, but check just in case. | ||
val advanceApplyResult = snapshot.apply() | ||
if (advanceApplyResult.succeeded) { | ||
// This cast is fine, we know we've assigned a non-null value to all entries. | ||
@Suppress("UNCHECKED_CAST") | ||
savePoints += SavePoint( | ||
savedRecords = savedRecords as ScatterMap<StateObject, StateRecord>, | ||
rendering = lastRendering as R, | ||
) | ||
} | ||
// If !succeeded, throw the standard error. | ||
advanceApplyResult.check() | ||
} finally { | ||
snapshot.dispose() | ||
} | ||
} | ||
} | ||
|
||
override fun goBack(): Boolean { | ||
Snapshot.withMutableSnapshot { | ||
if (savePoints.isEmpty()) return false | ||
val toRestore = savePoints.removeAt(savePoints.lastIndex) | ||
|
||
// Restore all state objects' saved values. | ||
toRestore.restoreState() | ||
|
||
// Don't need to restore the last rendering, it will be computed fresh by the imminent | ||
// recomposition. | ||
} | ||
return true | ||
} | ||
|
||
/** | ||
* Returns a copy of the current readable record of this state object. A copy is needed since | ||
* active records can be mutated by other snapshots. | ||
*/ | ||
private fun StateObject.copyCurrentRecord(): StateRecord { | ||
val record = firstStateRecord.readable(this) | ||
// Records can be mutated in other snapshots, so create a copy. | ||
return record.create().apply { assign(record) } | ||
} | ||
|
||
/** | ||
* Sets the value of this state object to a [record] that was previously copied via | ||
* [copyCurrentRecord]. | ||
*/ | ||
private fun StateObject.restoreRecord(record: StateRecord) { | ||
firstStateRecord.writable(this) { assign(record) } | ||
} | ||
|
||
private inner class SavePoint( | ||
val savedRecords: ScatterMap<StateObject, StateRecord>, | ||
override val rendering: R, | ||
) : Step<R> { | ||
override fun <R> peekStateFromStep(block: () -> R): R { | ||
// Need a mutable snapshot to restore state. | ||
val restoreSnapshot = Snapshot.takeMutableSnapshot() | ||
try { | ||
restoreSnapshot.enter { | ||
restoreState() | ||
|
||
// Now take a read-only snapshot to enforce contract. | ||
val readOnlySnapshot = Snapshot.takeSnapshot() | ||
try { | ||
return readOnlySnapshot.enter(block) | ||
} finally { | ||
readOnlySnapshot.dispose() | ||
} | ||
} | ||
} finally { | ||
restoreSnapshot.dispose() | ||
} | ||
} | ||
|
||
fun restoreState() { | ||
savedRecords.forEach { stateObject, record -> | ||
stateObject.restoreRecord(record) | ||
} | ||
} | ||
} | ||
|
||
companion object { | ||
val NO_RENDERING = Any() | ||
} | ||
} |
98 changes: 98 additions & 0 deletions
98
workflow-ui/compose/src/main/java/com/squareup/workflow1/compose/StepperDemo.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,98 @@ | ||
package com.squareup.workflow1.compose | ||
|
||
import androidx.compose.runtime.Composable | ||
import androidx.compose.runtime.getValue | ||
import androidx.compose.runtime.mutableStateOf | ||
import androidx.compose.runtime.saveable.rememberSaveable | ||
import androidx.compose.runtime.setValue | ||
import androidx.compose.ui.util.fastJoinToString | ||
import com.squareup.workflow1.compose.DemoStep.ONE | ||
import com.squareup.workflow1.compose.DemoStep.THREE | ||
import com.squareup.workflow1.compose.DemoStep.TWO | ||
import com.squareup.workflow1.compose.Screen.ScreenOne | ||
import com.squareup.workflow1.compose.Screen.ScreenThree | ||
import com.squareup.workflow1.compose.Screen.ScreenTwo | ||
|
||
internal enum class DemoStep { | ||
ONE, | ||
TWO, | ||
THREE, | ||
} | ||
|
||
internal sealed interface Screen { | ||
val message: String | ||
|
||
data class ScreenOne( | ||
override val message: String, | ||
val onNextClicked: () -> Unit, | ||
) : Screen | ||
|
||
data class ScreenTwo( | ||
override val message: String, | ||
val onNextClicked: () -> Unit, | ||
val onBack: () -> Unit, | ||
) : Screen | ||
|
||
data class ScreenThree( | ||
override val message: String, | ||
val onBack: () -> Unit, | ||
) : Screen | ||
} | ||
|
||
@Composable | ||
internal fun StepperDemo() { | ||
var step by rememberSaveable { mutableStateOf(ONE) } | ||
println("step=$step") | ||
|
||
val stack: List<Screen> = stepper(advance = { step = it }) { | ||
val breadcrumbs = previousSteps.fastJoinToString(separator = " > ") { it.rendering.message } | ||
when (step) { | ||
ONE -> ScreenOne( | ||
message = "Step one", | ||
onNextClicked = { advance(TWO) }, | ||
) | ||
|
||
TWO -> ScreenTwo( | ||
message = "Step two", | ||
onNextClicked = { advance(THREE) }, | ||
onBack = { goBack() }, | ||
) | ||
|
||
THREE -> ScreenThree( | ||
message = "Step three", | ||
onBack = { goBack() }, | ||
) | ||
} | ||
} | ||
|
||
println("stack = ${stack.fastJoinToString()}") | ||
} | ||
|
||
@Composable | ||
internal fun StepperInlineDemo() { | ||
var step by rememberSaveable { mutableStateOf(ONE) } | ||
println("step=$step") | ||
|
||
val stack: List<Screen> = stepper { | ||
val breadcrumbs = previousSteps.fastJoinToString(separator = " > ") { it.rendering.message } | ||
when (step) { | ||
ONE -> ScreenOne( | ||
message = "Step one", | ||
onNextClicked = { advance { step = TWO } }, | ||
) | ||
|
||
TWO -> ScreenTwo( | ||
message = "Step two", | ||
onNextClicked = { advance { step = THREE } }, | ||
onBack = { goBack() }, | ||
) | ||
|
||
THREE -> ScreenThree( | ||
message = "Step three", | ||
onBack = { goBack() }, | ||
) | ||
} | ||
} | ||
|
||
println("stack = ${stack.fastJoinToString()}") | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you added this one later and it wasn't part of the initial revision and I meant to call out that something like this is needed. Likely, I would even go a step further and argue that
stepper()
should not return aList<R>
, but rather a type that expresses how the backstack was changed.Here is my prototype that Amazon uses in production now: https://github.com/amzn/app-platform/blob/main/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/backstack/PresenterBackstackScope.kt#L19-L32 (it's not part of the official API, because I wanted get an idea first how it works out in production). Here it runs in the browser: https://amzn.github.io/app-platform/#web-recipe-app
The problem I wanted to solve is to make it easy for the UI layer to play animations for changes in the backstack, like the cross-slide animation in the demo above. Exposing only a stack makes this more challenging for the UI layer. That's why I introduced the
BackstackChange
type.