Navigation in Compose (Navigation Compose and Navigation 3)
|
This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
Compose apps can reach for two different navigation libraries. Navigation Compose is the Compose front end
for the same AndroidX Navigation library used by
fragment-based navigation: a NavController drives a graph of
composable destinations, and the library owns the back stack internally. Navigation 3, a from-scratch
library first released stable in November 2025, inverts that relationship: the app itself owns the back stack as
an ordinary, observable list, and the library’s only job is to render whatever that list currently contains. This
page covers Navigation Compose 2.10.2 (androidx.navigation:navigation-compose) and Navigation 3 1.2.0
(androidx.navigation3:navigation3-runtime and androidx.navigation3:navigation3-ui), how to choose between
them, and how TrailMate’s Compose UI — introduced on
Jetpack Compose Fundamentals and built out on
Compose Layouts, Modifiers and Lazy Lists and
State, Recomposition and Side Effects in Compose — can be
wired up either way. ViewModel creation and scoping (hiltViewModel(), viewModel(), ViewModel lifecycle) is
introduced briefly here and covered in full on
ViewModel, Lifecycle-Aware Components, Coroutines and
Flow; Kotlin serialization (@Serializable), which both libraries use for type-safe routes, is a language
feature and is not retaught here.
Both libraries assume the Gradle setup from
Project Structure, Gradle and Build Variants plus the
org.jetbrains.kotlin.plugin.serialization Gradle plugin, which generates the KSerializer each route class
needs, and the org.jetbrains.kotlin.plugin.compose Compose compiler plugin every Compose module applies. On
AGP 9 the org.jetbrains.kotlin.android plugin is no longer applied: Kotlin support is built into AGP itself (see
Android Developers — Migrate to built-in Kotlin):
// app/build.gradle.kts
plugins {
id("com.android.application") // AGP 9.4.1: Kotlin is built in, no org.jetbrains.kotlin.android plugin
id("org.jetbrains.kotlin.plugin.compose") version "2.4.20" // Compose compiler
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.20" // generates route serializers
}
dependencies {
// Navigation Compose 2.10.2
implementation("androidx.navigation:navigation-compose:2.10.2")
// Navigation 3 1.2.0
implementation("androidx.navigation3:navigation3-runtime:1.2.0")
implementation("androidx.navigation3:navigation3-ui:1.2.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-navigation3:2.11.0")
implementation("androidx.compose.material3.adaptive:adaptive-navigation3:1.3.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
}
Navigation Compose 2.10: Type-Safe Routes, NavHost and NavController
Since Navigation 2.8, routes are ordinary Kotlin classes and objects annotated @Serializable, not string paths:
a screen with no arguments is a data object, a screen with arguments is a data class. TrailMate’s three
top-level routes are declared once and reused by both the graph and every navigate() call:
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailMateRoutes.kt
import kotlinx.serialization.Serializable
@Serializable
data object TrailList
@Serializable
data class TrailDetail(val trailId: String)
@Serializable
data object Settings
A NavHost associates a NavController with a graph built from these route types. Each destination is declared
with composable<T> instead of a string-keyed composable("route"), so the compiler checks that every
navigate() call and every argument read agrees with the route’s actual shape:
// app/src/main/kotlin/com/example/trailmate/TrailMateApp.kt
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
@Composable
fun TrailMateApp(navController: NavHostController = rememberNavController()) {
NavHost(navController = navController, startDestination = TrailList) {
composable<TrailList> {
TrailListScreen(
onTrailClick = { trailId -> navController.navigate(TrailDetail(trailId)) },
onSettingsClick = { navController.navigate(Settings) },
)
}
composable<TrailDetail> { backStackEntry ->
val route: TrailDetail = backStackEntry.toRoute()
TrailDetailScreen(trailId = route.trailId)
}
composable<Settings> {
SettingsScreen(onBackClick = { navController.popBackStack() })
}
}
}
toRoute<T>() deserializes the current NavBackStackEntry’s arguments back into the route instance, which is
why `TrailDetail(val trailId: String) needs no separate argument-parsing code — the same class is the route
and the typed argument bundle. Following the rule of
passing only lightweight identifiers between
destinations, TrailDetail carries only trailId, never a whole Trail; TrailDetailViewModel re-fetches the
full trail from TrailRepository using that ID (via SavedStateHandle, which Navigation Compose populates from
the same route arguments — see
ViewModel, Lifecycle-Aware Components, Coroutines and
Flow). See Android Developers — Navigation,
Android Developers — Type safety in
Navigation Compose and Android Developers — Navigate with NavController.
Controlling the Back Stack: popUpTo and launchSingleTop
Every navigate() call pushes a new entry by default, which is wrong for two common cases: re-selecting a
bottom-bar tab that is already showing (it would stack a duplicate copy of the same screen), and finishing a flow
by returning to an earlier destination while discarding everything pushed since (a "sign in, then land back on
whatever screen the user actually wanted" flow). launchSingleTop = true avoids the first case by reusing the
existing top-of-stack entry instead of pushing a duplicate; popUpTo avoids the second by popping destinations
off the stack, inclusive or not, before pushing the new one:
// A bottom-bar destination: never stack duplicates, and always return to a single instance
// of each tab instead of accumulating a deep stack of tab switches.
navController.navigate(TrailList) {
launchSingleTop = true
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
restoreState = true
}
// Finishing a "record hike" flow: drop RecordHike and everything pushed on top of it,
// landing back on TrailDetail.
navController.navigate(TrailDetail(trailId)) {
popUpTo<RecordHike> { inclusive = true }
}
popUpTo<T> (a reified, type-safe overload) and the string/ID overloads behave the same way; saveState/
restoreState additionally save and restore each destination’s own UI state (scroll position, rememberSaveable
values) across the pop, which is what makes repeated bottom-bar taps feel instantaneous instead of resetting each
tab. See Android Developers — Back stack and
multitasking with Navigation Compose.
Nested Graphs with navigation<T>
A nested graph groups a set of destinations that only make sense together — a multi-step flow, or a feature
module’s own screens — behind a single route that the outer graph navigates to. navigation<T> takes the same
@Serializable route pattern, plus a nested startDestination:
@Serializable data object HikeRecordingGraph
@Serializable data object RecordHike
@Serializable data class HikeSummary(val hikeId: String)
NavHost(navController = navController, startDestination = TrailList) {
composable<TrailList> { /* ... */ }
navigation<HikeRecordingGraph>(startDestination = RecordHike) {
composable<RecordHike> { RecordHikeScreen(/* ... */) }
composable<HikeSummary> { backStackEntry ->
val route: HikeSummary = backStackEntry.toRoute()
HikeSummaryScreen(hikeId = route.hikeId)
}
}
}
A ViewModel shared by every destination inside HikeRecordingGraph (recording state that must survive moving
from RecordHike to HikeSummary) is obtained by scoping hiltViewModel() to the graph’s back stack entry
rather than the current destination’s, using navController.getBackStackEntry(…):
composable<RecordHike> { backStackEntry ->
val graphEntry = remember(backStackEntry) {
navController.getBackStackEntry<HikeRecordingGraph>()
}
val sharedViewModel: HikeRecordingViewModel = hiltViewModel(graphEntry)
RecordHikeScreen(viewModel = sharedViewModel)
}
See Android Developers — Migrate to Navigation 3 (which contrasts this pattern with Navigation 3’s own scoping model, covered below) and Android Developers — Back stack and multitasking.
Deep Links
A type-safe deep link attaches a navDeepLink<T> (or the string/URI overload for legacy routes) to a
composable<T> destination, mapping an incoming URI’s path segments directly onto the route’s constructor
arguments:
composable<TrailDetail>(
deepLinks = listOf(navDeepLink<TrailDetail>(basePath = "https://trailmate.example.com/trails")),
) { backStackEntry ->
val route: TrailDetail = backStackEntry.toRoute()
TrailDetailScreen(trailId = route.trailId)
}
An intent filter in the manifest routes matching https://trailmate.example.com/trails/{trailId} URIs to
MainActivity, and the Navigation library resolves the rest — constructing the TrailDetail route, building a
synthetic back stack up to it, and delivering it to the running NavHost. See
Android Developers — Support deep
links (Navigation 3) for the contrasting, app-owned model Navigation 3 uses instead, and
App Components, the Manifest and Intents for the
manifest side of deep links.
Navigation 3 1.2: An App-Owned Back Stack
Navigation 3 (androidx.navigation3, stable since 1.0.0 on 2025-11-19, current 1.2.0) is not a new
version of the same library: it is a deliberately smaller runtime that renders whatever back stack the app hands
it, and leaves back-stack manipulation, persistence and scoping to a small set of composable helpers the app
wires up itself. Where Navigation Compose’s NavController is an opaque object the app calls methods on,
Navigation 3’s back stack is just a List<NavKey> the app can inspect, mutate and reason about directly.
Routes as NavKey
Every Navigation 3 route implements the marker interface NavKey and is @Serializable, so it can be
round-tripped through rememberSaveable and survive process death:
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailMateNavKeys.kt
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
@Serializable
data object TrailList : NavKey
@Serializable
data class TrailDetail(val trailId: String) : NavKey
@Serializable
data object Settings : NavKey
rememberNavBackStack and NavDisplay
rememberNavBackStack(vararg elements: NavKey) creates and remembers the back stack itself — a
NavBackStack<NavKey>, a SnapshotStateList under the hood — so it survives configuration changes and process
death the same way rememberSaveable state does. NavDisplay observes that list and renders whatever it
currently points at, resolving each NavKey to a composable through an entryProvider, most conveniently built
with the entryProvider { } DSL and its entry<T> builder:
// app/src/main/kotlin/com/example/trailmate/TrailMateApp.kt
import androidx.compose.runtime.Composable
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
@Composable
fun TrailMateApp() {
val backStack = rememberNavBackStack(TrailList)
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider {
entry<TrailList> {
TrailListScreen(
onTrailClick = { trailId -> backStack.add(TrailDetail(trailId)) },
onSettingsClick = { backStack.add(Settings) },
)
}
entry<TrailDetail> { key ->
TrailDetailScreen(trailId = key.trailId)
}
entry<Settings> {
SettingsScreen(onBackClick = { backStack.removeLastOrNull() })
}
},
)
}
Navigating forward is backStack.add(TrailDetail(trailId)); navigating back is backStack.removeLastOrNull().
There is no NavController, no popUpTo DSL and no launchSingleTop flag — both are just list operations the
app writes directly (backStack.removeAll { it is RecordHike } to drop a flow, if (backStack.lastOrNull() !=
TrailList) backStack.add(TrailList) to avoid a duplicate top entry), which is the trade-off Navigation 3 makes:
less API surface to learn, more explicit code at each call site. See
Android Developers — Navigation 3,
Android Developers — Get started with
Navigation 3, Android Developers — Understand the basics of Navigation 3 and
Android Developers — Save and manage
navigation state.
Entry Decorators: Saveable State and ViewModel Scoping
NavDisplay applies a list of entryDecorators to every entry before rendering it. The default,
rememberSaveableStateHolderNavEntryDecorator(), gives each entry its own rememberSaveable scope so that state
saved by an entry that later moves off-screen (but stays on the back stack) is restored correctly when it comes
back. The androidx.lifecycle:lifecycle-viewmodel-navigation3 add-on contributes a second decorator,
rememberViewModelStoreNavEntryDecorator(), which scopes a ViewModelStore to each back-stack entry — a
ViewModel obtained inside an entry<T> block is created when that key is pushed and cleared when it is popped,
mirroring what a NavController-owned back-stack entry does for hiltViewModel() in Navigation Compose.
Passing a custom entryDecorators list replaces the default, so the saveable-state decorator must stay first in
it.
Unlike Navigation Compose, Navigation 3 does not copy a NavKey’s fields into the entry’s
`SavedStateHandle, so savedStateHandle.toRoute<TrailDetail>() finds no arguments there. The key’s data is
passed to the ViewModel explicitly instead — with Hilt, through assisted injection and the
hiltViewModel(creationCallback = …) overload, as introduced on
Dependency Injection with Hilt, Dagger and Koin:
// app/src/main/kotlin/com/example/trailmate/TrailMateApp.kt
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
// TrailDetailViewModel is declared with
// @HiltViewModel(assistedFactory = TrailDetailViewModel.Factory::class) and an
// @AssistedInject constructor taking @Assisted trailId: String.
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = entryProvider {
entry<TrailDetail> { key ->
val viewModel = hiltViewModel<TrailDetailViewModel, TrailDetailViewModel.Factory>(
creationCallback = { factory -> factory.create(key.trailId) },
)
TrailDetailScreen(viewModel = viewModel)
}
},
)
See Android Developers — Apply logic to destinations with NavEntryDecorators and Android Developers — Use Hilt with other Jetpack libraries.
Sharing one ViewModel across several entries (the HikeRecordingGraph case from Navigation Compose above) is
now a matter of scoping the ViewModel lookup to a shared key the app chooses, rather than to a graph route the
library defines — for example, obtaining it once above the whole NavDisplay and passing it down, since there
is no separate "nested graph" concept in Navigation 3’s flat back stack. See
Android Developers — Apply logic
to destinations with NavEntryDecorators and
ViewModel, Lifecycle-Aware Components, Coroutines and
Flow for ViewModel scoping in general.
Scenes: List-Detail and Other Multi-Pane Layouts
A Scene is the unit NavDisplay actually renders: normally one NavEntry fills the whole scene, but a
SceneStrategy can combine several back-stack entries into one multi-pane Scene — the mechanism behind
adaptive, large-screen layouts. androidx.compose.material3.adaptive:adaptive-navigation3 ships a ready-made
list-detail strategy, rememberListDetailSceneStrategy<NavKey>(), plus the ListDetailSceneStrategy.listPane()/
detailPane()/extraPane() companion functions, which build the metadata that tags which entries belong to which
pane:
// app/src/main/kotlin/com/example/trailmate/TrailMateApp.kt
import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy
import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy
import androidx.compose.runtime.Composable
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
@Composable
fun TrailMateApp() {
val backStack = rememberNavBackStack(TrailList)
val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
sceneStrategies = listOf(listDetailStrategy),
entryProvider = entryProvider {
entry<TrailList>(metadata = ListDetailSceneStrategy.listPane()) {
TrailListScreen(
onTrailClick = { trailId ->
// Replace the shown detail instead of stacking one detail per click.
backStack.removeAll { it is TrailDetail }
backStack.add(TrailDetail(trailId))
},
)
}
entry<TrailDetail>(metadata = ListDetailSceneStrategy.detailPane()) { key ->
TrailDetailScreen(trailId = key.trailId)
}
},
)
}
On a wide window, TrailListScreen and TrailDetailScreen render side by side in one ListDetailScene; on a
narrow window, NavDisplay falls back to its default SinglePaneSceneStrategy and shows one entry at a time,
with no change to the destinations themselves. See
Adaptive Layouts, Edge-to-Edge and Large
Screens for window size classes and canonical layouts in general, and
Android Developers — Overview of scenes for
writing a custom SceneStrategy.
The Results API
Since Navigation 3 1.2.0, a screen returns a result to whichever entry navigated to it through a
ResultEventBus. The bus is supplied to every entry by adding rememberResultEventBusNavEntryDecorator() to
NavDisplay’s `entryDecorators — without it, LocalResultEventBus.current has no provider and fails at
runtime — and is read with LocalResultEventBus.current. This replaces the SavedStateHandle-based "previous
back stack entry" result pattern Navigation Compose uses:
// app/src/main/kotlin/com/example/trailmate/TrailMateApp.kt
import android.net.Uri
import androidx.compose.runtime.getValue
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.runtime.result.LocalResultEventBus
import androidx.navigation3.runtime.result.rememberResultEventBusNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberResultEventBusNavEntryDecorator(), // provides LocalResultEventBus
),
entryProvider = entryProvider {
// A photo-capture screen sends its result back to whoever pushed it.
entry<PhotoCapture> {
val resultBus = LocalResultEventBus.current
PhotoCaptureScreen(
onPhotoCaptured = { photoUri: Uri ->
resultBus.sendResult(resultKey = "captured_photo", result = photoUri)
backStack.removeLastOrNull()
},
)
}
// TrailDetailScreen observes the latest result as Compose state.
entry<TrailDetail> { key ->
val resultBus = LocalResultEventBus.current
val capturedPhoto by resultBus.conflateAsState<Uri?>(
resultKey = "captured_photo",
defaultValue = null, // shown until a result arrives
)
TrailDetailScreen(trailId = key.trailId, capturedPhoto = capturedPhoto)
}
},
)
ResultEffect is the event-based counterpart for a one-shot result (a snackbar shown exactly once) instead of an
observed, replayable value. Results delivered this way live only in memory and do not by themselves survive
process death; an app that needs that guarantee still persists the value (a repository, SavedStateHandle) as
well. See Android Developers — Return
results.
Predictive Back and Deep Links
NavDisplay participates in the system predictive-back gesture out of the box: as the user swipes back, it
previews the destination underneath before committing the pop, the same way the Views-based predictive-back
transitions do. No extra wiring is required beyond targeting an SDK where predictive back is enabled and letting
NavDisplay own the onBack callback, as in the examples above; see
Adaptive Layouts, Edge-to-Edge and Large
Screens for the platform-wide predictive-back rules that apply to every screen, Compose or Views.
Deep links are, consistently with the rest of Navigation 3, something the app builds itself rather than something
the library resolves for it. A DeepLinkMatcher (a UriDeepLinkMatcher for a URI pattern given as a
DeepLinkUri, a StaticKeyDeepLinkMatcher for a fixed key, or a custom implementation) turns an incoming
DeepLinkRequest into a match result carrying a NavKey, optionally wrapped by withBackStack { } — which then
yields a BackStackMatchResult — to synthesize the intermediate entries the deep link implies (so
opening a trail deep link still lands on a back stack of [TrailList, TrailDetail(id)], not just
[TrailDetail(id)]):
// app/src/main/kotlin/com/example/trailmate/TrailMateDeepLinks.kt
import android.content.Intent
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.deeplink.BackStackMatchResult
import androidx.navigation3.runtime.deeplink.DeepLinkMatcher
import androidx.navigation3.runtime.deeplink.DeepLinkRequest
import androidx.navigation3.runtime.deeplink.DeepLinkUri
import androidx.navigation3.runtime.deeplink.UriDeepLinkMatcher
import androidx.navigation3.runtime.deeplink.withBackStack
import kotlinx.serialization.serializer
private val trailDetailMatcher = UriDeepLinkMatcher(
uriPattern = DeepLinkUri("https://trailmate.example.com/trails/{trailId}"),
serializer = serializer<TrailDetail>(),
).withBackStack { matchResult -> listOf(TrailList, matchResult.key) }
private val deepLinkMatchers: List<DeepLinkMatcher<*, *>> = listOf(trailDetailMatcher)
// Called from MainActivity.onCreate() (and onNewIntent()) to build the initial back stack.
fun initialBackStack(intent: Intent): List<NavKey> {
val request = DeepLinkRequest(intent = intent)
val matchResult = deepLinkMatchers.mapNotNull { it.match(request) }.maxOrNull()
return when (matchResult) {
null -> listOf(TrailList)
is BackStackMatchResult<*, *> -> {
@Suppress("UNCHECKED_CAST")
matchResult.backStack as List<NavKey>
}
else -> listOf(matchResult.key as NavKey)
}
}
MainActivity passes the result to rememberNavBackStack(*initialBackStack(intent).toTypedArray()), so a cold
start from a deep link begins on [TrailList, TrailDetail(id)] while a normal launch begins on [TrailList].
Choosing Between Navigation Compose and Navigation 3
| Concern | Navigation Compose 2.10 | Navigation 3 1.2 |
|---|---|---|
Back stack ownership |
Owned by |
Owned by the app, an ordinary observable |
Popping/single-top behavior |
|
Plain list operations ( |
ViewModel scoped to a destination |
|
|
Sharing state across a flow |
A nested graph’s shared |
App-defined scoping (no built-in nested-graph concept) |
Adaptive, multi-pane layouts |
Composed by hand ( |
Built in via |
Returning a result to the previous screen |
|
|
Deep links |
Declared per destination ( |
Built by the app ( |
Maturity (as of this baseline) |
Stable since 2011, type-safe routes since 2.8 (2024) |
Stable since 1.0.0 (Nov 2025); 1.2.0 adds results, predictive back polish |
Navigation Compose remains the default choice for most apps: it is stable, its patterns (popUpTo,
launchSingleTop, nested graphs, hiltViewModel() scoping) are well understood, and most third-party libraries
and samples still target it. Navigation 3 is worth reaching for when an app already needs the kind of control its
explicit back stack gives — complex, conditional flows that are awkward to express as a static graph, or an
adaptive, multi-pane UI where scenes are a better fit than composing several navigation hosts by hand — and is
the direction Google is investing in going forward. A codebase does not have to choose exactly one: TrailMate’s
legacy, Views-based screens keep using
the classic Navigation component regardless of which of the two
Compose options its Compose screens use.
TrailMate’s Bottom Bar with NavigationSuiteScaffold
TrailMate’s three top-level destinations (TrailList, a hikes list, Settings) are shown behind a bottom
navigation bar on phones and a navigation rail on larger windows. NavigationSuiteScaffold picks the right
container for the current window size automatically, and works the same way regardless of which navigation
library renders the content passed to it:
// app/src/main/kotlin/com/example/trailmate/TrailMateApp.kt
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScope
private val topLevelDestinations = listOf(TrailList, Hikes, Settings)
@Composable
fun TrailMateApp() {
val backStack = rememberNavBackStack(TrailList)
NavigationSuiteScaffold(
navigationSuiteItems = {
topLevelDestinations.forEach { destination ->
item(
selected = backStack.lastOrNull() == destination,
onClick = {
if (backStack.lastOrNull() != destination) {
backStack.removeAll { it in topLevelDestinations }
backStack.add(destination)
}
},
icon = { /* ... */ },
label = { /* ... */ },
)
}
},
) {
NavDisplay(backStack = backStack, onBack = { backStack.removeLastOrNull() }, entryProvider = { /* ... */ })
}
}
The equivalent with Navigation Compose replaces the click handler’s list manipulation with the launchSingleTop/
popUpTo call shown earlier and keys selected off navController.currentBackStackEntryAsState() instead of
backStack.lastOrNull(). `ViewModel`s scoped per bottom-bar destination (so switching tabs does not reset each
screen’s state) are exactly the "ViewModel scopes" case covered on
ViewModel, Lifecycle-Aware Components, Coroutines and
Flow; `NavigationSuiteScaffold’s adaptive layout mechanics are covered in more depth on
Adaptive Layouts, Edge-to-Edge and Large
Screens.
Testing Navigation
Navigation Compose’s NavController can be swapped out for a TestNavHostController in a Compose UI test, which
lets a test assert on the current destination without running the real NavHost:
// feature/trails/src/androidTest/kotlin/com/example/trailmate/feature/trails/TrailListScreenTest.kt
@get:Rule val composeRule = createComposeRule()
@Test
fun clickingATrail_navigatesToDetail() {
lateinit var navController: TestNavHostController
composeRule.setContent {
navController = TestNavHostController(LocalContext.current).apply {
navigatorProvider.addNavigator(ComposeNavigator())
}
TrailMateNavHost(navController = navController)
}
composeRule.onNodeWithText("Rocky Ridge").performClick()
val route: TrailDetail = navController.currentBackStackEntry!!.toRoute()
assertThat(route.trailId).isEqualTo("trail-1")
}
Navigation 3 needs no equivalent fixture: because the back stack is a plain List<NavKey> the app owns, a test
drives navigation by mutating that list directly (backStack.add(TrailDetail("trail-1"))) and asserts on
backStack.last(), without any NavController-shaped test double. Compose UI testing in general — semantics
trees, composeRule, matchers — is covered on
Instrumented and UI Testing (Espresso, Compose Testing, UI
Automator). See Android Developers — Test navigation
for the full TestNavHostController API.
Migrating from Navigation 2 to Navigation 3
Because the two libraries model the back stack so differently, migrating a screen is closer to a rewrite of its
navigation wiring than a version bump: NavController calls become list mutations, NavHost’s graph-builder DSL
becomes an `entryProvider, SavedStateHandle-based results become ResultEventBus calls, and any code that
relied on a NavBackStackEntry’s lifecycle or `hiltViewModel() scoping needs the NavDisplay entry-decorator
equivalents shown above. The official guide walks through the mapping destination by destination and is the
right starting point for a real migration rather than attempting it from this page’s summary alone; it also
documents an incremental path (running both libraries side by side, one feature module at a time) rather than
requiring an all-at-once cutover. See
Android Developers — Migrate to
Navigation 3.
Figure: Back-Stack Operations
The following figure summarizes how the same three operations — push, pop and "pop up to, then push" — change
the back stack in both libraries, since the vocabulary differs (navigate()/popBackStack()/popUpTo versus
add()/removeLastOrNull()/removeAll()) even though the resulting stack shapes are identical.