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.
nav_graph.xml: Destinations, Actions, Arguments and Deep Links
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 and AppBarConfiguration
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 |
|---|---|---|
|
Phone-width layouts, 3-5 top-level destinations |
Always visible; each item keeps its own saved back stack via |
|
Apps with more top-level destinations than fit a bottom bar |
The drawer itself is a |
|
Tablet/foldable-width layouts, an alternative to |
Same menu resource and |
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.
Deep Links with NavDeepLinkBuilder
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.
(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.
References
-
Android Developers — Pass Data Between Destinations with Safe Args
-
Android Developers — Interact programmatically with the Navigation component
-
Android Developers —
androidx.navigation.fragmentPackage Summary -
Android Developers — Update UI Components with NavController
-
Android Developers — Create swipe views with tabs using ViewPager2