Skip to content

Kotlin Navigation Component — Android Screen Navigation Guide

DodaTech Updated 2026-06-28 9 min read

In this tutorial, you will learn about Kotlin Navigation Component. We cover key concepts, practical examples, and best practices to help you master this topic.

The Navigation Component is an Android Jetpack library that implements navigation between screens with a type-safe Kotlin DSL, automated back stack management, deep linking support, and integration with Compose and fragments.

What You'll Learn

  • Set up the Navigation Component with Kotlin DSL
  • Create navigation graphs programmatically
  • Navigate between composable screens
  • Pass type-safe arguments between screens
  • Handle the back stack and Up button
  • Implement bottom navigation with multiple graphs
  • Use deep links for external navigation
  • Test navigation flows

Why It Matters

Navigation is one of the most error-prone parts of Android development. Manual fragment transactions, back stack management, and argument passing lead to subtle bugs. The Navigation Component standardizes these patterns. It generates type-safe argument classes, handles configuration changes, and visualizes the navigation graph. For Compose apps, the NavHost composable integrates seamlessly with the declarative paradigm.

Real-World Use

DodaTech's Android utility app uses a single-activity architecture with the Navigation Component managing 12 screens. Bottom navigation switches between main tabs. Type-safe Safe Args passes device details between the list and detail screens. Deep links allow the app to open directly to specific configuration screens from notifications.

Learning Path

flowchart LR
  A[Retrofit] --> B[Navigation\nYou are here]
  B --> C[Coroutines]
  style B fill:#f90,color:#fff

Adding Dependencies

// build.gradle.kts (app level)
plugins {
    id("androidx.navigation.safeargs.kotlin") version "2.7.7"
}

dependencies {
    implementation("androidx.navigation:navigation-compose:2.7.7")
    implementation("androidx.navigation:navigation-fragment-ktx:2.7.7")
    implementation("androidx.navigation:navigation-ui-ktx:2.7.7")
}

Define a NavHost with routes and composable destinations.

import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController

// Define route constants
object Routes {
    const val HOME = "home"
    const val DETAILS = "details/{itemId}"
    const val PROFILE = "profile/{userId}"
    const val SETTINGS = "settings"
    
    fun details(itemId: Long) = "details/$itemId"
    fun profile(userId: String) = "profile/$userId"
}

@Composable
fun AppNavigation() {
    val navController: NavHostController = rememberNavController()
    
    NavHost(
        navController = navController,
        startDestination = Routes.HOME
    ) {
        composable(Routes.HOME) {
            HomeScreen(
                onItemClick = { itemId ->
                    navController.navigate(Routes.details(itemId))
                },
                onProfileClick = { userId ->
                    navController.navigate(Routes.profile(userId))
                },
                onSettingsClick = {
                    navController.navigate(Routes.SETTINGS)
                }
            )
        }
        
        composable(
            route = Routes.DETAILS,
            arguments = listOf(
                navArgument("itemId") {
                    type = NavType.LongType
                    defaultValue = -1L
                }
            )
        ) { backStackEntry ->
            val itemId = backStackEntry.arguments?.getLong("itemId") ?: -1L
            DetailScreen(
                itemId = itemId,
                onBack = { navController.popBackStack() }
            )
        }
        
        composable(
            route = Routes.PROFILE,
            arguments = listOf(
                navArgument("userId") { type = NavType.StringType }
            )
        ) { backStackEntry ->
            val userId = backStackEntry.arguments?.getString("userId") ?: ""
            ProfileScreen(
                userId = userId,
                onBack = { navController.popBackStack() }
            )
        }
        
        composable(Routes.SETTINGS) {
            SettingsScreen(
                onBack = { navController.popBackStack() }
            )
        }
    }
}

@Composable
fun HomeScreen(
    onItemClick: (Long) -> Unit,
    onProfileClick: (String) -> Unit,
    onSettingsClick: () -> Unit
) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text("Home Screen", fontSize = 24.sp)
        Button(onClick = { onItemClick(42) }) {
            Text("Go to Item 42")
        }
        Button(onClick = { onProfileClick("alice") }) {
            Text("View Alice's Profile")
        }
        Button(onClick = onSettingsClick) {
            Text("Settings")
        }
    }
}

Output: The user navigates between screens by tapping buttons. The back button returns to the previous screen. Arguments are passed type-safely.

Bottom Navigation with Navigation Component

Combine bottom navigation bars with the NavHost.

data class BottomNavItem(
    val label: String,
    val icon: ImageVector,
    val route: String
)

