Skip to content

Commit 8533b7a

Browse files
committed
API overhaul because splitting the keys and the composables don't work.
All of our tests and demos were built using `String` as the key, with `content` that does nothing but render the key. This approach doesn't reflect reality very well, and masked #63, where keys for more interesting objects can get out of sync with the `content` lambda that can render them. When popping, you would wind up crashing when the up to date lambda is unable to interpret the key for the screen that is being animated away. The fix is to change the API from something that takes a list of keys and a function that can render them, to a list of model objects that themselves are able to provide `@Composable Content()`. IMHO the updated API actually feels pretty good, more like the conventional hoisted-state `@Composable Foo(model: FooModel)` idiom. (Of course I've been working on this all day, so I'm biased.) We provide a new interface: ```kotlin interface BackstackFrame<out K : Any> { val key: K @composable fun Content() } ``` And change the signature of the `Backstack()` function: ```kotlin fun <K : Any> Backstack( frames: List<BackstackFrame<K>>, modifier: Modifier = Modifier, frameController: FrameController<K> ) ``` Note that the param type, `K`, is still the type of the key, not the type of a particular flavor of `BackstackFrame`. This makes it easy for us to provide convenience functions to map lists of arbitrary model objects to `BackstackFrame` instances, so it's not much more verbose than it used to be to make it go. Before: ```kotlin Backstack(backstack) { screen -> when(screen) { Screen.ContactList -> ShowContactList(navigator) is Screen.ContactDetails -> ShowContact(screen.id, navigator) is Screen.EditContact -> ShowEditContact(screen.id, navigator) } } ``` After: ```kotlin Backstack( backstack.toBackstackModel { screen -> when(screen) { Screen.ContactList -> ShowContactList(navigator) is Screen.ContactDetails -> ShowContact(screen.id, navigator) is Screen.EditContact -> ShowEditContact(screen.id, navigator) } ) ``` Note that there are two flavors of `toBackstackModel`. The second one supports models with more interesting keys. ```kotlin data class Portrait( val id: Int, val url: String ) Backstack( backstack.toBackstackModel( getKey = { it.id } ) { PrettyPicture(it) } ) ``` Fixes #63
1 parent ab4cbbc commit 8533b7a

File tree

8 files changed

+175
-151
lines changed

8 files changed

+175
-151
lines changed

compose-backstack-viewer/src/main/java/com/zachklipp/compose/backstack/viewer/BackstackViewerApp.kt

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import com.zachklipp.compose.backstack.BackstackTransition.Crossfade
4141
import com.zachklipp.compose.backstack.BackstackTransition.Slide
4242
import com.zachklipp.compose.backstack.defaultBackstackAnimation
4343
import com.zachklipp.compose.backstack.rememberTransitionController
44+
import com.zachklipp.compose.backstack.toBackstackModel
4445
import com.zachklipp.compose.backstack.xray.xrayed
4546

