Navigation and multiple screens
Navigation Compose routes and typed arguments, nested graphs, bottom navigation, deep links, and the back stack rules that avoid surprises.
Routes and arguments
@Serializable data class BookDetail(val bookId: Long)
@Composable
fun AppNavHost(navController: NavHostController = rememberNavController()) {
NavHost(navController = navController, startDestination = Home) {
composable<Home> {
HomeScreen(onOpenBook = { id -> navController.navigate(BookDetail(id)) })
}
composable<BookDetail> { entry ->
val route: BookDetail = entry.toRoute()
BookDetailScreen(bookId = route.bookId, onBack = navController::popBackStack)
}
composable<Settings> { SettingsScreen() }
}
}
// pop up to a destination without leaving a trail
navController.navigate(Home) {
popUpTo(navController.graph.startDestinationId) { saveState = true }
launchSingleTop = true
restoreState = true
}- Type-safe routes from a serializable class remove string concatenation and give compile-time checking of arguments.
- Arguments arrive as part of the route; for anything larger, pass an identifier and load the entity in the ViewModel - do not serialise a whole object into the route.
launchSingleTop = truestops a second tap creating a duplicate destination, andrestoreStatewithsaveStatekeeps scroll position when switching bottom-navigation tabs.- System back and the up button are different: the back stack is the user's history, while an up action navigates to a logical parent. Configure both deliberately.
💡
A ViewModel obtained with
hiltViewModel() inside a NavBackStackEntry is scoped to that destination, and is cleared when the entry is removed from the back stack. That is what makes a per-screen ViewModel behave as expected - and why hoisting it to the Activity breaks it.Nested graphs and bottom navigation
@Serializable data object HomeGraph
@Serializable data object Home
@Serializable data object Profile
@Serializable data object Search
@Composable
fun RootNavHost() {
val navController = rememberNavController()
Scaffold(
bottomBar = {
NavigationBar {
val entry by navController.currentBackStackEntryAsState()
val current = entry?.destination
NavigationBarItem(
selected = current?.hasRoute<Home>() == true,
onClick = { navController.navigateTopLevel(Home) },
icon = { Icon(Icons.Default.Home, contentDescription = "Home") },
label = { Text("Home") },
)
}
},
) { padding ->
NavHost(navController, startDestination = HomeGraph, modifier = Modifier.padding(padding)) {
navigation<HomeGraph>(startDestination = Home) {
composable<Home> { HomeScreen() }
composable<Search> { SearchScreen() }
}
navigation<ProfileGraph>(startDestination = Profile) {
composable<Profile> { ProfileScreen() }
composable<Settings> { SettingsScreen() }
}
}
}
}
fun NavHostController.navigateTopLevel(route: Any) = navigate(route) {
popUpTo(graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
restoreState = true
}| Rule | Why |
|---|---|
One NavHost per Activity in a Compose-only app | Two hosts means two back stacks and confusing behaviour |
| Nested graph per top-level section | Bottom-navigation tabs keep their own history |
popUpTo the start destination for tabs | Otherwise the stack grows with every tab switch |
| An argument only for identifiers | Routes are part of the back stack and of process-death restoration |
popBackStack rather than navigate when going up | Avoids duplicating a destination already on the stack |
Deep links and testing navigation
// in AndroidManifest.xml, inside the Activity
// <intent-filter>
// <action android:name="android.intent.action.VIEW" />
// <category android:name="android.intent.category.DEFAULT" />
// <category android:name="android.intent.category.BROWSABLE" />
// <data android:scheme="https" android:host="example.com" android:pathPrefix="/book" />
// </intent-filter>
composable<BookDetail>(
deepLinks = listOf(
navDeepLink<BookDetail>(basePath = "https://example.com/book"),
),
) { entry ->
val route: BookDetail = entry.toRoute()
BookDetailScreen(bookId = route.bookId)
}
// test navigation without a device or an emulator
@Test
fun navigatesToDetail() {
val navController = TestNavHostController(ApplicationProvider.getApplicationContext())
composeTestRule.setContent { AppNavHost(navController = navController) }
composeTestRule.onNodeWithText("The Dispossessed").performClick()
assertEquals("BookDetail", navController.currentBackStackEntry?.destination?.route)
}- A deep link must produce the same state as navigating in the app. If the detail screen needs data the route does not carry, it must load it itself.
- Handle the case where the link is opened for content the user cannot see: check authorization before rendering, rather than crashing on a missing entity.
TestNavHostControllermakes navigation testable in a JVM test, which is far faster than an instrumented test.- Set
android:launchModedeliberately and handleonNewIntent, or a second deep link will create a second Activity instead of reusing the existing task.
FAQ
How do I pass a complex object between screens?
Do not. Pass an id and let the destination load the object, or share it through a repository and a scoped ViewModel. A serialised object in the route bloats the back stack and breaks when the object changes.
Why does my bottom navigation reset the scroll position?
You are calling
navigate without saveState and restoreState, so the destination is recreated. Add both, and use launchSingleTop so repeated taps do not push duplicates.Related
Architecture: ViewModel, lifecycle and saved state Dependency injection with Hilt
Last refreshed 2026-09-18.