@Composable
fun MainScreen() {
    val navController = rememberNavController()
    val items = listOf(
        BottomNavItem("Home", Icons.Default.Home, Routes.HOME),
        BottomNavItem("Search", Icons.Default.Search, "search"),
        BottomNavItem("Profile", Icons.Default.Person, Routes.PROFILE.replace("/{userId}", "/me"))
    )
    
    Scaffold(
        bottomBar = {
            NavigationBar {
                val navBackStackEntry by navController.currentBackStackEntryAsState()
                val currentRoute = navBackStackEntry?.destination?.route
                
                items.forEach { item ->
                    NavigationBarItem(
                        icon = { Icon(item.icon, contentDescription = item.label) },
                        label = { Text(item.label) },
                        selected = currentRoute == item.route,
                        onClick = {
                            navController.navigate(item.route) {
                                popUpTo(navController.graph.startDestinationId) {
                                    saveState = true
                                }
                                launchSingleTop = true
                                restoreState = true
                            }
                        }
                    )
                }
            }
        }
    ) { paddingValues ->
        NavHost(
            navController = navController,
            startDestination = Routes.HOME,
            modifier = Modifier.padding(paddingValues)
        ) {
            composable(Routes.HOME) { HomeContent() }
            composable("search") { SearchContent() }
            composable("profile/me") { ProfileContent() }
        }
    }
}

Output: A bottom navigation bar with three tabs. Each tab preserves its state when switching between tabs.

Type-Safe Arguments with Kotlin Serialization

For complex argument types, use Kotlin serialization with the Navigation Compose.

import kotlinx.serialization.Serializable

// Define argument classes
@Serializable
data class ProductDetailArgs(
    val productId: Long,
    val productName: String
)

@Serializable
object HomeRoute

@Serializable
data class ProductRoute(val productId: Long, val productName: String)

@Composable
fun TypeSafeNavigation() {
    val navController = rememberNavController()
    
    NavHost(
        navController = navController,
        startDestination = HomeRoute
    ) {
        composable<HomeRoute> {
            Column {
                Text("Home")
                Button(onClick = {
                    navController.navigate(ProductRoute(42, "Kotlin Book"))
                }) {
                    Text("View Product")
                }
            }
        }
        
        composable<ProductRoute> { backStackEntry ->
            val args = backStackEntry.toRoute<ProductRoute>()
            Text("Product: ${args.productName} (ID: ${args.productId})")
            Button(onClick = { navController.popBackStack() }) {
                Text("Back")
            }
        }
    }
}

Output: Type-safe navigation with Kotlin serialization. No string route construction needed.

Moving Between Fragments with Navigation

For XML-based apps using fragments, define navigation in a NavGraph.

// Navigation graph in Kotlin DSL
class AppNavGraph : NavGraphBuilder.() -> Unit = {
    fragment<HomeFragment>("home")
    fragment<DetailFragment>("detail/{itemId}") {
        argument("itemId") {
            type = NavType.LongType
        }
    }
}

// In Activity
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        
        val navController = findNavController(R.id.nav_host_fragment)
        // Navigation UI setup
        NavigationUI.setupActionBarWithNavController(this, navController)
    }
    
    override fun onSupportNavigateUp(): Boolean {
        return findNavController(R.id.nav_host_fragment).navigateUp()
    }
}

// In Fragment
class HomeFragment : Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        view.findViewById<Button>(R.id.detail_button).setOnClickListener {
            findNavController().navigate(R.id.action_home_to_detail, bundleOf(
                "itemId" to 42L
            ))
        }
    }
}

Output: The Navigation Component handles fragment transactions and the back stack.

Deep links open the app directly to a specific screen.

// In NavHost
composable(
    route = Routes.PROFILE,
    arguments = listOf(
        navArgument("userId") { type = NavType.StringType }
    ),
    deepLinks = listOf(
        navDeepLink {
            uriPattern = "https://example.com/profile/{userId}"
        },
        navDeepLink {
            uriPattern = "myapp://profile/{userId}"
        }
    )
) { backStackEntry ->
    // Deep link handling
}

// In AndroidManifest.xml
<activity android:name=".MainActivity">
    <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" />
    </intent-filter>
</activity>

// Sending a deep link programmatically
val intent = Intent(Intent.ACTION_VIEW).apply {
    data = "https://example.com/profile/alice".toUri()
}
startActivity(intent)

Output: Clicking a link opens the app directly to the profile screen with the correct userId argument.

Handling the Back Stack

Control back stack behavior with popUpTo, launchSingleTop, and restoreState.

// Pop up to home before navigating (avoids building up a long back stack)
navController.navigate(Routes.DETAILS) {
    popUpTo(Routes.HOME) {
        inclusive = false  // Keep HOME in the back stack
    }
}

// Clear entire back stack and start fresh
navController.navigate(Routes.HOME) {
    popUpTo(0) { inclusive = true }
}

// Single top: don't create duplicate instances
navController.navigate(Routes.PROFILE) {
    launchSingleTop = true
}

// Restore state when navigating back
navController.navigate(Routes.SETTINGS) {
    restoreState = true
}

// Combine patterns
navController.navigate(bottomNavRoute) {
    popUpTo(navController.graph.startDestinationId) {
        saveState = true
    }
    launchSingleTop = true
    restoreState = true
}

Output: Proper back stack management prevents duplicate screens and ensures the user returns to the correct location.

Animating Navigation

Add transitions between screens.

NavHost(
    navController = navController,
    startDestination = Routes.HOME,
    enterTransition = { slideInHorizontally { it } },
    exitTransition = { slideOutHorizontally { -it } },
    popEnterTransition = { slideInHorizontally { -it } },
    popExitTransition = { slideOutHorizontally { it } }
) {
    composable(Routes.HOME) { HomeContent() }
    composable(Routes.DETAILS) { DetailContent() }
}

