| name | compose-navigation |
| description | Implement navigation in Jetpack Compose using Navigation Compose. Use when asked to set up navigation, pass arguments between screens, handle deep links, or structure multi-screen apps. |
Compose Navigation
Overview
Implement type-safe navigation in Jetpack Compose applications using the Navigation Compose library. This skill covers NavHost setup, argument passing, deep links, nested graphs, adaptive navigation, and testing.
Setup
Add the Navigation Compose dependency:
dependencies {
implementation("androidx.navigation:navigation-compose:2.8.5")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
}
plugins {
kotlin("plugin.serialization") version "2.0.21"
}
Core Concepts
1. Define Routes (Type-Safe)
Use @Serializable data classes/objects for type-safe routes:
import kotlinx.serialization.Serializable
@Serializable
object Home
@Serializable
data class Detail(val itemId: String)
@Serializable
data class ProductDetail(
val productId: String,
val category: String,
val fromSearch: Boolean = false
)
@Serializable
data class UserProfile(val userId: Long)
@Serializable
data class Settings(val section: String? = null)
2. Create NavHost & Basic Navigation
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Home
) {
composable<Home> {
HomeScreen(
onItemClick = { itemId ->
navController.navigate(Detail(itemId))
},
onSettingsClick = {
navController.navigate(Settings())
}
)
}
composable<Detail> { backStackEntry ->
val detail: Detail = backStackEntry.toRoute()
DetailScreen(
itemId = detail.itemId,
onBack = { navController.popBackStack() }
)
}
composable<Settings> {
SettingsScreen(
onBack = { navController.popBackStack() }
)
}
}
}
Navigation Patterns
Navigation with Arguments
@Composable
fun NavigationWithArgs() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = Home) {
composable<Home> {
HomeScreen(
onProductClick = { productId, category ->
navController.navigate(
ProductDetail(
productId = productId,
category = category,
fromSearch = false
)
)
}
)
}
composable<ProductDetail> { backStackEntry ->
val args: ProductDetail = backStackEntry.toRoute()
ProductDetailScreen(
productId = args.productId,
category = args.category,
showBackToSearch = args.fromSearch
)
}
composable<UserProfile> { backStackEntry ->
val args: UserProfile = backStackEntry.toRoute()
UserProfileScreen(userId = args.userId)
}
}
}
Bottom Navigation
enum class BottomNavDestination(
val route: Any,
val icon: ImageVector,
val label: String
) {
HOME(Home, Icons.Default.Home, "Home"),
SEARCH(Search, Icons.Default.Search, "Search"),
FAVORITES(Favorites, Icons.Default.Favorite, "Favorites"),
PROFILE(Profile, Icons.Default.Person, "Profile")
}
@Composable
fun MainScreenWithBottomNav() {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
Scaffold(
bottomBar = {
NavigationBar {
BottomNavDestination.entries.forEach { destination ->
NavigationBarItem(
icon = {
Icon(destination.icon, contentDescription = destination.label)
},
label = { Text(destination.label) },
selected = currentDestination?.hasRoute(destination.route::class) == true,
onClick = {
navController.navigate(destination.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
}
) { innerPadding ->
NavHost(
navController = navController,
startDestination = Home,
modifier = Modifier.padding(innerPadding)
) {
composable<Home> { HomeScreen() }
composable<Search> { SearchScreen() }
composable<Favorites> { FavoritesScreen() }
composable<Profile> { ProfileScreen() }
}
}
}
Bottom Nav with Badges
@Composable
fun BottomNavWithBadges(
cartCount: Int,
notificationCount: Int
) {
NavigationBar {
NavigationBarItem(
icon = { Icon(Icons.Default.Home, null) },
label = { Text("Home") },
selected = true,
onClick = { }
)
NavigationBarItem(
icon = {
BadgedBox(
badge = {
if (cartCount > 0) {
Badge { Text("$cartCount") }
}
}
) {
Icon(Icons.Default.ShoppingCart, null)
}
},
label = { Text("Cart") },
selected = false,
onClick = { }
)
}
}
Navigation Drawer (Modal)
@Composable
fun ModalDrawerNavigation() {
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
var selectedItem by remember { mutableStateOf(0) }
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet {
items.forEachIndexed { index, item ->
NavigationDrawerItem(
icon = { Icon(item.icon, contentDescription = null) },
label = { Text(item.label) },
selected = index == selectedItem,
onClick = {
selectedItem = index
scope.launch { drawerState.close() }
},
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
}
}
}
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(items[selectedItem].label) },
navigationIcon = {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Default.Menu, "Open drawer")
}
}
)
}
) { padding ->
Content(modifier = Modifier.padding(padding))
}
}
}
Adaptive Navigation (Rail/Drawer)
@Composable
fun NavigationRailLayout() {
var selectedItem by remember { mutableStateOf(0) }
Row(modifier = Modifier.fillMaxSize()) {
NavigationRail(
header = {
FloatingActionButton(onClick = { }) { Icon(Icons.Default.Add, "Create") }
}
) {
Spacer(Modifier.weight(1f))
railItems.forEachIndexed { index, item ->
NavigationRailItem(
icon = { Icon(item.icon, null) },
label = { Text(item.label) },
selected = selectedItem == index,
onClick = { selectedItem = index }
)
}
Spacer(Modifier.weight(1f))
}
Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
}
}
}
Deep Linking
Basic Deep Link Setup
AndroidManifest.xml:
<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="myapp" />
<data android:scheme="https" android:host="myapp.com" />
</intent-filter>
Composable Setup:
@Composable
fun DeepLinkNavigation() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = Home) {
composable<Home> { HomeScreen() }
composable<ProductDetail>(
deepLinks = listOf(
navDeepLink<ProductDetail>(basePath = "https://myapp.com/product"),
navDeepLink<ProductDetail>(basePath = "myapp://product")
)
) { backStackEntry ->
val args: ProductDetail = backStackEntry.toRoute()
ProductDetailScreen(productId = args.productId)
}
}
}
Handling Intent in Activity
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AppTheme {
val navController = rememberNavController()
LaunchedEffect(Unit) {
intent?.data?.let { uri -> navController.handleDeepLink(intent) }
}
AppNavigation(navController = navController)
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
}
}
Nested Navigation
@Composable
fun NestedNavigation() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = MainGraph) {
navigation<MainGraph>(startDestination = Home) {
composable<Home> {
HomeScreen(onItemClick = { navController.navigate(Detail(it)) })
}
composable<Search> { SearchScreen() }
composable<Profile> {
ProfileScreen(onSettingsClick = { navController.navigate(SettingsGraph) })
}
}
composable<Detail> { backStackEntry ->
val args: Detail = backStackEntry.toRoute()
DetailScreen(itemId = args.itemId)
}
navigation<SettingsGraph>(startDestination = SettingsMain) {
composable<SettingsMain> {
SettingsScreen(
onAccountClick = { navController.navigate(AccountSettings) },
onNotificationsClick = { navController.navigate(NotificationSettings) }
)
}
composable<AccountSettings> { AccountSettingsScreen() }
composable<NotificationSettings> { NotificationSettingsScreen() }
}
}
}
@Serializable object MainGraph
@Serializable object SettingsGraph
@Serializable object SettingsMain
@Serializable object AccountSettings
@Serializable object NotificationSettings
State Management & Animation
ViewModel Integration
@HiltViewModel
class NavigationViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
private val _navigationEvents = MutableSharedFlow<NavigationEvent>()
val navigationEvents = _navigationEvents.asSharedFlow()
fun navigateToDetail(itemId: String) {
viewModelScope.launch {
_navigationEvents.emit(NavigationEvent.NavigateToDetail(itemId))
}
}
}
@Composable
fun NavigationHandler(navController: NavHostController, viewModel: NavigationViewModel = hiltViewModel()) {
LaunchedEffect(Unit) {
viewModel.navigationEvents.collect { event ->
when (event) {
is NavigationEvent.NavigateToDetail -> navController.navigate(Detail(event.itemId))
NavigationEvent.NavigateBack -> navController.popBackStack()
}
}
}
}
Back Handler
@Composable
fun ScreenWithBackHandler(onBack: () -> Unit) {
var showExitDialog by remember { mutableStateOf(false) }
BackHandler { showExitDialog = true }
if (showExitDialog) {
AlertDialog(
onDismissRequest = { showExitDialog = false },
title = { Text("Exit App?") },
text = { Text("Are you sure you want to exit?") },
confirmButton = { TextButton(onClick = onBack) { Text("Exit") } },
dismissButton = { TextButton(onClick = { showExitDialog = false }) { Text("Cancel") } }
)
}
}
Navigation Animations
NavHost(
navController = navController,
startDestination = Home,
enterTransition = {
slideIntoContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween(300)
)
},
exitTransition = {
slideOutOfContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween(300)
)
},
popEnterTransition = {
slideIntoContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(300)
)
},
popExitTransition = {
slideOutOfContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(300)
)
}
) {
}
Testing
Setup
androidTestImplementation("androidx.navigation:navigation-testing:2.8.5")
Test 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()
}
}
Critical Rules
DO
- 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)