Navigation with Fragments (Navigation Component, Drawer, Bottom Nav, Tabs)

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.

The Navigation component is the AndroidX library that turns a set of fragments (or, less commonly, activities) into a single, declarative navigation graph: instead of an app hand-writing FragmentTransaction calls and its own back-stack bookkeeping, it describes every screen and every legal move between screens once, in nav_graph.xml or an equivalent Kotlin DSL graph, and lets a NavController execute moves against that graph consistently. This page covers Navigation 2.10.2 as used with the classic, Views-based fragment model from Fragments and the Fragment Lifecycle and the NavHostFragment pattern built on top of Activities and the Activity Lifecycle; the Jetpack Compose equivalent (Navigation Compose and the newer Navigation 3) is covered separately on Navigation in Compose (Navigation Compose and Navigation 3). The running example is TrailMate's legacy Views screens: TrailListFragment, TrailDetailFragment, a hike recording screen and a settings screen, hosted by a single MainActivity : AppCompatActivity.

Everything below assumes the Gradle setup from Project Structure, Gradle and Build Variants: the androidx.navigation:navigation-fragment-ktx and androidx.navigation:navigation-ui-ktx libraries (both 2.10.2) on the :app module, and the androidx.navigation.safeargs.kotlin Gradle plugin applied wherever Safe Args code generation is needed.

A navigation graph is an XML resource under res/navigation/ that lists every destination (a fragment, an activity, or another nested graph) and every action (a named, directed edge between two destinations, with its own transition animations and pop behavior). TrailMate’s top-level graph has three fragment destinations, the nested hike-recording graph (see Nested Graphs and navGraphViewModels) and a startDestination:

<?xml version="1.0" encoding="utf-8"?>
<!-- app/src/main/res/navigation/nav_graph.xml -->
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/nav_graph"
    app:startDestination="@id/trailListFragment">

    <fragment
        android:id="@+id/trailListFragment"
        android:name="com.example.trailmate.feature.trails.TrailListFragment"
        android:label="Trails"
        tools:layout="@layout/fragment_trail_list">
        <action
            android:id="@+id/action_trailList_to_trailDetail"
            app:destination="@id/trailDetailFragment"
            app:enterAnim="@anim/slide_in_right"
            app:exitAnim="@anim/slide_out_left"
            app:popEnterAnim="@anim/slide_in_left"
            app:popExitAnim="@anim/slide_out_right" />
    </fragment>

    <fragment
        android:id="@+id/trailDetailFragment"
        android:name="com.example.trailmate.feature.trails.TrailDetailFragment"
        android:label="{trailName}"
        tools:layout="@layout/fragment_trail_detail">
        <argument
            android:name="trailId"
            app:argType="string" />
        <argument
            android:name="trailName"
            app:argType="string"
            app:nullable="false"
            android:defaultValue="Trail" />
        <deepLink
            android:id="@+id/deepLink_trailDetail"
            app:uri="trailmate://trail/{trailId}" />
        <!-- Targets the nested graph, not a fragment inside it, so the graph's own back stack entry
             (and every navGraphViewModels(R.id.hike_recording_graph) ViewModel) is created. -->
        <action
            android:id="@+id/action_trailDetail_to_recordHike"
            app:destination="@id/hike_recording_graph" />
    </fragment>

    <fragment
        android:id="@+id/settingsFragment"
        android:name="com.example.trailmate.feature.settings.SettingsFragment"
        android:label="Settings"
        tools:layout="@layout/fragment_settings" />

    <navigation
        android:id="@+id/hike_recording_graph"
        app:startDestination="@id/recordHikeFragment">

        <fragment
            android:id="@+id/recordHikeFragment"
            android:name="com.example.trailmate.feature.trails.RecordHikeFragment"
            android:label="Recording"
            tools:layout="@layout/fragment_record_hike">
            <action
                android:id="@+id/action_recordHike_to_hikeSummary"
                app:destination="@id/hikeSummaryFragment" />
        </fragment>

        <fragment
            android:id="@+id/hikeSummaryFragment"
            android:name="com.example.trailmate.feature.trails.HikeSummaryFragment"
            android:label="Hike summary"
            tools:layout="@layout/fragment_hike_summary">
            <argument
                android:name="hikeId"
                app:argType="string" />
        </fragment>
    </navigation>
</navigation>

