Navigation¶
Navigation in Orbit is a back stack of Screens. There is no graph to declare and no route
strings to parse — you push a screen object and pop it.
The pieces¶
| Type | Role |
|---|---|
BackStack |
The stack of records. Owns order and persistence. |
Navigator |
What presenters call. goTo, pop, resetRoot. |
NavigableOrbitContent |
Renders the top record and scopes per screen state. |
NavDecoration |
Controls the transition between records. |
Presenters get a Navigator, never the back stack. That keeps them testable with
FakeNavigator.
Setting it up¶
val backStack = rememberSaveableBackStack(HomeScreen, AppJson)
val navigator = rememberNavigator(backStack) { finish() }
NavigableOrbitContent(
navigator = navigator,
backStack = backStack,
modifier = Modifier.fillMaxSize(),
)
The trailing lambda on rememberNavigator is onRootPop — it runs when something pops while
already at the root. On Android that means finishing the activity.
Navigator¶
navigator.goTo(DetailScreen("42"))
navigator.pop()
navigator.pop(DeletedResult("42"))
navigator.peek()
navigator.peekBackStack()
navigator.resetRoot(LoginScreen)
popUntil and popRoot are extensions for unwinding several entries:
Navigator.NoOp accepts every call and does nothing, which is handy for previews and for
OrbitContent used outside a back stack.
Android back¶
Orbit does not install a back handler for you, because what back means is app specific. Wire it where you host the content:
Returning a result¶
A screen can hand a value back to whoever launched it. Declare the result type:
The launching presenter uses rememberAnsweringNavigator, which returns a Navigator that
tags whatever it pushes so the result finds its way home:
class HomePresenter(private val backStack: SaveableBackStack) : Presenter<HomeState> {
@Composable
override fun present(): HomeState {
var lastDeleted by rememberRetained { mutableStateOf<String?>(null) }
val navigator = rememberAnsweringNavigator(backStack) { result ->
if (result is DeletedResult) lastDeleted = result.id
}
return HomeState(lastDeleted) { event ->
when (event) {
is HomeEvent.OpenDetail -> navigator.goTo(DetailScreen(event.id))
}
}
}
}
The launched screen pops with a value:
A plain pop() with no argument delivers nothing, so cancelling is just popping normally.
This presenter takes the back stack
rememberAnsweringNavigator needs the back stack, so a presenter that receives results takes
SaveableBackStack instead of Navigator. Presenters that only navigate forward should keep
taking Navigator.
Persistence¶
rememberSaveableBackStack writes the stack through rememberSaveable, so it survives both
configuration changes and process death. Screens are serialized with kotlinx-serialization, which
is why every screen must be registered:
val AppJson = Json {
serializersModule = SerializersModule {
polymorphic(Screen::class) {
subclass(HomeScreen::class)
subclass(DetailScreen::class)
}
}
}
Or on the builder, which is the same module wrapped for you:
Unregistered screens fail at runtime
A missing subclass line compiles cleanly and throws the first time the stack is saved. Add
the registration in the same commit as the screen.
Record keys are serialized alongside screens, so state scoping reattaches correctly after the process is rebuilt.
Record keys and state scoping¶
Every push creates a record with a fresh unique key — not a hash of the screen. Pushing the same screen twice therefore yields two records with independent state:
Those two entries have separate rememberRetained values and separate rememberSaveable values.
Popping the second reveals the first with its state intact.
When a record leaves the stack for good, NavigableOrbitContent tears down both its saveable
state and its retained registry, so nothing leaks into a later screen.
resetRoot¶
Replaces the whole stack. With saveState and restoreState it also implements tab switching:
saveState stores the current stack under its root screen; restoreState brings back a
previously saved stack for the new root if one exists. Saved stacks are in memory only and do not
survive process death.
Transitions¶
AnimatedNavDecoration is the default: a horizontal slide with a parallax on the outgoing
screen, choosing direction from whether the stack got deeper or shallower.
NavigableOrbitContent(
navigator = navigator,
backStack = backStack,
decoration = NoOpNavDecoration,
)
Set one globally with Orbit.Builder().setDefaultNavDecoration(...), or write your own:
object FadeNavDecoration : NavDecoration {
@Composable
override fun <T> DecoratedContent(
args: ImmutableList<T>,
backStackDepth: Int,
modifier: Modifier,
contentKey: (T) -> Any,
content: @Composable (T) -> Unit,
) {
AnimatedContent(
targetState = args.first(),
modifier = modifier,
contentKey = contentKey,
transitionSpec = { fadeIn() togetherWith fadeOut() },
) { arg ->
CompositionLocalProvider(LocalAnimatedVisibilityScope provides this) {
key(contentKey(arg)) { content(arg) }
}
}
}
}
Always use contentKey
A custom decoration must pass contentKey to AnimatedContent and wrap content in
key(contentKey(arg)). Compose derives rememberRetained's default key from the composition
key hash, which includes the identity of whatever AnimatedContent is keyed on. Keying on the
record object instead of its stable key silently breaks retained state after the back stack is
restored, because restoring creates new record instances.
Providing LocalAnimatedVisibilityScope is what makes shared elements
work; omit it and shared transitions become no-ops.