Skip to main content

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 @Serializable NavKeys, including keys with arguments.
  • Display the back stack with NavDisplay and an entryProvider.
  • 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.
Demo code

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.

ConceptWhat 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
entryProvidera function from key to entry, written with a small DSL
NavDisplaythe composable that observes the back stack and shows the top entry (or several, in a scene)
decoratorsadd per-entry features: saved state, ViewModel store, results
scenesdecide how many entries are shown at once: one pane, list + detail, dialog

Navigating is list manipulation:

ActionCode
go to a screenbackStack.add(SessionsKey)
go backbackStack.removeLastOrNull()
replace the current screenbackStack[backStack.lastIndex] = LoginKey
back to the startwhile (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 a data class.
  • NavKey is a marker interface. @Serializable lets rememberNavBackStack save 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 the Bundle size limit (TransactionTooLargeException).
// 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 with MapKey as the start destination and saves it automatically.
  • entry<SessionDetailKey> { key -> } is typed. Inside the lambda, key is a SessionDetailKey, so arguments are just properties (key.sessionId).
  • onBack is called for the system back button and gesture. When only one entry is left, NavDisplay doesn'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 }
warning

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 own rememberSaveable storage. 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() (from lifecycle-viewmodel-navigation3) gives every entry its own ViewModelStore. hiltViewModel() inside an entry returns a ViewModel that lives exactly as long as that entry is on the back stack, and onCleared() runs when it's popped. Put it after the saveable-state decorator, because it relies on it for SavedStateHandle.
Without the ViewModel decorator

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) }
}
  • @AssistedInject replaces @Inject on the constructor, and @Assisted marks the caller-supplied parameter.
  • The @AssistedFactory interface is implemented by Hilt. hiltViewModel<VM, Factory>(creationCallback = ...) (from androidx.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) and SessionDetailKey(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.

SportMap design

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.

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.

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.xNavigation 3
val navController = rememberNavController(), which owns the back stackval 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>().sessionIdkey.sessionId
ViewModels scoped to NavBackStackEntry automaticallyViewModel store decorator
nested navigation { } graphs, popUpTo, launchSingleToplist 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), SettingsKey and LoginKey (lecture 19), all in one NavDisplay in MainActivity.
  • Every entry gets its own ViewModel through rememberViewModelStoreNavEntryDecorator(). SessionDetailViewModel receives 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 in MainActivity (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 NavigationSuiteScaffold and optionally list-detail for sessions.

Self preparation QA​

  1. What is the back stack in Navigation 3, and who changes it? — An observable list of NavKeys created with rememberNavBackStack. Your code changes it with ordinary list operations, and NavDisplay shows the result.
  2. Why must keys be @Serializable, and why should they carry ids rather than objects? — The back stack is saved into the Bundle on rotation and process death. Ids keep the saved state small and make the repository the single source of truth.
  3. How does a screen navigate without knowing about the back stack? — It exposes callbacks such as onSessionClick(id), and the navigation code's entry decides what they do (e.g. backStack.add(SessionDetailKey(id))).
  4. What do the two standard entry decorators do, and in which order are they listed? — The saveable-state decorator gives each entry its own rememberSaveable storage, 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.
  5. How does SessionDetailViewModel get the session id into its constructor? — Hilt assisted injection: @HiltViewModel(assistedFactory = ...), an @AssistedInject constructor with an @Assisted sessionId, an @AssistedFactory, and hiltViewModel<VM, Factory>(creationCallback = { it.create(sessionId) }).
  6. What happens on back when only one entry is left? — NavDisplay doesn't intercept back, and the system handles it, finishing the activity (back to home).
  7. Why does overriding onBackPressed() not work in a targetSdk 37 app? — Apps targeting API 36+ use predictive back by default. onBackPressed is no longer called; use NavDisplay's onBack or BackHandler.
  8. 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/onNewIntent puts it into a state flow. A LaunchedEffect in the navigation puts MapKey on top, and the action is passed to the map entry, where (lecture 15) MapViewModel.onStopRequested() sets the dialog state. Then the action is cleared.
  9. Why check savedInstanceState == null before reading intent.action in onCreate? — After rotation the recreated activity still has the original intent. Without the check, the dialog would be requested again on every rotation.
  10. 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.