Navigation
Jetpack Navigation component for type-safe in-app navigation and deep linking in Android
You are an expert in the Jetpack Navigation component for building Android applications with Kotlin.
## Key Points
- Use type-safe navigation with `@Serializable` route classes instead of string-based routes.
- Keep the `NavController` at the top-level composable and pass navigation callbacks down, not the controller itself.
- Use `launchSingleTop = true` to prevent duplicate destinations on rapid taps.
- Use `popUpTo` with `saveState` and `restoreState` for bottom navigation tabs to preserve each tab's state.
- Define nested navigation graphs for feature modules to keep the navigation graph organized.
- Test navigation with `TestNavHostController` in instrumented tests.
- Passing the `NavController` deep into the composable tree instead of hoisting navigation events as lambdas, creating tight coupling.
- Forgetting `launchSingleTop = true`, causing duplicate screens on double-tap.
- Not handling the back stack correctly after login — failing to `popUpTo` the login screen with `inclusive = true` allows the user to press back into the login screen after authenticating.
- Using complex objects as navigation arguments instead of IDs — pass an ID and load the object at the destination.
- Not declaring deep link intent filters in `AndroidManifest.xml`, causing deep links to fail silently.
- Recreating the `NavController` on recomposition by not using `rememberNavController()`.
## Quick Example
```xml
<activity android:name=".MainActivity">
<nav-graph android:value="@navigation/nav_graph" />
</activity>
```skilldb get android-kotlin-skills/NavigationFull skill: 227 linesNavigation Component — Android/Kotlin
You are an expert in the Jetpack Navigation component for building Android applications with Kotlin.
Core Philosophy
Navigation in Android apps should be type-safe, declarative, and lifecycle-aware. The Navigation component provides a single source of truth for the app's navigation graph, replacing the fragile Intent and FragmentTransaction patterns that scattered navigation logic across the codebase. When every route is defined in one graph with typed arguments, you can see the entire structure of your app at a glance, and the compiler catches incorrect routes or missing arguments at build time rather than at runtime.
The NavController should be treated as infrastructure, not as something passed deep into the composable tree. Leaf composables should express user intent through callback lambdas like onUserClick: (Long) -> Unit, not through direct calls to navController.navigate(). This separation means your composables are reusable in different navigation contexts (standalone, in a tab, in a dialog) and testable without a real navigation host.
Android's back stack management is a solved problem when you follow the Navigation component's conventions. Bottom navigation tabs should use saveState and restoreState so each tab preserves its own back stack. Login-to-home transitions should use popUpTo with inclusive = true to prevent the user from pressing back into the login screen. These patterns exist because the Navigation component was designed around them; fighting the conventions creates bugs.
Anti-Patterns
-
Passing the NavController deep into composable children: When a list item composable directly calls
navController.navigate(), it is tightly coupled to the navigation mechanism. Passing navigation events as lambdas decouples presentation from navigation, making composables reusable and testable. -
Forgetting launchSingleTop on navigation actions: Without
launchSingleTop = true, tapping a button rapidly creates multiple copies of the same destination on the back stack. The user must press back multiple times to escape. This is a trivially avoidable UX bug. -
Using complex objects as navigation arguments: Passing a full
Userobject as a navigation argument requires serialization and creates fragile coupling between screens. Pass the user ID and load the object at the destination. This also ensures the destination always has fresh data. -
Not clearing the back stack after authentication: When the user logs in and navigates to the home screen, the login screen must be removed from the back stack with
popUpTo(login) { inclusive = true }. Without this, pressing back from the home screen returns to the login form. -
Recreating the NavController on recomposition: Creating
NavHostController()directly in a composable instead of usingrememberNavController()means a new controller (and a fresh empty back stack) is created on every recomposition, losing all navigation state.
Overview
The Navigation component is part of Android Jetpack and provides a framework for navigating between destinations in an app. It supports fragment-based navigation, Compose navigation, deep links, and type-safe argument passing. The navigation graph serves as a central map of all destinations and the connections between them.
Core Concepts
Navigation with Compose (Recommended)
Define routes as a sealed hierarchy and use NavHost to declare the navigation graph.
@Serializable
sealed class Screen {
@Serializable data object Home : Screen()
@Serializable data object Settings : Screen()
@Serializable data class UserDetail(val userId: Long) : Screen()
}
@Composable
fun AppNavHost(
navController: NavHostController = rememberNavController(),
modifier: Modifier = Modifier
) {
NavHost(
navController = navController,
startDestination = Screen.Home,
modifier = modifier
) {
composable<Screen.Home> {
HomeScreen(
onUserClick = { userId ->
navController.navigate(Screen.UserDetail(userId))
},
onSettingsClick = {
navController.navigate(Screen.Settings)
}
)
}
composable<Screen.Settings> {
SettingsScreen(onBack = { navController.popBackStack() })
}
composable<Screen.UserDetail> { backStackEntry ->
val detail: Screen.UserDetail = backStackEntry.toRoute()
UserDetailScreen(userId = detail.userId)
}
}
}
Bottom Navigation
@Composable
fun MainScreen() {
val navController = rememberNavController()
val tabs = listOf(
TabItem("Home", Icons.Default.Home, Screen.Home),
TabItem("Search", Icons.Default.Search, Screen.Search),
TabItem("Profile", Icons.Default.Person, Screen.Profile)
)
Scaffold(
bottomBar = {
NavigationBar {
val currentBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = currentBackStackEntry?.destination?.route
tabs.forEach { tab ->
NavigationBarItem(
icon = { Icon(tab.icon, contentDescription = tab.label) },
label = { Text(tab.label) },
selected = currentRoute == tab.screen::class.qualifiedName,
onClick = {
navController.navigate(tab.screen) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
}
) { innerPadding ->
AppNavHost(navController = navController, modifier = Modifier.padding(innerPadding))
}
}
Deep Links
composable<Screen.UserDetail>(
deepLinks = listOf(
navDeepLink {
uriPattern = "https://example.com/users/{userId}"
}
)
) { backStackEntry ->
val detail: Screen.UserDetail = backStackEntry.toRoute()
UserDetailScreen(userId = detail.userId)
}
Declare in AndroidManifest.xml:
<activity android:name=".MainActivity">
<nav-graph android:value="@navigation/nav_graph" />
</activity>
Implementation Patterns
Nested Navigation Graphs
fun NavGraphBuilder.settingsGraph(navController: NavController) {
navigation<Screen.SettingsRoot>(startDestination = Screen.Settings) {
composable<Screen.Settings> {
SettingsScreen(
onAccountClick = { navController.navigate(Screen.Account) },
onNotificationsClick = { navController.navigate(Screen.Notifications) }
)
}
composable<Screen.Account> { AccountScreen() }
composable<Screen.Notifications> { NotificationSettingsScreen() }
}
}
// In NavHost
NavHost(navController = navController, startDestination = Screen.Home) {
composable<Screen.Home> { HomeScreen() }
settingsGraph(navController)
}
Passing Results Back
// In destination screen
navController.previousBackStackEntry
?.savedStateHandle
?.set("selected_item", selectedItem)
navController.popBackStack()
// In calling screen
val result = navController.currentBackStackEntry
?.savedStateHandle
?.getStateFlow<String?>("selected_item", null)
?.collectAsStateWithLifecycle()
Conditional Navigation (Auth Guard)
@Composable
fun AppNavHost(isLoggedIn: Boolean) {
val navController = rememberNavController()
val startDestination = if (isLoggedIn) Screen.Home else Screen.Login
NavHost(navController = navController, startDestination = startDestination) {
composable<Screen.Login> {
LoginScreen(onLoginSuccess = {
navController.navigate(Screen.Home) {
popUpTo(Screen.Login) { inclusive = true }
}
})
}
composable<Screen.Home> { HomeScreen() }
}
}
Best Practices
- Use type-safe navigation with
@Serializableroute classes instead of string-based routes. - Keep the
NavControllerat the top-level composable and pass navigation callbacks down, not the controller itself. - Use
launchSingleTop = trueto prevent duplicate destinations on rapid taps. - Use
popUpTowithsaveStateandrestoreStatefor bottom navigation tabs to preserve each tab's state. - Define nested navigation graphs for feature modules to keep the navigation graph organized.
- Test navigation with
TestNavHostControllerin instrumented tests.
Common Pitfalls
- Passing the
NavControllerdeep into the composable tree instead of hoisting navigation events as lambdas, creating tight coupling. - Forgetting
launchSingleTop = true, causing duplicate screens on double-tap. - Not handling the back stack correctly after login — failing to
popUpTothe login screen withinclusive = trueallows the user to press back into the login screen after authenticating. - Using complex objects as navigation arguments instead of IDs — pass an ID and load the object at the destination.
- Not declaring deep link intent filters in
AndroidManifest.xml, causing deep links to fail silently. - Recreating the
NavControlleron recomposition by not usingrememberNavController().
Install this skill directly: skilldb add android-kotlin-skills
Related Skills
Architecture
MVVM architecture pattern with ViewModel, LiveData, and StateFlow for scalable Android apps
Coroutines
Kotlin coroutines and Flow for asynchronous programming and reactive streams in Android
Dependency Injection
Hilt and Dagger dependency injection for managing object creation and scoping in Android apps
Jetpack Compose
Jetpack Compose declarative UI toolkit for building native Android interfaces with Kotlin
Retrofit
Retrofit HTTP client for type-safe REST API communication in Android with Kotlin coroutines
Room Database
Room persistence library for local SQLite database access with compile-time query verification in Android