11 - Navigation 3
Recap
10 - App Architecture, ViewModel & Hilt split every screen into a route with a @HiltViewModel and a stateless screen composable. So far an app had exactly one screen. HW2 needs at least two (the game and a statistics screen). SportMap needs a map, a session list, a session detail, settings and a login screen. In lecture 02 you switched screens by starting another activity with an intent. A Compose app usually has one activity, and a screen is just a composable. This lecture shows how to decide which screen is visible, how back works, and how each screen gets its own ViewModel. It uses Navigation 3, the navigation library built for Compose.
By the end of this lecture you should be able to:
- Explain the Navigation 3 model: the back stack is a list of keys that you own.
- Define destinations as
@SerializableNavKeys, including keys with arguments. - Display the back stack with
NavDisplayand anentryProvider. - Navigate forward, back, and to top-level destinations by changing the list.
- Scope ViewModels to back-stack entries and pass a key into a ViewModel with Hilt assisted injection.
- Use NavigationSuiteScaffold, list-detail scenes, dialogs and results.
- Route a notification tap (an
Intent) to the right screen.
Lecture demos: native-2026-fall
One activity, many screens
With views, every screen was an activity, and Android managed the back stack of activities for you. In Compose, the only thing you need to answer is "which composable is shown now, and what does back do?" The naive answer works for two screens:
var showStats by rememberSaveable { mutableStateOf(false) }
if (showStats) StatsRoute() else GameRoute()
It breaks quickly. There's no back stack beyond one level and back doesn't work. State and ViewModels aren't separated per screen, and there are no animations. Navigation 3 keeps the same idea (navigation is state) and adds what's missing.
The Navigation 3 model
The back stack is a plain, observable MutableList of keys. You own it and change it. NavDisplay shows the entry for the last key and animates when the list changes.
| Concept | What it is |
|---|---|
key (NavKey) | identifies a destination and carries its arguments, e.g. SessionDetailKey(42) |
back stack (NavBackStack) | an observable list of keys, saved across rotation and process death |
entry (NavEntry) | a key plus the composable content to show for it |
| entryProvider | a function from key to entry, written with a small DSL |
| NavDisplay | the composable that observes the back stack and shows the top entry (or several, in a scene) |
| decorators | add per-entry features: saved state, ViewModel store, results |
| scenes | decide how many entries are shown at once: one pane, list + detail, dialog |
Navigating is list manipulation:
| Action | Code |
|---|---|
| go to a screen | backStack.add(SessionsKey) |
| go back | backStack.removeLastOrNull() |
| replace the current screen | backStack[backStack.lastIndex] = LoginKey |
| back to the start | while (backStack.size > 1) backStack.removeAt(backStack.lastIndex) |
Setup
# libs.versions.toml
[libraries]
androidx-navigation3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime", version.ref = "navigation3" }
androidx-navigation3-ui = { group = "androidx.navigation3", name = "navigation3-ui", version.ref = "navigation3" }
androidx-lifecycle-viewmodel-navigation3 = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-navigation3", version.ref = "lifecycle" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
// build.gradle.kts (top level)
alias(libs.plugins.kotlin.serialization) apply false
// app/build.gradle.kts
plugins {
// ...
alias(libs.plugins.kotlin.serialization)
}
dependencies {
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.kotlinx.serialization.json)
}
The serialization plugin is the same one lecture 19 uses for JSON. Here it generates the code that saves keys into the Bundle.
Keys
// ui/navigation/Keys.kt
@Serializable data object MapKey : NavKey
@Serializable data object SessionsKey : NavKey
@Serializable data class SessionDetailKey(val sessionId: Long) : NavKey
@Serializable data object SettingsKey : NavKey
@Serializable data object LoginKey : NavKey
- A destination without arguments is a
data object, and one with arguments is adata class. NavKeyis a marker interface.@SerializableletsrememberNavBackStacksave the back stack on rotation and process death.- Pass ids, not objects.
SessionDetailKey(sessionId)is enough, because the detail screen loads the session from the repository, the single source of truth. Large objects in keys also hit theBundlesize limit (TransactionTooLargeException).
NavDisplay
// ui/navigation/SportMapNavigation.kt
@Composable
fun SportMapNavigation() {
val backStack = rememberNavBackStack(MapKey)
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = entryProvider {
entry<MapKey> {
MapRoute(
onOpenSessions = { backStack.add(SessionsKey) },
onOpenSettings = { backStack.add(SettingsKey) },
)
}
entry<SessionsKey> {
SessionsRoute(onSessionClick = { id -> backStack.add(SessionDetailKey(id)) })
}
entry<SessionDetailKey> { key ->
SessionDetailRoute(sessionId = key.sessionId, onDeleted = { backStack.removeLastOrNull() })
}
entry<SettingsKey> { SettingsRoute(onLogin = { backStack.add(LoginKey) }) }
entry<LoginKey> { LoginRoute(onLoggedIn = { backStack.removeLastOrNull() }) } // lecture 19
},
)
}
rememberNavBackStack(MapKey)creates the back stack withMapKeyas the start destination and saves it automatically.entry<SessionDetailKey> { key -> }is typed. Inside the lambda,keyis aSessionDetailKey, so arguments are just properties (key.sessionId).onBackis called for the system back button and gesture. When only one entry is left,NavDisplaydoesn't intercept back, and the system closes the activity as usual.- Screens never see the back stack. They get callbacks (
onSessionClick,onBack), and only the navigation file decides where those lead. That keeps screens previewable and reusable, the same route/screen split as lecture 10.
Call SportMapNavigation() from setContent { SportMapTheme { ... } } in MainActivity.
Predictive back
Since Android 14 the user can peek at the previous screen while swiping back. For apps that target API 36+, predictive back animations are on by default, and onBackPressed() is no longer called. NavDisplay animates the back gesture between entries. For your own back handling inside a screen (closing a search field, a bottom sheet), use BackHandler:
BackHandler(enabled = searchOpen) { searchOpen = false }
Tutorials that override onBackPressed() in the activity are outdated. With targetSdk 37 that code silently never runs.
Decorators: state and ViewModels per entry
Decorators wrap each entry's content with extra behaviour:
rememberSaveableStateHolderNavEntryDecorator()gives every entry its ownrememberSaveablestorage. The scroll position of the session list survives a trip to the detail screen and back, and it's thrown away when the entry is popped. This is the default if you pass nothing.rememberViewModelStoreNavEntryDecorator()(fromlifecycle-viewmodel-navigation3) gives every entry its ownViewModelStore.hiltViewModel()inside an entry returns a ViewModel that lives exactly as long as that entry is on the back stack, andonCleared()runs when it's popped. Put it after the saveable-state decorator, because it relies on it forSavedStateHandle.
ViewModels fall back to the activity's store and live until the activity finishes. Open session 1, go back, open session 2, and you get session 1's ViewModel again, still showing session 1.
Passing the key into a ViewModel
SessionDetailViewModel needs the session id in its constructor, so it can start loading in init and survive rotation without reloading. Hilt has assisted injection for this: some constructor parameters come from Hilt, and others (the id from the key) from the caller.
@HiltViewModel(assistedFactory = SessionDetailViewModel.Factory::class)
class SessionDetailViewModel @AssistedInject constructor(
@Assisted private val sessionId: Long,
private val sessionRepository: SessionRepository, // lecture 18
) : ViewModel() {
val detail: StateFlow<SessionDetail?> = sessionRepository.sessionDetail(sessionId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
@AssistedFactory
interface Factory {
fun create(sessionId: Long): SessionDetailViewModel
}
}
@Composable
fun SessionDetailRoute(
sessionId: Long,
onDeleted: () -> Unit, // lecture 18 adds rename and delete
viewModel: SessionDetailViewModel = hiltViewModel<SessionDetailViewModel, SessionDetailViewModel.Factory>(
creationCallback = { factory -> factory.create(sessionId) }
),
) {
val detail by viewModel.detail.collectAsStateWithLifecycle()
detail?.let { SessionDetailScreen(detail = it) }
}
@AssistedInjectreplaces@Injecton the constructor, and@Assistedmarks the caller-supplied parameter.- The
@AssistedFactoryinterface is implemented by Hilt.hiltViewModel<VM, Factory>(creationCallback = ...)(fromandroidx.hilt:hilt-lifecycle-viewmodel-compose) calls it the first time. After rotation the existing ViewModel is returned and the callback isn't called again. - Combined with the ViewModel decorator,
SessionDetailKey(1)andSessionDetailKey(2)are different entries with different ViewModels.
Top-level destinations
Apps with a few equal sections show them in a navigation bar on phones and a navigation rail on tablets. NavigationSuiteScaffold (from material3-adaptive-navigation-suite, in the Compose BOM) picks the right one from the window size (lecture 07):
# libs.versions.toml
androidx-compose-material3-adaptive-navigation-suite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite" } # version from the BOM
// app/build.gradle.kts
implementation(libs.androidx.compose.material3.adaptive.navigation.suite)
data class TopLevelDestination(val key: NavKey, val label: String, val icon: ImageVector)
val topLevelDestinations = listOf(
TopLevelDestination(MapKey, "Map", Icons.Default.LocationOn),
TopLevelDestination(SessionsKey, "Sessions", Icons.AutoMirrored.Filled.List),
TopLevelDestination(SettingsKey, "Settings", Icons.Default.Settings),
)
/** Keep MapKey at the bottom, so back from any section returns to the map. */
fun NavBackStack<NavKey>.navigateTopLevel(key: NavKey) {
removeAll { it != MapKey }
if (key != MapKey) add(key)
}
@Composable
fun SportMapNavigation() {
val backStack = rememberNavBackStack(MapKey)
val selected = backStack.lastOrNull { key -> topLevelDestinations.any { it.key == key } }
NavigationSuiteScaffold(
navigationSuiteItems = {
topLevelDestinations.forEach { destination ->
item(
selected = destination.key == selected,
onClick = { backStack.navigateTopLevel(destination.key) },
icon = { Icon(destination.icon, contentDescription = null) },
label = { Text(destination.label) },
)
}
}
) {
NavDisplay(backStack = backStack, /* ... as above ... */)
}
}
The selected item is the last top-level key in the stack, so with [MapKey, SessionsKey, SessionDetailKey(42)] "Sessions" stays highlighted on the detail screen.
If each section must remember its own history (switching tabs and back restores the detail screen you were on), keep one list per section and show the current one. The official nav3-recipes repository has ready-made "multiple back stacks" and "bottom navigation" recipes. It's plain Kotlin, because back stacks are just lists.
A full-screen map with a bottom navigation bar wastes space that the statistics need. A common choice is a map screen with top-bar actions (sessions, settings, compass) and a navigation rail only on tablets. Both are fine, as long as you can explain the choice.
Scenes: list-detail, dialogs
By default NavDisplay shows one entry, the top one. A scene strategy can show several entries at once. You pass strategies in a list. The first one that wants to handle the current back stack wins, and single-pane is always the fallback.
List and detail side by side on tablets
# libs.versions.toml
androidx-compose-material3-adaptive-navigation3 = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3" } # version from the BOM
// app/build.gradle.kts
implementation(libs.androidx.compose.material3.adaptive.navigation3)
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun SportMapNavigation() {
val backStack = rememberNavBackStack(MapKey)
val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
sceneStrategies = listOf(listDetailStrategy),
entryProvider = entryProvider {
entry<SessionsKey>(
metadata = ListDetailSceneStrategy.listPane(
detailPlaceholder = { Text("Select a session") }
)
) {
SessionsRoute(onSessionClick = { id -> backStack.add(SessionDetailKey(id)) })
}
entry<SessionDetailKey>(metadata = ListDetailSceneStrategy.detailPane()) { key ->
SessionDetailRoute(sessionId = key.sessionId, onDeleted = { backStack.removeLastOrNull() })
}
// ...
},
)
}
On a phone nothing changes, and the detail screen replaces the list. On a tablet or an unfolded foldable, SessionsKey and SessionDetailKey are shown side by side as list and detail panes. The back stack is the same in both cases, and only the presentation adapts. The API is marked @ExperimentalMaterial3AdaptiveApi, so it's optional for the project.
Dialog destinations
@Serializable data object PickSessionTypeKey : NavKey
NavDisplay(
// ...
sceneStrategies = listOf(DialogSceneStrategy(), listDetailStrategy),
entryProvider = entryProvider {
entry<PickSessionTypeKey>(metadata = DialogSceneStrategy.dialog()) {
SessionTypePicker(/* ... */)
}
},
)
An entry with dialog() metadata is drawn as a dialog over the previous entry, but it's still a normal back-stack entry with its own ViewModel and saved state. Use it for small flows that deserve a destination. A plain confirmation is better as an AlertDialog controlled by UI state (lecture 07), like the stop confirmation in lecture 15.
Returning a result
Sometimes a screen returns a value to the one below it. Navigation 3 has a small result bus. Add its decorator, send a typed result from the top entry, and receive it with ResultEffect in the entry below:
data class SessionTypePicked(val typeId: String, val name: String)
NavDisplay(
// ...
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
rememberResultEventBusNavEntryDecorator(),
),
entryProvider = entryProvider {
entry<MapKey> {
val viewModel: MapViewModel = hiltViewModel()
ResultEffect<SessionTypePicked> { picked -> viewModel.onSessionTypePicked(picked) }
MapRoute(viewModel = viewModel, /* ... */)
}
entry<PickSessionTypeKey>(metadata = DialogSceneStrategy.dialog()) {
val resultBus = LocalResultEventBus.current
SessionTypePicker(onPick = { picked ->
resultBus.sendResult(picked)
backStack.removeLastOrNull()
})
}
},
)
Results are buffered until the receiver is composed again. They're not saved across rotation or process death. For anything important, write it to a repository instead and let both screens observe it. That's the single-source-of-truth answer from lecture 10, and usually the better one.
Navigating from outside: notification taps
Lecture 14 builds the tracking notification. Tapping it, or its Stop button, starts MainActivity with an action from TrackingActions:
TrackingActions.SHOW_MAP: just bring the map to front;TrackingActions.CONFIRM_STOP: bring the map to front and ask for stop confirmation there (lecture 15). Start and stop are never possible directly from the lock screen.
An Intent is an event from outside Compose. Convert it to state and let the navigation react to it:
<!-- AndroidManifest.xml: reuse the running activity, deliver new intents to onNewIntent -->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop">
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val launchAction = MutableStateFlow<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Only for a fresh start: after rotation `intent` is still the old one - don't replay it
if (savedInstanceState == null) launchAction.value = intent?.action
enableEdgeToEdge()
setContent {
SportMapTheme {
val action by launchAction.collectAsStateWithLifecycle()
SportMapNavigation(
launchAction = action,
onLaunchActionHandled = { launchAction.value = null },
)
}
}
}
// Activity already running (singleTop) - the new intent arrives here instead of onCreate
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
launchAction.value = intent.action
}
}
@Composable
fun SportMapNavigation(launchAction: String?, onLaunchActionHandled: () -> Unit) {
val backStack = rememberNavBackStack(MapKey)
LaunchedEffect(launchAction) {
when (launchAction) {
TrackingActions.SHOW_MAP -> { backStack.navigateTopLevel(MapKey); onLaunchActionHandled() }
TrackingActions.CONFIRM_STOP -> backStack.navigateTopLevel(MapKey) // the map handles the rest
}
}
NavDisplay(
backStack = backStack,
// ...
entryProvider = entryProvider {
entry<MapKey> {
MapRoute(
onOpenSessions = { backStack.add(SessionsKey) },
onOpenSettings = { backStack.add(SettingsKey) },
launchAction = launchAction,
onLaunchActionHandled = onLaunchActionHandled,
)
}
// ...
},
)
}
@Composable
fun MapRoute(
onOpenSessions: () -> Unit,
onOpenSettings: () -> Unit,
launchAction: String? = null,
onLaunchActionHandled: () -> Unit = {},
viewModel: MapViewModel = hiltViewModel(),
) {
// lecture 15: CONFIRM_STOP -> viewModel.onStopRequested() -> confirmation dialog, then onLaunchActionHandled()
// ... map, toolbar, statistics
}
The navigation only makes sure the map is on top, and hands the action to the map screen as a parameter. What the map does with CONFIRM_STOP is lecture 15's job: MapViewModel shows the stop confirmation dialog, and the route reports the action as handled. The pattern is the same as the one-off events in lecture 10. The event (intent) becomes state (launchAction), the consumer handles it and reports back (onLaunchActionHandled), and the state is cleared, so a rotation doesn't replay it. navigateTopLevel is idempotent, which makes it safe for the LaunchedEffect to run it twice.
The navigation 3 runtime also has a deep-link API (UriDeepLinkMatcher and friends) for https:// links into the app. SportMap only needs the actions above.
HW2: game and statistics
@Serializable data object GameKey : NavKey
@Serializable data object StatsKey : NavKey
@Composable
fun TicTacToeNavigation() {
val backStack = rememberNavBackStack(GameKey)
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = entryProvider {
entry<GameKey> { GameRoute(onOpenStats = { backStack.add(StatsKey) }) }
entry<StatsKey> { StatsRoute(onBack = { backStack.removeLastOrNull() }) }
},
)
}
GameRoute and StatsRoute are the routes from lecture 10 with one extra navigation callback each. The GameViewModel belongs to the GameKey entry. While the stats screen is on top, the game entry stays on the back stack, so the game is still there when you come back. Statistics come from GameStatsRepository (lecture 10), not from the game screen, so the stats screen doesn't need anything passed to it. In HW1 that screen was a separate activity, and here it's one more key.
Navigation 2 (NavController) - for reading other people's code
Before Navigation 3, Compose apps used Navigation Compose 2.x. Most tutorials, Stack Overflow answers and AI-generated code still produce it:
| Navigation Compose 2.x | Navigation 3 |
|---|---|
val navController = rememberNavController(), which owns the back stack | val backStack = rememberNavBackStack(MapKey): you own a list |
NavHost(navController, startDestination = MapRoute) { } | NavDisplay(backStack, entryProvider = entryProvider { }) |
composable<SessionDetailRoute> { entry -> } | entry<SessionDetailKey> { key -> } |
navController.navigate(SessionDetailRoute(42)) | backStack.add(SessionDetailKey(42)) |
navController.popBackStack() | backStack.removeLastOrNull() |
entry.toRoute<SessionDetailRoute>().sessionId | key.sessionId |
ViewModels scoped to NavBackStackEntry automatically | ViewModel store decorator |
nested navigation { } graphs, popUpTo, launchSingleTop | list operations in plain Kotlin |
Both are supported. Nav3 fits the Compose mental model better: navigation state is just state, observed like any other, and adaptive layouts (scenes) are built in. Use Navigation 3 in this course. When AI gives you NavHost, translate it with the table above.
In the final project
- Destinations:
MapKey(start),SessionsKey,SessionDetailKey(sessionId),SettingsKeyandLoginKey(lecture 19), all in oneNavDisplayinMainActivity. - Every entry gets its own ViewModel through
rememberViewModelStoreNavEntryDecorator().SessionDetailViewModelreceives the session id from the key through assisted injection. - The rotation requirement ("state has to be restored") is covered: the back stack is saved automatically, screen state lives in entry-scoped ViewModels, and the stop-confirmation dialog flag lives in
MapViewModel. - The notification's content tap (
SHOW_MAP) and Stop button (CONFIRM_STOP) arrive inMainActivity(singleTop,onNewIntent) and become navigation state, so stopping always requires the unlocked app and a confirmation. - For tablets (required: "all screen sizes are supported"), a navigation rail via
NavigationSuiteScaffoldand optionally list-detail for sessions.
Self preparation QA
- What is the back stack in Navigation 3, and who changes it? — An observable list of
NavKeys created withrememberNavBackStack. Your code changes it with ordinary list operations, andNavDisplayshows the result. - Why must keys be
@Serializable, and why should they carry ids rather than objects? — The back stack is saved into theBundleon rotation and process death. Ids keep the saved state small and make the repository the single source of truth. - How does a screen navigate without knowing about the back stack? — It exposes callbacks such as
onSessionClick(id), and the navigation code'sentrydecides what they do (e.g.backStack.add(SessionDetailKey(id))). - What do the two standard entry decorators do, and in which order are they listed? — The saveable-state decorator gives each entry its own
rememberSaveablestorage, and the ViewModel-store decorator gives each entry its own ViewModels, cleared on pop. Saveable state comes first, because the ViewModel decorator relies on it. - How does
SessionDetailViewModelget the session id into its constructor? — Hilt assisted injection:@HiltViewModel(assistedFactory = ...), an@AssistedInjectconstructor with an@Assisted sessionId, an@AssistedFactory, andhiltViewModel<VM, Factory>(creationCallback = { it.create(sessionId) }). - What happens on back when only one entry is left? —
NavDisplaydoesn't intercept back, and the system handles it, finishing the activity (back to home). - Why does overriding
onBackPressed()not work in a targetSdk 37 app? — Apps targeting API 36+ use predictive back by default.onBackPressedis no longer called; useNavDisplay'sonBackorBackHandler. - How does a tap on a notification end up showing a dialog on the map screen? — The notification starts
MainActivity(singleTop) with an action.onCreate/onNewIntentputs it into a state flow. ALaunchedEffectin the navigation putsMapKeyon top, and the action is passed to the map entry, where (lecture 15)MapViewModel.onStopRequested()sets the dialog state. Then the action is cleared. - Why check
savedInstanceState == nullbefore readingintent.actioninonCreate? — After rotation the recreated activity still has the original intent. Without the check, the dialog would be requested again on every rotation. - What does a list-detail scene strategy change in the back stack? — Nothing. The back stack is the same on phones and tablets, and the strategy only decides that list and detail entries are shown side by side when the window is wide enough.