Skip to main content
Technology & EngineeringAndroid Kotlin227 lines

Navigation

Jetpack Navigation component for type-safe in-app navigation and deep linking in Android

Quick Summary26 lines
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 lines
Paste into your CLAUDE.md or agent config

Navigation 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 User object 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 using rememberNavController() 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 @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.

Common Pitfalls

  • 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().

Install this skill directly: skilldb add android-kotlin-skills

Get CLI access →