4647
private val DEFAULT_BACKSTACKS = listOf(
@@ -147,7 +148,14 @@ private fun AppScreens(model: AppModel) {
147148

148149
MaterialTheme(colors = lightColors()) {
149150
Backstack(
150-
backstack = model.currentBackstack,
151+
frames = model.currentBackstack.toBackstackModel { screen ->
152+
AppScreen(
153+
name = screen,
154+
showBack = screen != model.bottomScreen,
155+
onAdd = { model.pushScreen("$screen+") },
156+
onBack = model::popScreen
157+
)
158+
},
151159
frameController = rememberTransitionController<String>(
152160
transition = model.selectedTransition.second,
153161
animationSpec = animation ?: defaultBackstackAnimation(),
@@ -165,14 +173,7 @@ private fun AppScreens(model: AppModel) {
165173
modifier = Modifier
166174
.fillMaxSize()
167175
.border(width = 3.dp, color = Color.Red),
168-
) { screen ->
169-
AppScreen(
170-
name = screen,
171-
showBack = screen != model.bottomScreen,
172-
onAdd = { model.pushScreen("$screen+") },
173-
onBack = model::popScreen
174-
)
175-
}
176+
)
176177
}
177178
}
178179

compose-backstack-xray/src/main/java/com/zachklipp/compose/backstack/xray/XrayController.kt

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import androidx.compose.ui.graphics.DefaultCameraDistance
1212
import androidx.compose.ui.graphics.graphicsLayer
1313
import androidx.compose.ui.input.pointer.pointerInput
1414
import androidx.compose.ui.unit.dp
15+
import com.zachklipp.compose.backstack.BackstackFrame
1516
import com.zachklipp.compose.backstack.FrameController
16-
import com.zachklipp.compose.backstack.FrameController.BackstackFrame
17+
import com.zachklipp.compose.backstack.FrameController.FrameAndModifier
1718
import com.zachklipp.compose.backstack.NoopFrameController
1819
import kotlin.math.sin
1920

@@ -22,16 +23,16 @@ import kotlin.math.sin
2223
* the screens in the backstack in pseudo-3D space. The 3D stack can be navigated via touch
2324
* gestures.
2425
*/
25-
@Composable fun <T : Any> FrameController<T>.xrayed(enabled: Boolean): FrameController<T> =
26-
remember { XrayController<T>() }.also {
26+
@Composable fun <K : Any> FrameController<K>.xrayed(enabled: Boolean): FrameController<K> =
27+
remember { XrayController<K>() }.also {
2728
it.enabled = enabled
2829
it.wrappedController = this
2930
}
3031

31-
private class XrayController<T : Any> : FrameController<T> {
32+
private class XrayController<K : Any> : FrameController<K> {
3233

3334
var enabled: Boolean by mutableStateOf(false)
34-
var wrappedController: FrameController<T> by mutableStateOf(NoopFrameController())
35+
var wrappedController: FrameController<K> by mutableStateOf(NoopFrameController())
3536

3637
private var offsetDpX by mutableStateOf(500.dp)
3738
private var offsetDpY by mutableStateOf(10.dp)
@@ -41,7 +42,7 @@ private class XrayController<T : Any> : FrameController<T> {
4142
private var alpha by mutableStateOf(.4f)
4243
private var overlayAlpha by mutableStateOf(.2f)
4344

44-
private var activeKeys by mutableStateOf(emptyList<T>())
45+
private var activeKeys by mutableStateOf(emptyList<BackstackFrame<K>>())
4546

4647
private val controlModifier = Modifier.pointerInput(Unit) {
4748
detectTransformGestures { _, pan, zoom, _ ->
@@ -56,14 +57,14 @@ private class XrayController<T : Any> : FrameController<T> {
5657
if (!enabled) wrappedController.activeFrames else {
5758
activeKeys.mapIndexed { index, key ->
5859
val modifier = Modifier.modifierForFrame(index, activeKeys.size, 1f)
59-
return@mapIndexed BackstackFrame(key, modifier)
60+
return@mapIndexed FrameAndModifier(key, modifier)
6061
}
6162
}
6263
}
6364

64-
override fun updateBackstack(keys: List<T>) {
65-
activeKeys = keys
66-
wrappedController.updateBackstack(keys)
65+
override fun updateBackstack(frames: List<BackstackFrame<K>>) {
66+
activeKeys = frames
67+
wrappedController.updateBackstack(frames)
6768
}
6869

6970
/**

compose-backstack/src/androidTest/java/com/zachklipp/compose/backstack/BackstackStateTest.kt

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,9 @@ import androidx.compose.runtime.saveable.rememberSaveable
1010
import androidx.compose.runtime.setValue
1111
import androidx.compose.ui.Modifier
1212
import androidx.compose.ui.test.assertIsDisplayed
13-
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
1413
import androidx.compose.ui.test.junit4.createComposeRule
1514
import androidx.compose.ui.test.onNodeWithText
1615
import androidx.compose.ui.test.performClick
17-
import androidx.test.ext.junit.rules.ActivityScenarioRule
1816
import com.google.common.truth.Truth.assertThat
1917
import org.junit.Rule
2018
import org.junit.Test
@@ -24,13 +22,15 @@ class BackstackStateTest {
2422
@get:Rule
2523
val compose = createComposeRule()
2624

25+
private fun List<String>.toCounters() = toBackstackModel {
26+
var counter by rememberSaveable { mutableStateOf(0) }
27+
BasicText("$it: $counter", Modifier.clickable { counter++ })
28+
}
29+
2730
@Test fun screen_state_is_restored_on_pop() {
2831
val backstack = mutableStateListOf("one")
2932
compose.setContent {
30-
Backstack(backstack, frameController = NoopFrameController()) {
31-
var counter by rememberSaveable { mutableStateOf(0) }
32-
BasicText("$it: $counter", Modifier.clickable { counter++ })
33-
}
33+
Backstack(backstack.toCounters(), frameController = NoopFrameController())
3434
}
3535

3636
// Update some state on the first screen.
@@ -55,10 +55,7 @@ class BackstackStateTest {
5555
@Test fun screen_state_is_discarded_after_pop() {
5656
val backstack = mutableStateListOf("one", "two")
5757
compose.setContent {
58-
Backstack(backstack, frameController = NoopFrameController()) {
59-
var counter by rememberSaveable { mutableStateOf(0) }
60-
BasicText("$it: $counter", Modifier.clickable { counter++ })
61-
}
58+
Backstack(backstack.toCounters(), frameController = NoopFrameController())
6259
}
6360

6461
// Update some state on the second screen.
@@ -78,10 +75,7 @@ class BackstackStateTest {
7875
@Test fun screen_state_is_discarded_when_removed_from_backstack_while_hidden() {
7976
var backstack by mutableStateOf(listOf("one"))
8077
compose.setContent {
81-
Backstack(backstack, frameController = NoopFrameController()) {
82-
var counter by rememberSaveable { mutableStateOf(0) }
83-
BasicText("$it: $counter", Modifier.clickable { counter++ })
84-
}
78+
Backstack(backstack.toCounters(), frameController = NoopFrameController())
8579
}
8680

8781
// Update some state on the first screen.
@@ -112,13 +106,16 @@ class BackstackStateTest {
112106
val backstack = mutableStateListOf("one")
113107
val transcript = mutableListOf<String>()
114108
compose.setContent {
115-
Backstack(backstack, frameController = NoopFrameController()) {
116-
BasicText(it)
117-
DisposableEffect(Unit) {
118-
transcript += "+$it"
119-
onDispose { transcript += "-$it" }
120-
}
121-
}
109+
Backstack(
110+
backstack.toBackstackModel {
111+
BasicText(it)
112+
DisposableEffect(Unit) {
113+
transcript += "+$it"
114+
onDispose { transcript += "-$it" }
115+
}
116+
},
117+
frameController = NoopFrameController()
118+
)
122119
}
123120

124121
assertThat(transcript).containsExactly("+one")
@@ -143,10 +140,13 @@ class BackstackStateTest {
143140

144141
val backstack = mutableStateListOf(Screen("one"))
145142
compose.setContent {
146-
Backstack(backstack, frameController = NoopFrameController()) {
147-
var counter by rememberSaveable { mutableStateOf(0) }
148-
BasicText("${it.name}: $counter", Modifier.clickable { counter++ })
149-
}
143+
Backstack(
144+
backstack.toBackstackModel {
145+
var counter by rememberSaveable { mutableStateOf(0) }
146+
BasicText("${it.name}: $counter", Modifier.clickable { counter++ })
147+
},
148+
frameController = NoopFrameController()
149+
)
150150
}
151151

152152
// Update some state on the first screen.

compose-backstack/src/androidTest/java/com/zachklipp/compose/backstack/BackstackTransitionsTest.kt

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,12 @@ class BackstackTransitionsTest {
6363
assertTransition(Crossfade, forward = false)
6464
}
6565

66+
private fun List<String>.toBackstack() = toBackstackModel { BasicText(it) }
67+
6668
private fun assertInitialStateWithSingleScreen(transition: BackstackTransition) {
6769
val originalBackstack = listOf("one")
6870
compose.setContent {
69-
Backstack(originalBackstack, transition = transition) { BasicText(it) }
71+
Backstack(originalBackstack.toBackstack(), transition = transition)
7072
}
7173

7274
compose.onNodeWithText("one").assertIsDisplayed()
@@ -75,27 +77,27 @@ class BackstackTransitionsTest {
7577
private fun assertInitialStateWithMultipleScreens(transition: BackstackTransition) {
7678
val originalBackstack = listOf("one", "two")
7779
compose.setContent {
78-
Backstack(originalBackstack, transition = transition) { BasicText(it) }
80+
Backstack(originalBackstack.toBackstack(), transition = transition)
7981
}
8082

8183
compose.onNodeWithText("two").assertIsDisplayed()
8284
compose.onNodeWithText("one").assertDoesNotExist()
8385
}
8486

8587
private fun assertTransition(transition: BackstackTransition, forward: Boolean) {
86-
val firstBackstack = mapOf(1 to "one")
87-
val secondBackstack = mapOf(1 to "one", 2 to "two")
88+
val firstBackstack = listOf("one")
89+
val secondBackstack = listOf("one", "two")
8890
var backstack by mutableStateOf(if (forward) firstBackstack else secondBackstack)
8991
compose.mainClock.autoAdvance = false
9092

9193
compose.setContent {
9294
Backstack(
93-
backstack.keys.toList(),
95+
backstack.toBackstack(),
9496
frameController = rememberTransitionController(
9597
animationSpec = animation,
9698
transition = transition
9799
)
98-
) { BasicText(backstack.getValue(it)) }
100+
)
99101
}
100102

101103
val initialText = if (forward) "one" else "two"

0 commit comments

Comments
 (0)