Android Navigation
Irbisa · cheatsheetSeptember 13, 2026

Android Navigation

Navigation Component, Safe Args, type-safe routes, deep links, back stack, predictive back

Middle Developer16 itemscompressed for a skim
  1. 01

    Your app is one Activity with a NavHost. When the user swipes Back, which stack pops — the NavController's or the system's task — and when do the two disagree?

    Easy

    Back goes to the OnBackPressedDispatcher first, and the NavHost has a callback there that is enabled only while its own back stack has something to pop, so the NavController wins until it is empty and only then does the system pop the Activity off the task.

    // One Activity, one NavHost: the NavController owns everything the user calls "a screen"
    setContent {
      val navController = rememberNavController()
      NavHost(navController, startDestination = Home) { /* ... */ }
    }
    …
  2. 02

    Safe Args on an XML graph versus @Serializable routes in Compose — what does each actually generate, and what will neither of them let you put in an argument?

    Easy

    Safe Args is a Gradle plugin that generates Directions and Args classes from your XML graph, type-safe routes generate nothing at all because the library reads your @Serializable route class through kotlinx.serialization, and both end up writing the same Bundle — which is exactly what limits them.

    // --- Type-safe routes: no codegen of yours, kotlinx.serialization does the work ---
    @Serializable data object Home
    @Serializable data class Detail(val orderId: Long, val highlight: String? = null)
    
    NavHost(navController, startDestination = Home) {
      composable<Home> { HomeScreen(onOpen = { id -> navController.navigate(Detail(id)) }) }
    …
  3. 03

    A reviewer rejects your PR for passing the whole Order object to the detail destination. What actually goes wrong, and what do you pass instead?

    Easy

    A navigation argument is a snapshot copied into a Bundle, so passing the model hands the detail screen a second source of truth that is stale the moment anything updates it — pass the id and let the screen re-read the object from the repository.

    // ❌ The model as an argument: a frozen copy, unreachable by deep link, unbounded in size
    @Serializable
    data class DetailRoute(val order: Order)
    // navController.navigate(DetailRoute(order))
    
    // ✅ An id, plus the little that lets the first frame render
    …
  4. 04

    Sign-in succeeds and Back must never walk into the auth flow again — what does that one navigation call look like, and what is each option in it doing?

    Medium

    One navigate with the right NavOptions replaces the stack atomically — go to Home while popping the auth graph inclusively and with launchSingleTop — whereas a sequence of popBackStack() calls followed by a navigate is the version that flickers and races.

    @Serializable data object AuthGraph
    @Serializable data object Login
    @Serializable data object Otp
    @Serializable data object Home
    
    NavHost(navController, startDestination = AuthGraph) {
    …
  5. 05

    Three wizard steps have to share one draft without a singleton. How do you scope a ViewModel to the nested graph, and when exactly is it cleared?

    Medium

    Scope it to the nested graph's own NavBackStackEntryhiltViewModel(navController.getBackStackEntry<WizardGraph>()) — and it is cleared the moment that graph entry leaves the back stack, which is what makes it a real scope instead of a singleton with extra steps.

    @Serializable data object WizardGraph
    @Serializable data object Step1
    @Serializable data object Step2
    @Serializable data object Review
    
    @HiltViewModel
    …
  6. 06

    Bottom navigation where each tab keeps its own history: what do saveState and restoreState really preserve, and what still resets when the user comes back to a tab?

    Medium

    The pair saves and restores the popped entries themselves — their ids, arguments, saved-state bundles and, because the ids come back too, their ViewModels — but the composition is thrown away, so anything held in a plain remember starts over.

    private data class Tab(val route: Any, val icon: ImageVector, val label: String)
    
    @Composable
    fun RootScaffold(tabs: List<Tab>) {
      val navController = rememberNavController()
      val entry by navController.currentBackStackEntryAsState()
    …
  7. 07

    A picker destination must hand a chosen address back to the form. SavedStateHandle, a shared ViewModel or a callback — which of them actually survives process death?

    Medium

    Only the previous entry's SavedStateHandle survives process death, because it is the one channel the system writes into the saved-state bundle; a shared ViewModel dies with the process and a callback dies with the composition.

    // ✅ The picker writes into the CALLER's handle, then pops
    @Composable
    fun PickerScreen(navController: NavController) {
      AddressList(onPick = { address ->
        navController.previousBackStackEntry
          ?.savedStateHandle
    …
  8. 08

    A NavBackStackEntry is a LifecycleOwner, a ViewModelStoreOwner and a SavedStateRegistryOwner at once. When is each of those actually torn down?

    Medium

    All three go together, at the moment the entry is popped off the back stack and its exit transition finishes — which is why a screen-scoped ViewModel outlives a rotation but not a Back press.

    composable<Detail> { entry ->
      // All three owners here are this entry, not the Activity
      val vm: DetailViewModel = hiltViewModel()               // LocalViewModelStoreOwner == entry
      var query by rememberSaveable { mutableStateOf("") }    // the entry's SavedStateRegistry
      val state by vm.state.collectAsStateWithLifecycle()     // LocalLifecycleOwner == entry
    …
  9. 09

    A push notification opens order detail directly, and Back closes the app instead of going to the order list — how do you give that deep link a real back stack?

    Medium

    Back exits because the deep link created a task holding exactly one entry; the fix is to let navigation synthesize a stack, which it builds from the graph hierarchy and not from anything the user did.

    @Serializable data object Home
    @Serializable data object OrdersGraph
    @Serializable data object OrderList
    @Serializable data class OrderDetail(val id: Long)
    
    NavHost(navController, startDestination = Home) {
    …
  10. 10

    Your Profile composable calls navigate(Login) when the session is missing, and Login ends up on the stack twice — what went wrong, and where does that check belong?

    Medium

    A composable body is not a place where things happen once — it re-runs on every recomposition, so navigate() written there fires again on each pass until the new destination has actually taken over.

    // WRONG: the body re-runs on every recomposition, so this fires more than once
    @Composable
    fun ProfileScreen(loggedIn: Boolean, nav: NavController) {
      if (!loggedIn) nav.navigate(Login)     // two Login entries, or an IllegalArgumentException
      ProfileContent()
    }
    …
  11. 11

    Back in your app snaps instantly while every other app peeks at the previous screen during the gesture — what turns that animation on, and what must you write yourself?

    Medium

    Predictive back is on by default for apps targeting Android 15 (API 35) and later, so if yours snaps, something inside the app is still consuming Back the old way.

    // AndroidManifest.xml
    // targetSdk 35+: predictive back is on by default; the flag matters only below that,
    // and false is now a per-Activity opt-out.
    // <application android:enableOnBackInvokedCallback="true" ... >
    //   <activity android:name=".LegacyActivity" android:enableOnBackInvokedCallback="false" />
    …
  12. 12

    Is a single Activity still the answer in 2026, and when a dialog or bottom sheet is a navigation destination, what is actually sitting on the back stack?

    Medium

    One Activity per app is still the default — the NavController owns the back stack, and Activities are reserved for the handful of things only a task boundary can do.

    @Serializable data object Feed
    @Serializable data class Confirm(val orderId: Long)
    @Serializable data object Filters
    
    NavHost(nav, startDestination = Feed) {
      composable<Feed> { entry ->
    …
  13. 13

    On a tablet the list and the detail are visible at once, yet the back stack still claims the user is "on" the detail — what can a single linear stack not express here?

    Hard

    A linear back stack encodes exactly one visible destination at a time, so the moment two panes share the screen, "which destination am I on" stops being the right question — the answer is a pane layout plus a selection, and that is a second, smaller history the NavController should not own.

    // One NavController destination for the whole screen; panes live inside it
    @Serializable data object Inbox
    
    NavHost(nav, startDestination = Inbox) {
      composable<Inbox> { MailScreen(onSettings = { nav.navigate(Settings) }) }
    }
    …
  14. 14

    :feature:checkout has to open a screen that lives in :feature:profile, and neither module can see the other's route classes — how do you wire that up?

    Hard

    You break the dependency cycle by moving one of the two things out: either the route types drop into a module both features depend on, or the destination stays private and the feature exposes a lambda that :app fills in.

    // :core:navigation — routes only. No UI, no dependency on any feature.
    @Serializable data object Checkout
    @Serializable data class Profile(val userId: Long)
    
    // :feature:checkout — depends on :core:navigation, NOT on :feature:profile
    fun NavGraphBuilder.checkoutSection(onOpenProfile: (Long) -> Unit) {
    …
  15. 15

    In Navigation 3 the back stack is a list you own and mutate — what does that actually change versus a NavController, and what do you now have to write yourself?

    Hard

    Navigation 3 replaces the NavController with a snapshot-state list of keys that your own code mutates, NavDisplay renders it, and every navigation operation becomes an ordinary list operation.

    @Serializable data object Home : NavKey
    @Serializable data object Login : NavKey
    @Serializable data class Article(val id: Long) : NavKey
    
    @Composable
    fun App(loggedIn: Boolean) {
    …
  16. 16

    How do you assert in a test that tapping a row really navigated, and how do you find out from production data where real users get stuck or loop?

    Hard

    Test the wiring in one place and the screens with no NavController at all; in production, listen once on the controller instead of logging a screen name inside every composable.

    // 1. The screen knows nothing about navigation — assert the callback
    @Test fun tappingARowReportsTheSelection() {
      var opened: Long? = null
      composeRule.setContent { OrderList(orders = sample, onOpenOrder = { opened = it }) }
      composeRule.onNodeWithText("Order #42").performClick()
      assertEquals(42L, opened)
    …