Each <argument> declares a typed parameter (string, integer, boolean, float, a Parcelable/Serializable custom type, or a reference), an optional android:defaultValue, and whether it is app:nullable; the Navigation Gradle plugin turns these declarations into generated, type-checked classes rather than the caller building a raw Bundle by hand (see Safe Args). android:label can itself reference an argument with {argumentName}, which is how AppBarConfiguration derives a screen-specific title automatically (see NavigationUI and AppBarConfiguration). A <deepLink> associates a URI pattern with a destination and its arguments, letting an external Intent — a notification tap, an App Links URL, or a manual NavDeepLinkBuilder call — land directly on that destination with its arguments already populated (see Deep Links with NavDeepLinkBuilder and App Components, the Manifest and Intents for the manifest-level <intent-filter> side of App Links). See Android Developers — Navigation and Android Developers — Navigate to a Destination.

NavHostFragment and findNavController

The graph is hosted by a NavHostFragment, placed in the activity’s layout like any other fragment container, which owns a NavController and swaps the visible fragment as the controller navigates:

<?xml version="1.0" encoding="utf-8"?>
<!-- app/src/main/res/layout/activity_main.xml -->
<androidx.coordinatorlayout.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/nav_host_fragment"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:defaultNavHost="true"
        app:navGraph="@navigation/nav_graph" />

    <com.google.android.material.bottomnavigation.BottomNavigationView
        android:id="@+id/bottom_nav"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom"
        app:menu="@menu/bottom_nav_menu" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

