Added a comprehensive collection of Compose development guidelines, best practices, and skill definitions in the .codex directory. Co-authored-by: Junie <junie@jetbrains.com>
8.3 KiB
Navigation 2
NavHost, NavController, and graph DSL for Jetpack Compose navigation. Nav 2 is not deprecated and remains fully supported.
For shared navigation concepts (MVI rules, anti-patterns, version decision guide), see navigation.md. For DI wiring (Hilt/Koin + Nav 2), see navigation-2-di.md. For migrating to Nav 3, see navigation-migration.md.
References:
Core Concepts
Nav 2 has three building blocks:
- NavController — imperative controller that manages the back stack and navigation actions
- NavHost — composable container that maps routes to composable destinations
- NavGraph — the navigation graph defined via the
NavHostDSL
Basic Setup with String Routes
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "home") {
composable("home") {
HomeScreen(onNavigateToDetail = { id -> navController.navigate("detail/$id") })
}
composable("detail/{itemId}") { backStackEntry ->
val itemId = backStackEntry.arguments?.getString("itemId") ?: return@composable
DetailScreen(itemId = itemId, onBack = { navController.navigateUp() })
}
}
}
How you wire ViewModels and state inside each composable block depends on your project's architecture — see navigation.md for the MVI boundary pattern where navigation is driven by ViewModel effects.
Type-Safe Routes (2.8+)
From Navigation Compose 2.8+, routes can be @Serializable types instead of strings. This is the recommended approach for new Nav 2 code:
@Serializable data object Home
@Serializable data class Detail(val itemId: String)
NavHost(navController = navController, startDestination = Home) {
composable<Home> {
HomeScreen(onNavigateToDetail = { id -> navController.navigate(Detail(id)) })
}
composable<Detail> { backStackEntry ->
val detail: Detail = backStackEntry.toRoute()
DetailScreen(itemId = detail.itemId, onBack = { navController.navigateUp() })
}
}
Navigation Arguments (Legacy String Routes)
For pre-2.8 projects using string routes:
composable(
route = "detail/{itemId}?sort={sort}",
arguments = listOf(
navArgument("itemId") { type = NavType.StringType },
navArgument("sort") { type = NavType.StringType; defaultValue = "name" },
)
) { backStackEntry ->
val itemId = backStackEntry.arguments?.getString("itemId") ?: return@composable
val sort = backStackEntry.arguments?.getString("sort") ?: "name"
DetailScreen(itemId = itemId, sortBy = sort)
}
Type-safe routes (2.8+) are the recommended default — the navArgument DSL is for legacy codebases.
Common Navigation Actions
navController.navigate("detail/$id")
navController.navigate("detail/$id") {
popUpTo("home") { inclusive = false }
launchSingleTop = true
}
navController.navigateUp()
navController.popBackStack()
// Type-safe (2.8+)
navController.navigate(Detail(id)) {
popUpTo<Home> { inclusive = false }
launchSingleTop = true
}
Top-Level Tabs with NavigationBar
Use NavigationBar with currentBackStackEntryAsState(). Track selection with destination.hierarchy and hasRoute(route::class).
@Serializable sealed interface TopLevelRoute {
@Serializable data object Home : TopLevelRoute
@Serializable data object Search : TopLevelRoute
@Serializable data object Profile : TopLevelRoute
}
@Composable
fun MainScreen() {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
val tabs = listOf(
Triple(TopLevelRoute.Home, Icons.Default.Home, "Home"),
Triple(TopLevelRoute.Search, Icons.Default.Search, "Search"),
Triple(TopLevelRoute.Profile, Icons.Default.Person, "Profile"),
)
Scaffold(
bottomBar = {
NavigationBar {
tabs.forEach { (route, icon, label) ->
val selected =
currentDestination?.hierarchy?.any { it.hasRoute(route::class) } == true
NavigationBarItem(
selected = selected,
onClick = {
navController.navigate(route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
icon = { Icon(icon, contentDescription = label) },
label = { Text(label) },
)
}
}
},
) { padding ->
NavHost(
navController = navController,
startDestination = TopLevelRoute.Home,
modifier = Modifier.padding(padding),
) {
composable<TopLevelRoute.Home> { HomeScreen(navController) }
composable<TopLevelRoute.Search> { SearchScreen(navController) }
composable<TopLevelRoute.Profile> { ProfileScreen(navController) }
}
}
}
Deep Links
Type-safe (2.8+):
composable<Detail>(
deepLinks = listOf(
navDeepLink<Detail>(basePath = "https://example.com/detail")
)
) { backStackEntry ->
val detail: Detail = backStackEntry.toRoute()
DetailScreen(detail.itemId)
}
Navigate with Results
Pass data back via SavedStateHandle on back stack entries (avoids bloating route arguments):
// Sender: set on previous entry, then pop
Button(onClick = {
navController.previousBackStackEntry?.savedStateHandle?.set("filter_result", selectedFilter)
navController.navigateUp()
}) { Text("Apply") }
// Receiver: observe on current entry
val filterResult = navController.currentBackStackEntry
?.savedStateHandle
?.getStateFlow<String?>("filter_result", null)
?.collectAsStateWithLifecycle()
Nested Navigation Graphs
Group related destinations under a nested graph:
NavHost(navController = navController, startDestination = "home") {
composable("home") { HomeScreen(navController) }
navigation(startDestination = "checkout/cart", route = "checkout") {
composable("checkout/cart") { CartScreen(navController) }
composable("checkout/shipping") { ShippingScreen(navController) }
composable("checkout/payment") { PaymentScreen(navController) }
}
}
Type-safe: use navigation<Graph>(startDestination = Route) with @Serializable types — same structure as above.
Animations
Default transitions on NavHost:
NavHost(
navController = navController,
startDestination = Home,
enterTransition = { slideInHorizontally(initialOffsetX = { it }) + fadeIn() },
exitTransition = { slideOutHorizontally(targetOffsetX = { -it }) + fadeOut() },
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }) + fadeIn() },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) + fadeOut() },
) { /* destinations */ }
Conditional Navigation (Auth Guards)
Redirect via startDestination and clear login from the stack after success:
@Composable
fun AppNavigation(isAuthenticated: Boolean) {
val navController = rememberNavController()
val startDestination = if (isAuthenticated) Home else Login
NavHost(navController = navController, startDestination = startDestination) {
composable<Login> {
LoginScreen(onLoginSuccess = {
navController.navigate(Home) {
popUpTo<Login> { inclusive = true }
}
})
}
composable<Home> { HomeScreen(navController) }
composable<Detail> { DetailScreen(navController) }
}
}