What This Skill Provides
The Compose Navigation skill covers implementing type-safe navigation using Navigation Compose library. Use this skill when setting up navigation, passing arguments between screens, handling deep links, or structuring multi-screen apps.
When to Use This Skill
Setting up navigation in a new Compose app
Creating navigation graphs with multiple screens
Passing data between destinations
Implementing deep links for notifications or web links
Building bottom navigation or adaptive navigation UIs
Testing navigation flows
Setup
Add the Navigation Compose dependency to your project:
dependencies {
implementation ( "androidx.navigation:navigation-compose:2.8.5" )
// For type-safe navigation (recommended)
implementation ( "org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3" )
}
// Enable serialization plugin
plugins {
kotlin ( "plugin.serialization" ) version "2.0.21"
}
Type-Safe Routes
Define routes using @Serializable data classes or objects for compile-time safety.
import kotlinx.serialization.Serializable
// Simple screen (no arguments)
@Serializable
object Home
// Screen with required argument
@Serializable
data class Profile ( val userId: String )
// Screen with optional argument
@Serializable
data class Settings ( val section: String ? = null )
// Screen with multiple arguments
@Serializable
data class ProductDetail (
val productId: String ,
val showReviews: Boolean = false
)
Using @Serializable routes provides type safety, reducing runtime errors and making refactoring easier.
Basic Navigation Setup
Create NavController
Create NavHost
@Composable
fun MyApp () {
val navController = rememberNavController ()
AppNavHost (navController = navController)
}
Navigation Patterns
Forward Navigation
// Navigate to a new screen
navController. navigate ( Profile (userId = "user123" ))
// Navigate and replace current screen
navController. navigate (Home) {
popUpTo < Home > { inclusive = true }
}
Backward Navigation
// Navigate back
navController. popBackStack ()
// Navigate back to specific destination
navController. popBackStack < Home >(inclusive = false )
Advanced Navigation Options
navController. navigate ( Profile (userId = "user123" )) {
// Pop up to destination (clear back stack)
popUpTo < Home > {
inclusive = false // Keep Home in stack
saveState = true // Save state of popped screens
}
// Avoid multiple copies of same destination
launchSingleTop = true
// Restore state when navigating to this destination
restoreState = true
}
Navigate Forward
Replace Current
Clear Back Stack
navController. navigate ( Settings (section = "notifications" ))
navController. navigate (Home) {
popUpTo < Settings > { inclusive = true }
}
navController. navigate (Home) {
popUpTo ( 0 ) // Clear entire back stack
}
Bottom Navigation Pattern
Implement bottom navigation with proper state preservation.
@Composable
fun MainScreen () {
val navController = rememberNavController ()
Scaffold (
bottomBar = {
NavigationBar {
val navBackStackEntry by navController. currentBackStackEntryAsState ()
val currentDestination = navBackStackEntry?.destination
NavigationBarItem (
icon = { Icon (Icons.Default.Home, contentDescription = "Home" ) },
label = { Text ( "Home" ) },
selected = currentDestination?. hasRoute < Home >() == true ,
onClick = {
navController. navigate (Home) {
popUpTo (navController.graph. findStartDestination ().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
NavigationBarItem (
icon = { Icon (Icons.Default.Settings, contentDescription = "Settings" ) },
label = { Text ( "Settings" ) },
selected = currentDestination?. hasRoute < Settings >() == true ,
onClick = {
navController. navigate ( Settings ()) {
popUpTo (navController.graph. findStartDestination ().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
) { innerPadding ->
AppNavHost (
navController = navController,
modifier = Modifier. padding (innerPadding)
)
}
}
The bottom navigation pattern uses saveState and restoreState to preserve the state of each tab when switching between them.
Passing Arguments
Retrieve Arguments in Composable
composable < Profile > { backStackEntry ->
val profile: Profile = backStackEntry. toRoute ()
ProfileScreen (userId = profile.userId)
}
Retrieve Arguments in ViewModel
@HiltViewModel
class ProfileViewModel @Inject constructor (
savedStateHandle: SavedStateHandle ,
private val userRepository: UserRepository
) : ViewModel () {
private val profile: Profile = savedStateHandle. toRoute < Profile >()
val user: StateFlow < User ?> = userRepository
. getUser (profile.userId)
. stateIn (
viewModelScope,
SharingStarted. WhileSubscribed ( 5000 ),
null
)
}
Pass IDs, Not Objects Always pass only IDs or primitives as navigation arguments. Fetch complex objects in the ViewModel using the ID. // CORRECT
navController. navigate ( Profile (userId = "user123" ))
// INCORRECT - Don't pass complex objects
// navController.navigate(Profile(user = complexUserObject))
Deep Links
Define Deep Links
@Serializable
data class Profile ( val userId: String )
composable < Profile >(
deepLinks = listOf (
navDeepLink < Profile >(basePath = "https://example.com/profile" )
)
) { backStackEntry ->
val profile: Profile = backStackEntry. toRoute ()
ProfileScreen (userId = profile.userId)
}
Manifest Configuration
< 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 >
Create PendingIntent for Notifications
val context = LocalContext.current
val deepLinkIntent = Intent (
Intent.ACTION_VIEW,
"https://example.com/profile/user123" . toUri (),
context,
MainActivity:: class .java
)
val pendingIntent = TaskStackBuilder. create (context). run {
addNextIntentWithParentStack (deepLinkIntent)
getPendingIntent (
0 ,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
Nested Navigation
Create nested navigation graphs for logical grouping of screens.
@Serializable object AuthGraph
@Serializable object Login
@Serializable object Register
@Serializable object ForgotPassword
NavHost (navController = navController, startDestination = Home) {
composable < Home > { HomeScreen () }
// Nested graph for authentication flow
navigation < AuthGraph >(startDestination = Login) {
composable < Login > {
LoginScreen (
onLoginSuccess = {
navController. navigate (Home) {
popUpTo < AuthGraph > { inclusive = true }
}
},
onNavigateToRegister = {
navController. navigate (Register)
}
)
}
composable < Register > { RegisterScreen () }
composable < ForgotPassword > { ForgotPasswordScreen () }
}
}
Nested graphs help organize related screens and allow you to pop entire flows at once.
Adaptive Navigation
Use NavigationSuiteScaffold for responsive navigation that adapts to screen size.
@Composable
fun AdaptiveApp () {
val navController = rememberNavController ()
val navBackStackEntry by navController. currentBackStackEntryAsState ()
val currentDestination = navBackStackEntry?.destination
NavigationSuiteScaffold (
navigationSuiteItems = {
item (
icon = { Icon (Icons.Default.Home, contentDescription = "Home" ) },
label = { Text ( "Home" ) },
selected = currentDestination?. hasRoute < Home >() == true ,
onClick = { navController. navigate (Home) }
)
item (
icon = { Icon (Icons.Default.Settings, contentDescription = "Settings" ) },
label = { Text ( "Settings" ) },
selected = currentDestination?. hasRoute < Settings >() == true ,
onClick = { navController. navigate ( Settings ()) }
)
}
) {
AppNavHost (navController = navController)
}
}
NavigationSuiteScaffold automatically switches between:
Bottom navigation bar on phones
Navigation rail on tablets
Navigation drawer on large screens
Testing Navigation
class NavigationTest {
@get : Rule
val composeTestRule = createComposeRule ()
private lateinit var navController: TestNavHostController
@Before
fun setup () {
composeTestRule. setContent {
navController = TestNavHostController (LocalContext.current)
navController.navigatorProvider. addNavigator ( ComposeNavigator ())
AppNavHost (navController = navController)
}
}
@Test
fun verifyStartDestination () {
composeTestRule
. onNodeWithText ( "Welcome" )
. assertIsDisplayed ()
}
@Test
fun navigateToProfile_displaysProfileScreen () {
composeTestRule
. onNodeWithText ( "View Profile" )
. performClick ()
assertTrue (
navController.currentBackStackEntry
?.destination?. hasRoute < Profile >() == true
)
}
}
Best Practices
DO: Use Type-Safe Routes
DON'T: Use String Routes
@Serializable
data class Profile ( val userId: String )
navController. navigate ( Profile (userId = "123" ))
DO: Pass IDs Only
DON'T: Pass Complex Objects
navController. navigate ( Profile (userId = user.id))
// Fetch in ViewModel
class ProfileViewModel (savedStateHandle: SavedStateHandle ) {
val userId = savedStateHandle. toRoute < Profile >().userId
val user = repository. getUser (userId)
}
Critical Rules
Use @Serializable routes for type safety
Pass only IDs/primitives as arguments
Use popUpTo with launchSingleTop for bottom navigation
Extract NavHost to a separate composable for testability
Use SavedStateHandle.toRoute<T>() in ViewModels
DON’T
Pass complex objects as navigation arguments
Create NavController inside NavHost
Navigate in LaunchedEffect without proper keys
Forget FLAG_IMMUTABLE for PendingIntents (Android 12+)
Use string-based routes (legacy pattern)
References