app:defaultNavHost="true" registers the NavHostFragment to intercept the system back button/gesture and delegate it to the NavController’s own back-stack handling, so a fragment destination does not need its own `OnBackPressedCallback just to go back. Every fragment reaches its NavController through the Fragment.findNavController() extension, which walks up to the nearest NavHostFragment:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListFragment.kt
class TrailListFragment : Fragment(R.layout.fragment_trail_list) {

    private val adapter = TrailAdapter { trail ->
        findNavController().navigate(
            TrailListFragmentDirections.actionTrailListToTrailDetail(
                trailId = trail.id,
                trailName = trail.name,
            ),
        )
    }
}

Outside a fragment — from an Activity, a View, or a menu-item click listener — the equivalent lookups are Activity.findNavController(viewId) and View.findNavController(). Both eventually resolve to the same NavController instance the NavHostFragment created, so there is exactly one back stack per nav host, no matter how it is reached. See Android Developers — The Navigation Back Stack.

Safe Args

Safe Args is a Gradle plugin (androidx.navigation.safeargs.kotlin, applied per module) that reads nav_graph.xml and generates, for every destination with an action pointing to it, a Directions class with one method per outgoing action and a …​Args class that exposes its own declared arguments. Applying the plugin:

// app/build.gradle.kts
plugins {
    id("androidx.navigation.safeargs.kotlin")
}

The generated TrailListFragmentDirections.actionTrailListToTrailDetail(trailId, trailName) call used above returns a NavDirections implementation carrying a type-checked Bundle under the hood — passing an Int where the graph declared trailId as a string is a compile error, not a runtime ClassCastException from a mistyped Bundle key. On the receiving side, TrailDetailFragment reads its arguments through the generated …​Args class and the navArgs() property delegate instead of touching arguments directly:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailDetailFragment.kt
class TrailDetailFragment : Fragment(R.layout.fragment_trail_detail) {

    private val args: TrailDetailFragmentArgs by navArgs()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        viewModel.loadTrail(args.trailId)
    }
}

Safe Args only generates code for actions declared in the graph; navigating with a raw destination ID (navController.navigate(R.id.trailDetailFragment, bundleOf("trailId" to trail.id))) still works but loses the compile-time argument checking, so it is worth reserving for cases the graph genuinely cannot express (a destination chosen dynamically at runtime). See Android Developers — Pass Data Between Destinations with Safe Args.

The Kotlin DSL and Type-Safe Routes

Navigation also ships a Kotlin DSL (androidx.navigation:navigation-fragment-ktx) that builds a graph in code instead of XML, which is convenient for a graph assembled dynamically or shared with a multi-module app where each feature module contributes its own destinations:

// app/src/main/kotlin/com/example/trailmate/MainActivity.kt
val navHostFragment = supportFragmentManager
    .findFragmentById(R.id.nav_host_fragment) as NavHostFragment
navHostFragment.navController.graph = navHostFragment.navController.createGraph(
    startDestination = "trailList",
) {
    fragment<TrailListFragment>("trailList") {
        label = "Trails"
    }
    fragment<TrailDetailFragment>("trailDetail/{trailId}") {
        label = "Trail"
        argument("trailId") { type = NavType.StringType }
    }
}

Navigation’s newer type-safe routes take this further by letting a destination’s route be a @Serializable class or object instead of a string pattern, so the compiler checks both the route name and its argument types — the same kotlinx.serialization-backed mechanism Navigation Compose uses (see Navigation in Compose (Navigation Compose and Navigation 3)). Building the DSL block itself — the trailing-lambda createGraph { …​ } shape, fragment<T>(route) { …​ } as a receiver-scoped builder call — is ordinary Kotlin DSL syntax, covered in depth on Type-Safe Builders and DSLs rather than repeated here. See Android Developers — Navigate with Type Safety.

popUpTo and launchSingleTop

Every navigate() call can carry NavOptions that change how it manipulates the back stack instead of simply pushing a new entry. Two options cover the overwhelming majority of real needs:

popUpTo (with an optional inclusive flag) removes entries from the back stack up to — and, if inclusive, including — a named destination before pushing the new one. It is how a "finish onboarding" or "hike saved" action avoids leaving the finished flow reachable by pressing back:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/RecordHikeFragment.kt
// Generated from action_recordHike_to_hikeSummary and HikeSummaryFragment's hikeId argument.
findNavController().navigate(
    RecordHikeFragmentDirections.actionRecordHikeToHikeSummary(hikeId = hikeId),
    navOptions {
        // Drop the finished recording screen, but keep the hike_recording_graph entry (and its
        // shared ViewModel) alive for HikeSummaryFragment; back from the summary returns to TrailDetail.
        popUpTo(R.id.recordHikeFragment) { inclusive = true }
    },
)

launchSingleTop avoids pushing a duplicate entry when the target is already the top of the back stack, which matters most for destinations reached repeatedly from a BottomNavigationView or NavigationRailView tab (see BottomNavigationView, DrawerLayout and NavigationRailView) — without it, tapping the same tab twice would stack a second identical instance of that destination instead of reusing the one already showing:

bottomNav.setOnItemSelectedListener { item ->
    navController.navigate(
        item.itemId,
        args = null,
        navOptions = navOptions {
            launchSingleTop = true
            restoreState = true
            popUpTo(navController.graph.findStartDestination().id) {
                saveState = true
            }
        },
    )
    true
}

saveState/restoreState (paired with popUpTo) are what let each top-level tab keep its own scroll position and back stack across switches, the same behavior NavigationUI.setupWithNavController wires up automatically for a BottomNavigationView (see BottomNavigationView, DrawerLayout and NavigationRailView). See Android Developers — NavOptions.

Nested Graphs and navGraphViewModels

A nested graph groups a related set of destinations — for example, TrailMate’s hike-recording flow (the recording screen and the summary screen) — behind a single entry in the parent graph, so the parent only needs to know about one destination ID for the whole flow. Actions from outside the flow must target the nested graph’s ID (@id/hike_recording_graph), never a fragment inside it; navigating straight to recordHikeFragment would skip the graph’s own back stack entry:

<?xml version="1.0" encoding="utf-8"?>
<!-- app/src/main/res/navigation/nav_graph.xml (excerpt: the nested graph and the action that enters it) -->
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/nav_graph"
    app:startDestination="@id/trailListFragment">

    <fragment
        android:id="@+id/trailDetailFragment"
        android:name="com.example.trailmate.feature.trails.TrailDetailFragment">
        <action
            android:id="@+id/action_trailDetail_to_recordHike"
            app:destination="@id/hike_recording_graph" />
    </fragment>

    <navigation
        android:id="@+id/hike_recording_graph"
        app:startDestination="@id/recordHikeFragment">

        <fragment
            android:id="@+id/recordHikeFragment"
            android:name="com.example.trailmate.feature.trails.RecordHikeFragment">
            <action
                android:id="@+id/action_recordHike_to_hikeSummary"
                app:destination="@id/hikeSummaryFragment" />
        </fragment>
        <fragment
            android:id="@+id/hikeSummaryFragment"
            android:name="com.example.trailmate.feature.trails.HikeSummaryFragment">
            <argument
                android:name="hikeId"
                app:argType="string" />
        </fragment>
    </navigation>
</navigation>

Destinations inside a nested graph commonly need to share state for the whole flow — for instance, an in-memory elapsed-distance counter both RecordHikeFragment and HikeSummaryFragment read. navGraphViewModels(graphId) scopes a ViewModel to the nested graph’s own NavBackStackEntry rather than to either fragment individually, so it is created once when the flow starts and cleared once the whole nested graph is popped. The delegate looks up the graph’s entry on the back stack, so it throws IllegalArgumentException if the flow was entered without going through hike_recording_graph — the same lifetime-scoping idea as activityViewModels() from Fragments and the Fragment Lifecycle, but scoped to a flow instead of the whole activity:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/RecordHikeFragment.kt
class RecordHikeFragment : Fragment(R.layout.fragment_record_hike) {

    private val hikeViewModel: HikeRecordingViewModel
        by navGraphViewModels(R.id.hike_recording_graph)
}

HikeSummaryFragment declares the identical by navGraphViewModels(R.id.hike_recording_graph) delegate and receives the same HikeRecordingViewModel instance, because both fragments resolve to the same NavBackStackEntry for that nested graph. See Android Developers — Nested Navigation Graphs and Android Developers — androidx.navigation.fragment Package Summary.

NavigationUI wires standard Material components to a NavController so the app does not hand-write the listener code that keeps them in sync with the current destination. AppBarConfiguration declares which destinations count as "top-level" — destinations that show a hamburger/drawer icon instead of an "up" arrow, and never receive a back arrow of their own:

// app/src/main/kotlin/com/example/trailmate/MainActivity.kt
class MainActivity : AppCompatActivity(R.layout.activity_main) {

    private lateinit var appBarConfiguration: AppBarConfiguration

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val navHostFragment = supportFragmentManager
            .findFragmentById(R.id.nav_host_fragment) as NavHostFragment
        val navController = navHostFragment.navController

        appBarConfiguration = AppBarConfiguration(
            topLevelDestinationIds = setOf(
                R.id.trailListFragment,
                R.id.settingsFragment,
            ),
            drawerLayout = findViewById(R.id.drawer_layout),
        )
        setupActionBarWithNavController(navController, appBarConfiguration)
        findViewById<BottomNavigationView>(R.id.bottom_nav)
            .setupWithNavController(navController)
    }

    override fun onSupportNavigateUp(): Boolean =
        findNavController(R.id.nav_host_fragment).navigateUp(appBarConfiguration)
            || super.onSupportNavigateUp()
}

setupActionBarWithNavController keeps the Toolbar’s title in sync with each destination’s `android:label (including argument placeholders such as {trailName}) and swaps the navigation icon between the drawer icon and an up arrow based on AppBarConfiguration. onSupportNavigateUp() must delegate to navController.navigateUp(appBarConfiguration) for the up arrow — and the system’s own "up" affordance — to respect that same top-level/child distinction instead of always just popping the back stack by one entry. See Android Developers — Update UI Components with NavController and Android Developers — AppBarConfiguration.

BottomNavigationView, DrawerLayout and NavigationRailView

Material’s three top-level navigation surfaces are all wired to a NavController with the same setupWithNavController(navController) extension shown above, and all three read their menu items from a res/menu/ resource whose item IDs match destination IDs in the graph, so tapping an item calls navController.navigate(itemId) with the popUpTo/launchSingleTop/saveState combination from popUpTo and launchSingleTop applied automatically:

Component Typical placement Notes

BottomNavigationView

Phone-width layouts, 3-5 top-level destinations

Always visible; each item keeps its own saved back stack via setupWithNavController.

DrawerLayout + NavigationView

Apps with more top-level destinations than fit a bottom bar

The drawer itself is a DrawerLayout wrapping the content and a side NavigationView; passing drawerLayout into AppBarConfiguration makes the hamburger icon open it automatically.

NavigationRailView

Tablet/foldable-width layouts, an alternative to BottomNavigationView on wider screens

Same menu resource and setupWithNavController call; typically swapped in for the bottom bar at a window size class breakpoint (see Adaptive Layouts, Edge-to-Edge and Large Screens).

A DrawerLayout with a side NavigationView needs the drawer layout itself passed to AppBarConfiguration, as already shown in NavigationUI and AppBarConfiguration, plus wiring the NavigationView:

