The NavHost registers an OnBackPressedCallback that is enabled while its back stack holds more than one entry, so Back pops the NavController's stack first; only at the start destination does Back fall through and finish the Activity.
Two stacks: the system task holds Activities; the NavController holds NavBackStackEntry objects inside one Activity. Neither knows about the other.
Your own handler: a BackHandler(enabled = dirty) in a screen sits above the NavHost's callback while that screen is composed. That is how a "discard changes?" dialog intercepts Back.
Where they disagree: a second Activity starts with its own one-entry nav stack, and a deep link into a flat graph synthesizes only Home -> Target, so Back leaves sooner than the user expects.
AndroidMiddle
Android Navigation
Navigation Component, Safe Args, type-safe routes, deep links, back stack, predictive back
Questions closed
0 / 20
- 0Review
- 2Asked often
Questions
Back is chronological: it reverses the user's last step and can leave your app. Up is hierarchical: it goes to the logical parent screen and never leaves your app.
In Navigation, Back goes through the OnBackPressedDispatcher to popBackStack(); Up is the arrow in the top app bar, wired to navController.navigateUp().
They match when the user arrived through the hierarchy. After a deep link from another app they differ: Back returns to that app, Up opens the parent inside yours.
Top-level destinations (by default only the start destination) show no Up arrow.
An argument is a copy frozen into a Bundle at navigation time, so a whole object goes stale, eats into the saved-state size limit and cannot come from a deep link. Pass the id and let the screen load current data from the repository.
Staleness: a sync or an edit on another screen changes the row, and the detail screen keeps showing the old copy.
Size: arguments are written into saved instance state, which crosses Binder with roughly 1 MB for the whole transaction; a big object ends in TransactionTooLargeException.
Reachability: a deep link or a notification can only supply what fits in a URL, and after process death the stack is rebuilt from the saved arguments alone.
Fast first frame: if needed, add one or two light fields (a title) next to the id.
The ViewModel reads the id from SavedStateHandle and observes the repository.
For XML graphs the Safe Args Gradle plugin generates Directions and Args classes from <argument> tags; since Navigation 2.8 you can instead declare a @Serializable route class, navigate with an instance of it and read it back with toRoute(). Both end up in a Bundle.
Safe Args: plugin androidx.navigation.safeargs.kotlin. It generates ListFragmentDirections.actionListToDetail(orderId) for the caller and DetailFragmentArgs, read with by navArgs(). Types come from app:argType.
Type-safe routes: nothing is generated in your code; kotlinx.serialization derives the route and a NavType for each field. A custom type needs an entry in typeMap.
In the ViewModel: savedStateHandle.toRoute<Detail>() gives the same object, also after process death.
Legacy: string routes like "detail/{id}" with navArgument still compile, but nothing checks keys or types at build time.
Limits: only Bundle data: primitives, strings, enums, Parcelable/Serializable. No lambdas, Context, Bitmap or Flow.
popUpTo(X) pops everything above X before navigating, inclusive = true pops X as well, and launchSingleTop reuses the destination if it is already on top. After sign-in, one navigate(Home) with popUpTo<AuthGraph> { inclusive = true } leaves no way back.
**Missing inclusive: the target stays on the stack and Back lands on Login again. This is the most common version of the bug.
Wipe everything**: pop the root graph inclusively: popUpTo(navController.graph.id) { inclusive = true }. The root graph entry is always on the stack.
**launchSingleTop: a double tap or a duplicated event does not push a second copy.
Silent no-op**: popUpTo with a route that is not on the stack pops nothing and only writes an info-level log line (Ignoring popBackStack to destination ...). Check logcat first when the stack looks wrong.
One call: a chain of popBackStack() calls followed by navigate flashes intermediate screens.
**No saveState here**: otherwise the next sign-in can restore the previous user's entries and ViewModels.
A nested graph groups destinations into a flow with its own start destination, and it sits on the back stack as an entry of its own. Using that entry as the ViewModelStoreOwner gives every screen of the flow one shared ViewModel, cleared when the flow is popped.
Declaring it: navigation<WizardGraph>(startDestination = Step1) { ... }, or <navigation> in XML. Callers navigate to the graph, not to its inner screens.
Getting the shared instance: hiltViewModel(navController.getBackStackEntry<WizardGraph>()); in Fragments, by navGraphViewModels(R.id.wizard_graph) or hiltNavGraphViewModels.
Key the lookup: wrap it in remember(entry) on the current destination's entry, so you never keep a graph entry from an earlier visit.
Only inside the graph: getBackStackEntry throws IllegalArgumentException when the graph is not on the stack.
The silent bug: plain hiltViewModel() in each step gives each step its own instance, and nothing warns you.
Process death: the ViewModel is gone; keep what must survive in its SavedStateHandle or in storage.
Write the value into previousBackStackEntry.savedStateHandle and pop; the caller observes its own entry's SavedStateHandle. This survives rotation and process death, unlike a callback or a shared ViewModel.
Bundle-able values: send an id, not the object.
Read once: observe with getStateFlow(...) and collectAsStateWithLifecycle(), act, then remove() the key, or the old result is applied again on the next visit.
Fragments: the Fragment Result API (setFragmentResult / setFragmentResultListener) does the same for Fragment and dialog destinations.
Shared ViewModel: fine inside one flow, but lost on process death unless backed by SavedStateHandle.
Callbacks: a lambda cannot be a route argument, and a singleton "result bus" leaks the caller and delivers to a screen that may be gone.
The result is written after popBackStack(). The pop is synchronous, so by then previousBackStackEntry is the entry below Search (here Home): the city lands in Home's SavedStateHandle, and Search never sees it.
Why it happens: popBackStack() updates the back stack before the next line runs: currentBackStackEntry is now Search and previousBackStackEntry is Home. Had Search been the start destination, previousBackStackEntry would be null and the ?. chain would drop the result silently.
The fix: write into previousBackStackEntry.savedStateHandle while the picker is still on top, then pop. Search's getStateFlow emits the id when the screen is composed again.
Catch it next time: the symptom is silent (no exception; the value lands in the wrong entry's SavedStateHandle, or nowhere), so cover it with a TestNavHostController test: navigate to Search, then to CityPicker, invoke the pick, and assert currentBackStackEntry?.savedStateHandle?.get<Long>("city_id") == 7L.
Every tap is a plain navigate, so tabs pile up on one back stack and nothing is saved. A tab switch must pop to the start destination with saveState = true and navigate with restoreState = true and launchSingleTop = true.
**popUpTo(graph.findStartDestination().id) { saveState = true }: takes the outgoing tab off the stack and stashes its entries: arguments, saved state and ViewModels.
restoreState = true**: brings back the incoming tab's stash: its depth, rememberSaveable values and LazyListState scroll.
**launchSingleTop = true: repeated taps on the same tab do not stack copies of its root.
selected**: check destination.hierarchy, not the destination itself, so the tab stays highlighted while the user is deep inside it. Give each tab its own nested graph.
Not restored: plain remember, because the composition is disposed, and LaunchedEffect(Unit) runs again on every visit. Each saved tab keeps its ViewModels in memory.
A composable body can run many times (recomposition, transition frames), so a navigate() written there fires repeatedly and stacks Login. Navigation is a side effect: put it in LaunchedEffect or a ViewModel event, or better, pick the start destination before the NavHost exists.
Why twice: the body re-runs while the old entry is still composed during the exit transition, so each pass calls navigate() again and pushes another Login.
Decide up front: once the session is known, pick startDestination = Home or AuthGraph before showing the NavHost. A signed-out user never reaches Profile.
Per-screen check: the ViewModel emits an event through Channel(...).receiveAsFlow(), collected in one LaunchedEffect. A StateFlow flag replays after rotation and navigates again.
Guard anyway: launchSingleTop = true on the Login call, and pop the auth graph inclusively after sign-in.
LaunchedEffect(loggedIn) { if (!loggedIn) navigate(Login) } is the minimal fix; deciding up front or from the ViewModel keeps Profile from composing at all.
Result back: Login reports success via previousBackStackEntry.savedStateHandle (a LOGIN_SUCCESSFUL flag), as in the Android conditional-navigation guide.
Nothing stops a second tap: during the exit transition the list is still on screen and clickable, so a double tap calls navigate() twice and pushes OrderDetail twice. Wrapping the click in dropUnlessResumed ignores every tap after the first navigation.
Why it happens: navigate() pushes the entry synchronously, but NavHost animates the switch (a fade by default), and the outgoing screen keeps receiving input until it leaves composition. Each tap is a separate navigate() with its own entry.
How the fix works: dropUnlessResumed (lifecycle-runtime-compose 2.8+) checks the LocalLifecycleOwner, here the screen's NavBackStackEntry, at click time. The first navigate() moves the Orders entry from RESUMED to STARTED, so the second tap is dropped.
**launchSingleTop is a partial defence**: it only covers a repeat of the same destination on top; taps on two different rows (OrderDetail(1), then OrderDetail(2)) or on a row plus a toolbar icon still stack screens, while dropUnlessResumed blocks all of them.
Without the helper: the same check by hand: navigate only if entry.lifecycle.currentState == Lifecycle.State.RESUMED.
Catch it next time: treat a bare navigate() inside onClick as a review flag, and include double taps on list rows in QA checks.
When a deep link starts a new task, Navigation builds the back stack from the graph hierarchy: the start destination of each parent graph. Put OrderDetail in a nested graph whose start destination is OrderList, and Back goes to the list.
Flat graph: if detail is a sibling of the start destination, the synthesized stack is just Home -> OrderDetail; there is no list to return to.
Your own notifications: build the PendingIntent with TaskStackBuilder and an ACTION_VIEW intent, or with NavDeepLinkBuilder for XML graphs; the same hierarchy rule fills in the middle.
**onNewIntent**: with a singleTop or singleTask Activity, later links arrive there, not in onCreate. Forward them with navController.handleDeepLink(intent) or the tap does nothing.
Test from a cold start: adb shell am start -a android.intent.action.VIEW -d "https://shop.example.com/order/42", not by navigating inside the running app.
Synthesized entries: they were never on screen, so the list loads its data only when the user goes back.
A deep link is any URI your intent filter matches, so the user may get a chooser. An App Link is an https link with android:autoVerify="true" that the system verifies against your domain's assetlinks.json and then opens straight in your app.
Verification file: https://<host>/.well-known/assetlinks.json lists your package name and the SHA-256 fingerprint of the signing certificate. With Play App Signing that is the Play key from the Console, not your upload key.
Android 12+: an unverified web link opens in the browser instead of a chooser, so a broken assetlinks.json silently sends users to the website.
Custom schemes like myapp:// cannot be verified; any app can register them, so never trust their parameters.
Debugging: adb shell pm get-app-links <package> shows the state per domain, adb shell pm verify-app-links --re-verify <package> runs it again.
Navigation: the destination still declares navDeepLink; the manifest needs the <intent-filter> (or <nav-graph> for XML graphs).
Each destination on the stack gets its own lifecycle, ViewModel store and saved state, all owned by its entry. They survive rotation and being covered by another screen, and are destroyed together when the entry is popped.
Lifecycle: the top entry is RESUMED, an entry covered by a full-screen destination is CREATED, and one under a dialog destination is STARTED. collectAsStateWithLifecycle() inside a destination follows the entry, so it stops when the screen is covered.
ViewModelStore: viewModel() or hiltViewModel() inside composable { } is scoped to the entry; onCleared() runs on pop, not on rotation.
Saved state: rememberSaveable and the ViewModel's SavedStateHandle live in the entry and are restored after process death.
Composition vs entry: an off-screen entry's composition is disposed while the entry lives on: rememberSaveable survives, remember does not.
Two entries, two ViewModels: navigating to the same route twice shares nothing.
The exception: popping with saveState = true keeps the entry's state and ViewModels for a later restoreState.
Single Activity is the default: one NavHost owns the whole back stack, so transitions, shared ViewModels and deep links live in one place. A second Activity is worth it only where a task or window boundary is the point.
What one Activity buys: one back stack, graph-scoped ViewModels across a flow, consistent transitions and predictive back, one deep-link entry point.
When a second one fits: a system entry point that may run in its own task (a share target for ACTION_SEND, a widget configuration screen), a different launchMode, taskAffinity or process, or a window with its own theme and orientation such as a camera or video player.
Not a reason: sign-in, onboarding or a modal flow. Make those nested graphs.
Legacy apps: migrate screen by screen: a NavHost per Activity first, then merge the Activities.
Predictive back shows a preview of where Back leads while the user is still swiping, so the system must know before the gesture starts whether your app will handle it. You declare that with enabled callbacks instead of overriding onBackPressed().
Turning it on: android:enableOnBackInvokedCallback="true" in the manifest. For apps targeting Android 16 (API 36) it is the default, and onBackPressed() and KEYCODE_BACK are no longer delivered.
Migration: replace onBackPressed() overrides with OnBackPressedCallback in Views or BackHandler in Compose, with isEnabled driven by state. A callback that is always enabled and decides inside the handler blocks the animation.
Free animations: back-to-home and cross-Activity come from the system. Navigation Compose 2.8+ previews the previous screen using your popEnterTransition and popExitTransition; Material 3 sheets, drawers and search bars follow the finger.
Custom animation: PredictiveBackHandler gives a Flow<BackEventCompat>: follow progress, commit when the flow completes, restore on CancellationException.
Keep screens unaware of the NavController (they take lambdas like onOpenOrder: (Long) -> Unit) and test them by asserting the lambda. Test the graph wiring separately with TestNavHostController and assert the back stack.
Screens: ordinary Compose UI tests; no navigation library involved, so they stay fast.
Graph: TestNavHostController from navigation-testing, with ComposeNavigator added, passed to your real NavHost. Click, then check currentBackStackEntry?.destination?.hasRoute<OrderDetail>() and read arguments with toRoute<OrderDetail>().
**popUpTo claims**: assert the whole currentBackStack.value, not only the top entry.
Deep links: navController.navigate(uri) only checks that the link matches. To see the synthesized parent stack, deliver a VIEW Intent with handleDeepLink(intent) or launch the Activity with it (ActivityScenario, or adb shell am start by hand).
Only :app should see every feature: each feature exposes a NavGraphBuilder extension and takes outgoing navigation as lambdas, and :app wires the lambdas. The other option is a shared :core:navigation module that holds only route classes.
Builders up, lambdas down
fun NavGraphBuilder.checkoutSection(onOpenProfile: (Long) -> Unit); screens stay internal. This is the Now in Android setup.
Features stay independent and testable in isolation. The cost is long lambda lists in :app.
Shared route module
:core:navigation contains only @Serializable routes, so navigate(Profile(id)) type-checks from any feature.
The cost: it grows into a catalogue of every screen, and a change to it recompiles every feature.
Split each feature into :feature:x:api (routes only) and :feature:x:impl instead of one :core:navigation; then only the small api modules are shared.
Without a direct reference
**Ordinary modules that :app should not wire by hand**: each feature binds a NavGraphBuilder.() -> Unit into a Hilt @IntoSet multibinding and :app installs them in a loop.
Dynamic feature modules (not visible at build time): navigate by Uri matched by the target's navDeepLink; Fragment graphs also have navigation-dynamic-features-fragment with <include-dynamic>.
Indirection loses the compiler: a link that no longer matches fails at runtime. Add a test that resolves every declared link.
A linear back stack assumes one visible destination. With two panes, List -> Detail claims the user left a list that is still on screen, and Back seems to do nothing. Keep list-detail as one destination and let a pane navigator hold the selection.
What breaks
Back pops to a list that never went away.
Folding or rotating to a compact width means reinterpreting the stack, and window-size checks spread around every navigate call.
What works
One NavController destination for the whole screen. Inside it, rememberListDetailPaneScaffoldNavigator<Long>() from Material 3 adaptive holds the pane history and the selection, saved across configuration changes.
NavigableListDetailPaneScaffold shows two panes when the window size class allows and one otherwise, and handles Back between panes.
navigateTo and navigateBack are suspend functions; call them from rememberCoroutineScope().
An empty detail pane is a normal tablet state. The selection is a key; the detail pane loads its own data.
In Navigation 3 the same idea is a scene strategy such as ListDetailSceneStrategy, which renders several back stack entries in one scene.
Navigation 3 (stable since late 2025) makes the back stack a snapshot-state list of keys that you own and mutate, and NavDisplay renders whatever is in it. Navigating becomes ordinary list operations instead of calls on a NavController.
What you gain
Conditional flows are plain Kotlin: if (!loggedIn) backStack.add(Login).
Keys are typed objects, and entryProvider maps each key type to its content.
A SceneStrategy can show several entries at once: list-detail, dialogs, or your own scene.
The back stack is testable state: a test asserts on a list.
What you write yourself
popUpTo, launchSingleTop, per-tab back stacks (one list per tab) and turning a deep link into an initial list.
Entry-scoped state comes from decorators: rememberSaveableStateHolderNavEntryDecorator() for rememberSaveable, rememberViewModelStoreNavEntryDecorator() from lifecycle-viewmodel-navigation3 for ViewModels.
rememberNavBackStack survives process death only because keys are @Serializable and implement NavKey.
When to adopt it
Compose-only: no Fragment, XML graph or NavigationUI support. A good fit for new Compose apps and adaptive layouts; Navigation 2 is still maintained, so a working app has no urgent reason to migrate.