Skip to content

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.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.popUntil { it is HomeScreen }
navigator.popRoot()

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:

BackHandler(enabled = !backStack.isAtRoot) { navigator.pop() }

Returning a result

A screen can hand a value back to whoever launched it. Declare the result type:

@Serializable
data class DeletedResult(val id: String) : PopResult

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:

navigator.pop(DeletedResult(screen.id))

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:

Orbit.Builder()
    .setScreenSerializers {
        subclass(HomeScreen::class)
        subclass(DetailScreen::class)
    }

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:

navigator.goTo(DetailScreen("a"))
navigator.goTo(DetailScreen("a"))

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:

navigator.resetRoot(ProfileScreen, saveState = true, restoreState = true)

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.