<?xml version="1.0" encoding="utf-8"?>
<!-- app/src/main/res/layout/activity_main.xml (drawer variant, excerpt) -->
<androidx.drawerlayout.widget.DrawerLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/drawer_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/nav_host_fragment"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:navGraph="@navigation/nav_graph" />

    <com.google.android.material.navigation.NavigationView
        android:id="@+id/nav_view"
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:layout_gravity="start"
        app:menu="@menu/drawer_menu" />
</androidx.drawerlayout.widget.DrawerLayout>
findViewById<NavigationView>(R.id.nav_view).setupWithNavController(navController)

Because a NavigationRailView and a BottomNavigationView are usually alternated for the same set of top-level destinations rather than combined, TrailMate’s settings/profile destination is reachable from whichever surface is currently shown — a bottom bar item on a phone, a rail item on a wider layout — without changing the underlying graph or destination IDs at all. See Android Developers — BottomNavigationView, Android Developers — DrawerLayout, and Android Developers — NavigationView.

TabLayout with ViewPager2 and TabLayoutMediator

TabLayout paired with ViewPager2 covers a different case than the top-level surfaces above: swipeable sibling pages under a single destination, rather than separate destinations in the nav graph. TrailMate’s trail detail screen, for example, could use tabs for "Overview", "Photos" and "Reviews" without those tabs being their own back-stack entries:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailDetailFragment.kt
class TrailDetailFragment : Fragment(R.layout.fragment_trail_detail) {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val pager = view.findViewById<ViewPager2>(R.id.trail_pager)
        val tabs = view.findViewById<TabLayout>(R.id.trail_tabs)

        pager.adapter = TrailDetailPagerAdapter(this)
        TabLayoutMediator(tabs, pager) { tab, position ->
            tab.text = when (position) {
                0 -> "Overview"
                1 -> "Photos"
                else -> "Reviews"
            }
        }.attach()
    }
}

private class TrailDetailPagerAdapter(
    fragment: Fragment,
) : FragmentStateAdapter(fragment) {
    override fun getItemCount() = 3
    override fun createFragment(position: Int): Fragment = when (position) {
        0 -> TrailOverviewFragment()
        1 -> TrailPhotosFragment()
        else -> TrailReviewsFragment()
    }
}

TabLayoutMediator is what keeps the TabLayout’s selected tab and the `ViewPager2’s current page in sync in both directions, replacing the hand-written `ViewPager.OnPageChangeListener/TabLayout.setupWithViewPager pairing from the pre-ViewPager2 API. Nothing here touches the NavController: FragmentStateAdapter manages its own child FragmentManager and its own fragment lifecycles independently of the navigation graph, which is why mixing the two — putting a nav-graph destination inside a ViewPager2 page — is unusual and generally avoided. See Android Developers — TabLayout and Android Developers — ViewPager2.

Where a <deepLink> element in nav_graph.xml: Destinations, Actions, Arguments and Deep Links handles an incoming URI from outside the app, NavDeepLinkBuilder builds the matching artificial back stack for code that needs to construct a PendingIntent pointing at a destination — most commonly a notification action. Given TrailMate’s RecordHikeService posting a "hike in progress" notification (see Background Work: WorkManager, Services and Alarms), tapping it should open TrailDetailFragment with the synthetic back stack (trailListFragment beneath it) that navigateUp() expects, not just that one fragment with nothing to pop back to:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/HikeNotifications.kt
val pendingIntent = NavDeepLinkBuilder(context)
    .setGraph(R.navigation.nav_graph)
    .setDestination(R.id.trailDetailFragment)
    .setArguments(bundleOf("trailId" to trailId, "trailName" to trailName))
    .createPendingIntent()

NotificationCompat.Builder(context, "trail_updates")
    .setContentTitle("Hike in progress")
    .setContentIntent(pendingIntent)
    .setSmallIcon(R.drawable.ic_hike)
    .build()

createPendingIntent() produces an Intent targeting the graph’s host activity with extras that tell the NavController to synthesize trailListFragment → trailDetailFragment on the back stack before showing the destination, so pressing back or up from the notification-launched screen behaves exactly as if the user had navigated there normally. See Android Developers — NavDeepLinkBuilder and Android Developers — Create Deep Links to App Content.

Testing Navigation with TestNavHostController

TestNavHostController is a NavHostController subclass built for tests: it navigates a real graph with test navigators instead of a NavHostFragment, so it needs no activity, fragment transaction or inflated views, which makes it suitable for a narrow test that only checks which destination an action leads to. It still needs an Android Context and the app’s resources (ApplicationProvider.getApplicationContext() and R.navigation.nav_graph), so as a local test under src/test it runs on Robolectric — the @RunWith(AndroidJUnit4::class) runner, with testOptions.unitTests.isIncludeAndroidResources = true — as set up on Unit Testing with JUnit, Mockito, MockK and Robolectric; the same class also runs unchanged as an instrumented test under src/androidTest:

// app/src/test/kotlin/com/example/trailmate/TrailNavigationTest.kt
import androidx.lifecycle.ViewModelStore
import androidx.navigation.testing.TestNavHostController
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class) // Robolectric provides the Context and resources in src/test
class TrailNavigationTest {

    private lateinit var navController: TestNavHostController

    @Before
    fun setUp() {
        navController = TestNavHostController(ApplicationProvider.getApplicationContext())
        navController.setViewModelStore(ViewModelStore()) // lets navGraphViewModels() scopes be created
        navController.setGraph(R.navigation.nav_graph)
    }

    @Test
    fun `selecting a trail navigates to trail detail with its id`() {
        navController.navigate(
            TrailListFragmentDirections.actionTrailListToTrailDetail(
                trailId = "trail-1",
                trailName = "Ridge Loop",
            ),
        )

        val destination = navController.currentBackStackEntry?.destination
        assertEquals(R.id.trailDetailFragment, destination?.id)
        assertEquals("trail-1", navController.currentBackStackEntry?.arguments?.getString("trailId"))
    }

    @Test
    fun `recording a hike enters the nested graph`() {
        navController.setCurrentDestination(R.id.trailDetailFragment, TrailDetailFragmentArgs("trail-1").toBundle())

        navController.navigate(TrailDetailFragmentDirections.actionTrailDetailToRecordHike())

        assertEquals(R.id.recordHikeFragment, navController.currentDestination?.id)
        assertNotNull(navController.getBackStackEntry(R.id.hike_recording_graph))
    }
}

This kind of test verifies routing — that a given action lands on the expected destination with the expected arguments — and is deliberately narrow: it does not inflate TrailDetailFragment’s own view, so it cannot catch a layout or binding bug. Broader fragment-plus-navigation checks, using a real `NavHostFragment inside a FragmentScenario or an Espresso instrumented test, are covered on Unit Testing with JUnit, Mockito, MockK and Robolectric and Instrumented and UI Testing (Espresso, Compose Testing, UI Automator). See Android Developers — Test Navigation and Android Developers — TestNavHostController.

TrailMate’s Navigation Graph

The figure below shows TrailMate’s Views-based navigation graph as a whole: the top-level destinations reachable from the bottom navigation bar, the hike-recording flow nested underneath trail detail, and where a deep link from a notification re-enters the graph.

flowchart TD Start(["App start"]) --> TrailList["TrailListFragment
(top-level, bottom nav)"] TrailList -->|"action_trailList_to_trailDetail
(trailId, trailName)"| TrailDetail["TrailDetailFragment"] TrailDetail -->|"action_trailDetail_to_recordHike
(destination: hike_recording_graph)"| RecordHike["RecordHikeFragment
(nested graph start)"] RecordHike -->|"action_recordHike_to_hikeSummary (hikeId)
popUpTo recordHikeFragment, inclusive"| HikeSummary["HikeSummaryFragment"] HikeSummary -->|"back: pops the nested graph"| TrailDetail TrailList -.->|"bottom nav item"| Settings["SettingsFragment
(top-level, bottom nav)"] DeepLink(["Notification deep link
(NavDeepLinkBuilder)"]) -.->|"synthetic back stack:
TrailList then TrailDetail"| TrailDetail subgraph HikeGraph["Nested graph: hike_recording_graph
(shared navGraphViewModels)"] RecordHike HikeSummary end

TrailListFragment and SettingsFragment are the two top-level destinations declared in AppBarConfiguration and reachable directly from the BottomNavigationView; TrailDetailFragment and the nested hike_recording_graph are reached only through actions, so both show an up arrow instead of the drawer/rail icon. The dashed edge from "Notification deep link" reflects that NavDeepLinkBuilder does not draw a new edge in the graph itself — it synthesizes the same TrailList → TrailDetail back stack a normal action would have produced, as described in Deep Links with NavDeepLinkBuilder.