Output: Screens slide in from the right and slide out to the left during forward navigation. Back navigation reverses the animation.

Testing Navigation

Test navigation flows with NavigationTest.

class NavigationTest {
    @get:Rule
    val composeTestRule = createAndroidComposeRule<MainActivity>()
    
    @Test
    fun testNavigateToDetail() {
        composeTestRule.setContent {
            AppNavigation()
        }
        
        // Click the button that navigates to detail
        composeTestRule
            .onNodeWithText("Go to Item 42")
            .performClick()
        
        // Verify we're on the detail screen
        composeTestRule
            .onNodeWithText("Item ID: 42")
            .assertExists()
    }
    
    @Test
    fun testBackNavigation() {
        composeTestRule.setContent {
            AppNavigation()
        }
        
        composeTestRule
            .onNodeWithText("Go to Item 42")
            .performClick()
        
        composeTestRule
            .onNodeWithText("Back")
            .performClick()
        
        composeTestRule
            .onNodeWithText("Home Screen")
            .assertExists()
    }
}

Output: Tests verify that navigation actions lead to the correct screens.

Common Mistakes

  1. Not handling Up button correctly: In a single-activity app, the Up button should navigate to the logical parent. Use NavigationUI.setupActionBarWithNavController() for automatic handling.

  2. Using string route construction instead of type-safe args: String routes are error-prone. Use Kotlin serialization or Safe Args for compile-time verification.

  3. Forgetting to handle back stack during navigation: Without popUpTo, navigation builds up a deep back stack. Users have to press back many times to return to the start.

  4. Creating multiple NavHost controllers: There should be one NavController per navigation scope. Creating multiple instances causes state loss.

  5. Not handling Process death and state restoration: Navigation Component handles this automatically only when you use Safe Args and saveStateHandle. Test with "Don't keep activities" enabled.

  6. Using activity instead of composable/fragment navigation: Use Compose navigation for Compose apps and fragment navigation for XML apps. Mixing them adds complexity.

Practice Questions

  1. What is the role of NavHostController?

Answer: NavHostController manages the navigation state, back stack, and navigation actions. It is created with rememberNavController() in Compose.

  1. How do you pass complex data between screens?

Answer: Use type-safe navigation with Kotlin serialization for data classes, or store complex data in a shared ViewModel and pass only an identifier as the argument.

  1. What does popUpTo do?

Answer: popUpTo removes destinations from the back stack up to (but not including) the specified destination. It prevents the back stack from growing indefinitely.

  1. How do you implement deep links?

Answer: Define deepLink entries in the NavHost composable with URI patterns. Add intent filters in the AndroidManifest. The Navigation Component extracts arguments from the deep link URI.

  1. Challenge: Create a multi-module navigation setup where each feature module contributes its own navigation graph. Use a root NavGraph that includes sub-graphs from each feature.

Answer:

// Root navigation graph
@Composable
fun RootNavigation() {
    val navController = rememberNavController()
    
    NavHost(
        navController = navController,
        startDestination = "main"
    ) {
        // Main graph
        navigation(startDestination = "feed", route = "main") {
            composable("feed") { FeedScreen(navController) }
            composable("notifications") { NotificationsScreen(navController) }
        }
        
        // Feature graphs
        authGraph(navController)
        settingsGraph(navController)
        profileGraph(navController)
    }
}

// Feature graph extension
fun NavGraphBuilder.authGraph(navController: NavHostController) {
    navigation(startDestination = "login", route = "auth") {
        composable("login") { LoginScreen(navController) }
        composable("register") { RegisterScreen(navController) }
        composable("forgot_password") { ForgotPasswordScreen(navController) }
    }
}

// Navigate to feature graph
navController.navigate("auth") {
    popUpTo("main") { inclusive = false }
}

Mini Project

Build a multi-screen recipe app with Navigation. Requirements:

  • Bottom navigation with Home, Search, and Favorites tabs
  • Type-safe navigation to recipe detail screen
  • Deep link to specific recipe from a notification
  • Animations between screens
  • Proper back stack: pressing back from detail returns to the correct tab
  • Handle Up button correctly in the toolbar
  • Test navigation flow

This project consolidates all navigation concepts in a practical application.

FAQ

What is the difference between navigate and popBackStack?

navigate adds a new destination to the back stack. popBackStack removes the current destination and returns to the previous one.

Can I use Navigation Component with bottom navigation?

Yes. Use NavigationBar with NavigationBarItem. Use popUpTo with saveState and restoreState for preserving tab state.

How do I pass optional arguments?

Use defaultValue in navArgument or use nullable types in Kotlin serialization routes.

What is launchSingleTop?

It prevents creating a new instance of the destination if it is already at the top of the back stack. Common for bottom navigation items.

How do I get the current route?

Use navController.currentBackStackEntryAsState() and access the destination's route property.

What's Next

After mastering navigation, learn coroutines for asynchronous programming. You can also explore Room database for local data persistence.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro