Navigation (Fragment & Compose) Explained
UI › Material & Nav
Most modern Android apps use single-activity architecture: one Activity hosts a NavHost, and every screen is a destination inside it instead of its own Activity. That single decision is why interviewers spend so much time on Navigation: it replaces a pile of Intent, FragmentTransaction, and manual back-stack bookkeeping with one component to reason about, so it's worth naming exactly what that component centralizes: one back stack, one place to handle deep links, and one place to scope state shared across screens.
The Navigation component has three moving parts. A NavGraph is the declarative map of destinations and how they connect; in an XML graph those connections are called actions, in Compose you connect destinations just by calling navigate() with a route. A NavController is the object you call to move between destinations, and it owns the back stack. A NavHost is the container that displays whichever destination is current, NavHostFragment in the View system, the NavHost composable in Compose.
Everything else in this lesson is really just what you can tell a NavController to do, and how the Fragment and Compose worlds each wire that up.
In Compose you build the graph directly in code with the NavHost composable. Each destination is registered with composable(route) { }, and the controller that drives them comes from rememberNavController():
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(navController, startDestination = "home") {
composable("home") { HomeScreen(navController) }
composable("detail/{id}") { backStackEntry ->
DetailScreen(id = backStackEntry.arguments?.getString("id"))
}
}
}
rememberNavController() matters specifically because Compose recomposes functions constantly. It stores the controller in the composition so the same instance, and the same back stack, survives every recomposition. Build a NavController directly in the composable body instead and you'd get a fresh, empty one each time the function ran again, silently wiping out navigation history.
Passing arguments between destinations needs to be type-safe, or you end up debugging a wrong getInt call at runtime instead of catching it at compile time. For XML and Fragment graphs, the **Safe Args** Gradle plugin reads your nav graph and generates a Directions class for navigating with arguments and an Args class for reading them:
// Navigate, with generated Directions
val action = HomeFragmentDirections.actionHomeToDetail(itemId = 42)
findNavController().navigate(action)
// Read, with generated Args
val args: DetailFragmentArgs by navArgs()
val id = args.itemId
Both classes are generated straight from the graph, so a renamed argument or a wrong type becomes a compile error in the calling code, not a crash you find in production.
Navigation Compose solves the same problem differently: instead of a code generator, you declare a route as a Kotlin class annotated @Serializable, register it with composable<Route> { }, and navigate by passing an instance of it:
@Serializable
data class DetailRoute(val itemId: Int)
navController.navigate(DetailRoute(itemId = 42))
This relies on kotlinx.serialization to encode and decode the route, so it only works once the project applies the Kotlin serialization Gradle plugin, org.jetbrains.kotlin.plugin.serialization, and each route class is marked @Serializable. Skip either one and the route class won't compile against composable<Route>.
Inside a destination, you read those typed arguments back with toRoute(), which deserializes the current back stack entry into your route type:
composable<DetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<DetailRoute>()
DetailScreen(id = route.itemId)
}
A ViewModel can skip the constructor entirely and pull the same arguments from its injected SavedStateHandle, which Navigation pre-seeds with the destination's arguments before the ViewModel is even created:
class DetailViewModel(private val handle: SavedStateHandle) : ViewModel() {
val route: DetailRoute = handle.toRoute()
}
It's the same SavedStateHandle you'd already use to survive process death, Navigation just fills in the destination's arguments for you ahead of time.
**Deep links** come in two flavors, and interviewers like to check you haven't conflated them.
An **implicit** deep link matches an external URI. You declare it with navDeepLink in the graph, backed by an intent-filter, so a browser link or another app can open yours straight at that destination:
navDeepLink { uriPattern = "https://example.com/item/{id}" }
An **explicit** deep link is built in your own code, typically for a notification's PendingIntent, and jumps directly to a destination with no URI matching involved at all:
val pending = NavDeepLinkBuilder(context)
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.detailFragment)
.setArguments(bundleOf("id" to 42))
.createPendingIntent()
Both kinds reconstruct a synthetic back stack automatically, so pressing Back from a deep-linked screen behaves like you navigated there the normal way, rather than exiting straight out of the app.
Back stack control is where navigate() earns its keep, mainly through two flags. popUpTo(route) removes destinations off the stack up to route before pushing the new one; add inclusive = true and it removes route itself too. launchSingleTop reuses an existing top instance instead of pushing a duplicate copy of it.
// Back stack before: home → profile → settings
navController.navigate("login") {
popUpTo("home") { inclusive = true }
}
Trace it: popUpTo("home") walks the stack down to home, and inclusive = true removes home as well, so profile, settings, and home are all gone before login gets pushed onto whatever's left.
Plain popUpTo and launchSingleTop stop duplicate screens, but bottom navigation needs one more thing: each tab should keep its own scroll position and back stack even after you switch away and back. That's what saveState and restoreState do. Leaving a tab with saveState = true saves that tab's back stack; returning to it with restoreState = true puts it right back instead of resetting to the tab's start destination. All three flags together are the standard bottom-nav pattern:
navController.navigate(Screen.Home) {
popUpTo(navController.graph.startDestinationId) { saveState = true }
launchSingleTop = true
restoreState = true
}
Two more NavController mechanics come up constantly. Going back has two APIs, and they aren't interchangeable: popBackStack() unconditionally removes the current destination, while navigateUp() follows Up-button semantics and can behave differently at a graph's start destination, for instance when a synthetic back stack was built for a deep link.
val handled = navController.navigateUp()
if (!handled) finish()
Separately, a Compose bottom bar that needs to reactively highlight the current tab shouldn't read navController.currentDestination once during composition, that value would never update. Instead it collects currentBackStackEntryAsState(), a State<NavBackStackEntry?> that recomposes on every destination change, and compares its route against each tab.
Single-activity architecture only pays off if screens inside a flow can actually share state without reaching for something global. That's what graph-scoped ViewModels are for: instead of an Application-scoped singleton, you scope the ViewModel to the navigation graph itself. It lives exactly as long as that graph is on the back stack, so sibling destinations share one instance and it's cleaned up automatically once the flow finishes.
// Fragment: scope to the "checkout" subgraph
val sharedVm: CheckoutViewModel by navGraphViewModels(R.id.checkoutGraph)
// Compose: scope to the parent graph's back stack entry
val entry = navController.getBackStackEntry("checkout")
val sharedVm: CheckoutViewModel = viewModel(entry)
An Application-scoped singleton would work too, technically, but it would outlive the flow and leak state into screens that have nothing to do with checkout.
That checkout example assumed a nested graph, a subgraph with its own start destination that groups a feature like onboarding or checkout inside a parent graph. It modularizes the feature, keeps its internal routes encapsulated from the rest of the app, and gives you exactly the scope a graph-scoped ViewModel needs. In Compose you declare one with the navigation(...) builder inside NavHost:
NavHost(navController, startDestination = "main") {
composable("main") { MainScreen() }
navigation(startDestination = "step1", route = "checkout") {
composable("step1") { Step1Screen() }
composable("step2") { Step2Screen() }
}
}
From outside, the rest of the graph just navigates to "checkout" as if it were a single destination; it has no idea step1 and step2 exist underneath it.
One more asymmetry worth knowing: navigation arguments only flow forward, from caller to destination. Getting a result back, say from a picker screen, needs a different mechanism. Each NavBackStackEntry exposes its own SavedStateHandle, so the callee writes the result onto the caller's entry before popping, and the caller observes it as state:
// Callee, right before popping
navController.previousBackStackEntry
?.savedStateHandle
?.set("result", value)
navController.popBackStack()
// Caller, observing
val result by navController.currentBackStackEntry!!
.savedStateHandle
.getStateFlow<String?>("result", null)
.collectAsState()
It's the same SavedStateHandle mechanism you already saw pre-seeded with navigation arguments, just used in the opposite direction.
Step back and the whole component is one shape repeated at different scales. NavController owns the back stack and the flags, popUpTo, launchSingleTop, saveState/restoreState, that shape it. Graphs, including nested ones, give you scoping: for navigation structure and for ViewModel lifetimes. And SavedStateHandle is the thread that carries arguments in and results back out, whether a ViewModel is reading them or a caller is receiving them.
The Fragment and Compose APIs differ in exactly one place that matters: how arguments are declared and read, Safe Args' generated Directions/Args classes versus @Serializable routes and toRoute(). Everything else, the controller, the back stack rules, the graph scoping, is the same idea wearing two different syntaxes. If an interviewer asks you to contrast the two platforms, that's the one sentence to lead with.