Navigation Deep Dive
This guide provides a comprehensive overview of navigation in this Android template, which uses Jetpack Navigation Compose with type-safe navigation powered by Kotlin Serialization.
Table of Contents
- Overview
- Type-Safe Navigation
- Simple Navigation
- Navigation with Arguments
- Nested Navigation Graphs
- Adaptive Navigation (Bottom Bar / Navigation Rail / Drawer)
- Back Stack Management
- Navigation Testing
- Common Patterns
- Best Practices
- Troubleshooting
Overview
Navigation Architecture
This template uses Jetpack Navigation Compose with type-safe navigation via Kotlin Serialization. This approach provides:
- Compile-time safety: Routes are defined as Kotlin objects/data classes
- Type-safe arguments: Arguments are passed as typed parameters
- IDE support: Auto-completion and refactoring support
- Reduced errors: No string-based routes to mess up
Key Components
The Navigation Layer consists of:
- Route Definitions -
@Serializableobjects/data classes - Navigation Extensions -
NavController.navigateToX() - Graph Builders -
NavGraphBuilder.xScreen() - Top-Level Destinations -
TopLevelDestinationenum - App State -
JetpackAppState - NavHost Setup -
JetpackNavHost
Navigation Flow
graph LR
A[User Action] --> B[Screen Composable]
B --> C[NavController Extension]
C --> D[JetpackAppState/NavController]
D --> E[Route Composable]
E --> F[Screen Composable]
Complete Navigation Graph
This diagram shows the complete navigation graph structure used in this template:
graph TB
subgraph NavHost["NavHost (app/navigation/NavHost.kt)"]
Start{{"Conditional Start Destination<br/>(Auth vs Home)"}}
end
subgraph AuthGraph["AuthNavGraph"]
SignIn["SignIn<br/>(No arguments)"]
SignUp["SignUp<br/>(No arguments)"]
SignIn -. navigate .-> SignUp
SignUp -. navigate .-> SignIn
end
subgraph HomeGraph["HomeNavGraph"]
Home["Home<br/>(No arguments)"]
Item["Item<br/>(itemId: String?)"]
Home -->|onJetpackClick| Item
Item -->|onBackClick| Home
end
Profile["Profile<br/>(Top-level destination)"]
Start -->|isUserLoggedIn = false| AuthGraph
Start -->|isUserLoggedIn = true| HomeGraph
SignIn -. auth success .-> HomeGraph
SignUp -. auth success .-> HomeGraph
HomeGraph <-. bottom nav .-> Profile
Profile <-. bottom nav .-> HomeGraph
Legend:
- Solid arrows (→): Direct navigation within a graph
- Dotted arrows (-.->): Navigation between graphs or conditional navigation
- Orange box: Conditional routing logic
- Red box: Authentication graph (not logged in)
- Green boxes: Main app destinations (logged in)
Type-Safe Navigation
Defining Routes
Routes are defined as @Serializable objects or data classes in each feature module's navigation/
directory:
// feature/home/navigation/HomeNavigation.kt
package dev.atick.feature.home.navigation
import kotlinx.serialization.Serializable
// Simple route without arguments
@Serializable
data object Home
// Route with required argument
@Serializable
data class Item(val itemId: String?)
// Navigation graph
@Serializable
data object HomeNavGraph
Why use @Serializable?
- Kotlin Serialization automatically handles serialization/deserialization
- Type-safe argument passing with compile-time checking
- No manual string parsing or type conversion
- Supports complex types (enums, lists, custom classes)
Route Organization Pattern (Used in This Template)
Each feature module has a navigation/ directory with a single file defining all routes:
feature/auth/navigation/- ContainsAuthNavigation.ktdefiningAuthNavGraph,SignIn,SignUpfeature/home/navigation/- ContainsHomeNavigation.ktdefiningHomeNavGraph,Home,Itemfeature/profile/navigation/- ContainsProfileNavigation.ktdefiningProfile
Example from this template:
// feature/auth/navigation/AuthNavigation.kt
package dev.atick.feature.auth.navigation
import kotlinx.serialization.Serializable
@Serializable
data object AuthNavGraph
@Serializable
data object SignIn
@Serializable
data object SignUp
Simple Navigation
NavController Extension
Create extension functions for type-safe navigation in the same file as route definitions:
// feature/profile/navigation/ProfileNavigation.kt
fun NavController.navigateToProfileScreen(navOptions: NavOptions? = null) {
navigate(Profile, navOptions)
}
Example from this template:
// feature/auth/navigation/AuthNavigation.kt
fun NavController.navigateToSignInScreen(navOptions: NavOptions? = null) {
navigate(SignIn, navOptions)
}
fun NavController.navigateToSignUpScreen(navOptions: NavOptions? = null) {
navigate(SignUp, navOptions)
}
Benefits:
- Centralized navigation logic
- Consistent API across features
- Easy to find all navigation entry points
- Supports NavOptions for advanced behavior
Graph Builder Extension
Create extension functions to register screens:
// feature/profile/navigation/ProfileNavigation.kt
fun NavGraphBuilder.profileScreen(
onShowSnackbar: suspend (String, SnackbarAction, Throwable?) -> Boolean
) {
composable<Profile> {
ProfileScreen(
onShowSnackbar = onShowSnackbar
)
}
}
Example from this template:
// feature/auth/navigation/AuthNavigation.kt
fun NavGraphBuilder.signInScreen(
onSignUpClick: () -> Unit,
onShowSnackbar: suspend (String, SnackbarAction, Throwable?) -> Boolean,
) {
composable<SignIn> {
SignInScreen(
onSignUpClick = onSignUpClick,
onShowSnackbar = onShowSnackbar,
)
}
}
NavHost Setup (Template Pattern)
This template uses a centralized JetpackNavHost composable:
// app/src/main/kotlin/dev/atick/compose/navigation/NavHost.kt
@Composable
fun JetpackNavHost(
appState: JetpackAppState,
onShowSnackbar: suspend (String, SnackbarAction, Throwable?) -> Boolean,
modifier: Modifier = Modifier,
) {
val navController = appState.navController
val startDestination =
if (appState.isUserLoggedIn) HomeNavGraph::class else AuthNavGraph::class
NavHost(
navController = navController,
startDestination = startDestination,
modifier = modifier,
) {
authNavGraph(
nestedNavGraphs = {
signInScreen(
onSignUpClick = navController::navigateToSignUpScreen,
onShowSnackbar = onShowSnackbar,
)
signUpScreen(
onSignInClick = navController::navigateToSignInScreen,
onShowSnackbar = onShowSnackbar,
)
},
)
homeNavGraph(
nestedNavGraphs = {
homeScreen(
onJetpackClick = navController::navigateToItemScreen,
onShowSnackbar = onShowSnackbar,
)
itemScreen(
onBackClick = navController::popBackStack,
onShowSnackbar = onShowSnackbar,
)
},
)
profileScreen(
onShowSnackbar = onShowSnackbar,
)
}
}
Key Pattern: Conditional start destination based on authentication state.
Navigation with Arguments
Required Arguments
Pass required data via constructor parameters:
// Define route with required argument
@Serializable
data class Item(val itemId: String?)
// Navigation extension
fun NavController.navigateToItemScreen(itemId: String?) {
navigate(Item(itemId)) { launchSingleTop = true }
}
// Screen registration - arguments are extracted automatically
fun NavGraphBuilder.itemScreen(
onBackClick: () -> Unit,
onShowSnackbar: suspend (String, SnackbarAction, Throwable?) -> Boolean,
) {
composable<Item> {
ItemScreen(
onBackClick = onBackClick,
onShowSnackbar = onShowSnackbar,
)
}
}
In the Route/Screen composable, access arguments via the ViewModel:
// feature/home/ui/item/ItemScreen.kt
@Composable
fun ItemScreen(
onBackClick: () -> Unit,
onShowSnackbar: suspend (String, SnackbarAction, Throwable?) -> Boolean,
viewModel: ItemViewModel = hiltViewModel()
) {
// ViewModel automatically receives itemId via SavedStateHandle
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
StatefulComposable(
state = uiState,
onShowSnackbar = onShowSnackbar
) { screenData ->
// UI implementation
}
}
In the ViewModel, access arguments via SavedStateHandle:
@HiltViewModel
class ItemViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val repository: JetpackRepository
) : ViewModel() {
// Navigation arguments are automatically available in SavedStateHandle
private val itemId: String? = savedStateHandle.toRoute<Item>().itemId
// Use itemId to load data
init {
loadItem(itemId)
}
}
Optional Arguments
Use default parameter values for optional arguments:
@Serializable
data class ArticleDetail(
val articleId: String,
val scrollToComments: Boolean = false,
val highlightCommentId: String? = null
)
// Navigation with optional arguments
fun NavController.navigateToArticleDetail(
articleId: String,
scrollToComments: Boolean = false,
highlightCommentId: String? = null,
navOptions: NavOptions? = null
) {
navigate(
ArticleDetail(
articleId = articleId,
scrollToComments = scrollToComments,
highlightCommentId = highlightCommentId
),
navOptions
)
}
// Usage
navController.navigateToArticleDetail("article-123") // Use defaults
navController.navigateToArticleDetail(
articleId = "article-123",
scrollToComments = true,
highlightCommentId = "comment-456"
)
Complex Types
Kotlin Serialization supports complex argument types:
// Enum arguments
enum class FilterType {
ALL, FAVORITES, ARCHIVED
}
@Serializable
data class ArticleList(
val filter: FilterType = FilterType.ALL
)
// List arguments (use @Serializable-compatible types)
@Serializable
data class MultiSelect(
val selectedIds: List<String> = emptyList()
)
// Custom serializable types
@Serializable
data class DateRange(
val startDate: String, // Use ISO 8601 format
val endDate: String
)
@Serializable
data class Reports(
val dateRange: DateRange
)
Nested Navigation Graphs
Template Pattern for Nested Graphs
This template uses a specific pattern for nested navigation graphs:
// feature/auth/navigation/AuthNavigation.kt
fun NavGraphBuilder.authNavGraph(
nestedNavGraphs: NavGraphBuilder.() -> Unit,
) {
navigation<AuthNavGraph>(startDestination = SignIn) {
nestedNavGraphs()
}
}
Key Pattern:
- The graph builder accepts a lambda (
nestedNavGraphs) that defines child screens - This allows the NavHost to control which screens are included in the graph
Using Nested Graphs
Include nested graphs in the main NavHost:
// app/src/main/kotlin/dev/atick/compose/navigation/NavHost.kt
NavHost(
navController = navController,
startDestination = startDestination,
modifier = modifier,
) {
authNavGraph(
nestedNavGraphs = {
signInScreen(
onSignUpClick = navController::navigateToSignUpScreen,
onShowSnackbar = onShowSnackbar,
)
signUpScreen(
onSignInClick = navController::navigateToSignInScreen,
onShowSnackbar = onShowSnackbar,
)
},
)
homeNavGraph(
nestedNavGraphs = {
homeScreen(/* ... */)
itemScreen(/* ... */)
},
)
profileScreen(/* ... */)
}
Navigating to Nested Graphs
Navigate to the root of a nested graph:
// Navigate to HomeNavGraph extension
fun NavController.navigateToHomeNavGraph(navOptions: NavOptions? = null) {
navigate(HomeNavGraph, navOptions)
}
// Navigate directly to a specific screen in the graph
navController.navigateToItemScreen("item-123")
Conditional Start Destination
This template demonstrates conditional navigation based on authentication:
val startDestination =
if (appState.isUserLoggedIn) HomeNavGraph::class else AuthNavGraph::class
NavHost(
navController = navController,
startDestination = startDestination,
modifier = modifier,
) {
// ...
}
Adaptive Navigation (Bottom Bar / Navigation Rail / Drawer)
NavigationSuiteScaffold Pattern
This template uses NavigationSuiteScaffold for adaptive navigation that automatically switches between:
- Bottom navigation bar (compact screens)
- Navigation rail (medium screens)
- Navigation drawer (expanded screens)
Top-Level Destinations
Define top-level destinations in an enum:
// app/src/main/kotlin/dev/atick/compose/navigation/TopLevelDestination.kt
enum class TopLevelDestination(
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
@StringRes val iconTextId: Int,
@StringRes val titleTextId: Int,
val route: KClass<*>,
) {
HOME(
selectedIcon = Icons.Filled.Home,
unselectedIcon = Icons.Outlined.Home,
iconTextId = R.string.home,
titleTextId = R.string.home,
route = Home::class,
),
PROFILE(
selectedIcon = Icons.Filled.Person,
unselectedIcon = Icons.Outlined.Person,
iconTextId = R.string.profile,
titleTextId = R.string.profile,
route = Profile::class,
),
}
JetpackAppState Pattern
Centralize navigation logic in an app state holder:
// app/src/main/kotlin/dev/atick/compose/ui/JetpackAppState.kt
@Stable
class JetpackAppState(
val isUserLoggedIn: Boolean,
val navController: NavHostController,
// ...
) {
val currentDestination: NavDestination?
@Composable get() = /* ... */
val currentTopLevelDestination: TopLevelDestination?
@Composable get() = TopLevelDestination.entries.firstOrNull { topLevelDestination ->
currentDestination?.hasRoute(route = topLevelDestination.route) == true
}
val topLevelDestinations: List<TopLevelDestination> = TopLevelDestination.entries
fun navigateToTopLevelDestination(topLevelDestination: TopLevelDestination) {
val topLevelNavOptions = navOptions {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
when (topLevelDestination) {
TopLevelDestination.HOME -> navController.navigateToHomeNavGraph(topLevelNavOptions)
TopLevelDestination.PROFILE -> navController.navigateToProfileScreen(topLevelNavOptions)
}
}
}
NavigationSuiteScaffold Setup
// app/src/main/kotlin/dev/atick/compose/ui/JetpackApp.kt
JetpackNavigationSuiteScaffold(
navigationSuiteItems = {
appState.topLevelDestinations.forEach { destination ->
val selected = currentDestination.isTopLevelDestinationInHierarchy(destination)
item(
selected = selected,
onClick = { appState.navigateToTopLevelDestination(destination) },
icon = {
Icon(
imageVector = destination.unselectedIcon,
contentDescription = null,
)
},
selectedIcon = {
Icon(
imageVector = destination.selectedIcon,
contentDescription = null,
)
},
label = { Text(stringResource(destination.iconTextId)) }
)
}
},
windowAdaptiveInfo = windowAdaptiveInfo,
) {
JetpackScaffold(
appState = appState,
snackbarHostState = snackbarHostState,
onTopAppBarActionClick = onTopAppBarActionClick,
modifier = modifier,
)
}
Benefits:
- Automatically adapts to different screen sizes
- Consistent navigation UX across device types
- Single source of truth for navigation items
- Built-in state preservation
Conditional Navigation UI
Show navigation only for top-level destinations:
if (appState.currentTopLevelDestination == null) {
// No navigation UI (e.g., auth screens, detail screens)
JetpackScaffold(/* ... */)
return
}
// Show NavigationSuiteScaffold for top-level destinations
JetpackNavigationSuiteScaffold(/* ... */)
Back Stack Management
Understanding the Back Stack
The back stack is a LIFO (Last In, First Out) stack of destinations:
Navigating Up
Go back one step in the back stack:
// In your composable
onBackClick = { navController.popBackStack() }
// Or use navigateUp() for proper up navigation
onBackClick = { navController.navigateUp() }
Example from this template:
// feature/home/navigation/HomeNavigation.kt
itemScreen(
onBackClick = navController::popBackStack,
onShowSnackbar = onShowSnackbar,
)
Pop to Destination
Remove destinations until reaching a specific one:
// Pop back to Home, removing all intermediate screens
navController.navigate(Home) {
popUpTo(Home) {
inclusive = true // Include Home itself in the pop
}
}
Top-Level Navigation Pattern
This template uses a consistent pattern for top-level navigation:
fun navigateToTopLevelDestination(topLevelDestination: TopLevelDestination) {
val topLevelNavOptions = navOptions {
// Pop up to the start destination to avoid stack buildup
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination
launchSingleTop = true
// Restore state when re-selecting a previously selected item
restoreState = true
}
when (topLevelDestination) {
TopLevelDestination.HOME -> navController.navigateToHomeNavGraph(topLevelNavOptions)
TopLevelDestination.PROFILE -> navController.navigateToProfileScreen(topLevelNavOptions)
}
}
Key aspects:
saveState = true: Preserves screen state when navigating awaylaunchSingleTop = true: Prevents duplicate destinationsrestoreState = true: Restores saved state when returning
Single Top Launch Mode
Avoid duplicate destinations in the stack:
Example from this template:
fun NavController.navigateToItemScreen(itemId: String?) {
navigate(Item(itemId)) { launchSingleTop = true }
}
Back Press Handling
Handle back press with BackHandler:
@Composable
fun UnsavedChangesScreen(
onNavigateBack: () -> Unit,
viewModel: EditViewModel = hiltViewModel()
) {
val hasUnsavedChanges by viewModel.hasUnsavedChanges.collectAsState()
var showDialog by remember { mutableStateOf(false) }
BackHandler(enabled = hasUnsavedChanges) {
showDialog = true
}
if (showDialog) {
AlertDialog(
onDismissRequest = { showDialog = false },
title = { Text("Unsaved Changes") },
text = { Text("You have unsaved changes. Are you sure you want to leave?") },
confirmButton = {
TextButton(
onClick = {
showDialog = false
onNavigateBack()
}
) {
Text("Leave")
}
},
dismissButton = {
TextButton(onClick = { showDialog = false }) {
Text("Stay")
}
}
)
}
// Screen content...
}
Navigation Testing
Testing Navigation Actions
Test that navigation happens correctly:
@OptIn(ExperimentalTestApi::class)
class HomeNavigationTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private lateinit var navController: TestNavHostController
@Before
fun setup() {
composeTestRule.setContent {
navController = TestNavHostController(LocalContext.current).apply {
navigatorProvider.addNavigator(ComposeNavigator())
}
NavHost(navController = navController, startDestination = Home) {
homeScreen(
onJetpackClick = { itemId ->
navController.navigateToItemScreen(itemId)
},
onShowSnackbar = { _, _, _ -> true }
)
itemScreen(
onBackClick = { navController.popBackStack() },
onShowSnackbar = { _, _, _ -> true }
)
}
}
}
@Test
fun navigateToItem_updatesCurrentDestination() {
// Navigate to item
composeTestRule.runOnUiThread {
navController.navigateToItemScreen("item-123")
}
// Verify current destination
composeTestRule.runOnUiThread {
val currentRoute = navController.currentBackStackEntry?.toRoute<Item>()
assertEquals("item-123", currentRoute?.itemId)
}
}
@Test
fun clickBackButton_navigatesUp() {
// Navigate to item
composeTestRule.runOnUiThread {
navController.navigateToItemScreen("item-123")
}
// Click back button
composeTestRule.onNodeWithContentDescription("Back").performClick()
// Verify navigation
composeTestRule.runOnUiThread {
val currentRoute = navController.currentBackStackEntry?.destination?.hasRoute<Home>()
assertTrue(currentRoute == true)
}
}
}
Testing Back Stack
Test back stack behavior:
@Test
fun backStack_preservesState() {
val navController = TestNavHostController(
ApplicationProvider.getApplicationContext()
).apply {
navigatorProvider.addNavigator(ComposeNavigator())
}
// Navigate through several screens
navController.navigate(Home)
navController.navigate(Profile)
navController.navigate(Settings)
// Verify back stack size
assertEquals(3, navController.backQueue.size)
// Pop back
navController.popBackStack()
// Verify current destination
val currentRoute = navController.currentBackStackEntry?.destination?.hasRoute<Profile>()
assertTrue(currentRoute == true)
}
Common Patterns
Conditional Navigation
Navigate based on state:
@Composable
fun HomeRoute(
onShowSnackbar: suspend (String, SnackbarAction, Throwable?) -> Boolean,
onNavigateToAuth: () -> Unit,
viewModel: HomeViewModel = hiltViewModel()
) {
val isAuthenticated by viewModel.isAuthenticated.collectAsStateWithLifecycle()
LaunchedEffect(isAuthenticated) {
if (!isAuthenticated) {
onNavigateToAuth()
}
}
// Rest of the screen...
}
Navigation After Async Operation
Navigate after an async operation completes:
@Composable
fun CreateArticleRoute(
onShowSnackbar: suspend (String, SnackbarAction, Throwable?) -> Boolean,
onNavigateToDetail: (String) -> Unit,
viewModel: CreateArticleViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// Navigate when article is created
LaunchedEffect(uiState.data.createdArticleId) {
uiState.data.createdArticleId?.let { articleId ->
onNavigateToDetail(articleId)
}
}
StatefulComposable(
state = uiState,
onShowSnackbar = onShowSnackbar
) { screenData ->
CreateArticleScreen(
screenData = screenData,
onSubmit = viewModel::createArticle
)
}
}
Accessing Navigation Arguments in ViewModel
Use SavedStateHandle.toRoute<T>() to access navigation arguments:
@HiltViewModel
class ItemViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val repository: JetpackRepository
) : ViewModel() {
// Extract navigation arguments
private val itemId: String? = savedStateHandle.toRoute<Item>().itemId
private val _uiState = MutableStateFlow(UiState(ItemScreenData()))
val uiState = _uiState.asStateFlow()
init {
loadItem(itemId)
}
private fun loadItem(itemId: String?) {
_uiState.updateStateWith {
repository.getItem(itemId)
}
}
}
Best Practices
Do's
Use type-safe navigation
// Good: Type-safe
navController.navigate(Item(itemId = "123"))
// Avoid: String-based routes
navController.navigate("item/123")
Create navigation extensions
// Centralize navigation logic
fun NavController.navigateToItem(itemId: String?) {
navigate(Item(itemId = itemId)) { launchSingleTop = true }
}
Separate Route and Screen composables
// Route: Handles ViewModel and navigation callbacks
@Composable
fun ItemScreen(
onBackClick: () -> Unit,
onShowSnackbar: suspend (String, SnackbarAction, Throwable?) -> Boolean,
viewModel: ItemViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
StatefulComposable(uiState, onShowSnackbar) { screenData ->
ItemScreenContent(screenData, onBackClick)
}
}
// Screen content: Pure UI, no navigation knowledge
@Composable
private fun ItemScreenContent(
screenData: ItemScreenData,
onBackClick: () -> Unit
) {
// UI only
}
Use JetpackAppState pattern for top-level navigation
// Centralize navigation state and logic
class JetpackAppState(
val navController: NavHostController,
// ...
) {
fun navigateToTopLevelDestination(destination: TopLevelDestination) {
// Consistent navigation logic
}
}
Use NavOptions for complex navigation
Pass callbacks, not NavController
// Good: Pass specific callbacks
fun NavGraphBuilder.itemScreen(
onBackClick: () -> Unit,
onShowSnackbar: suspend (String, SnackbarAction, Throwable?) -> Boolean
) {
composable<Item> {
ItemScreen(onBackClick, onShowSnackbar)
}
}
// Avoid: Passing NavController
fun NavGraphBuilder.itemScreen(navController: NavController) {}
Don'ts
Don't pass NavController to Screen composables
// Bad: Screen shouldn't know about NavController
@Composable
fun ItemScreen(navController: NavController) {
}
// Good: Use callbacks
@Composable
fun ItemScreen(onBackClick: () -> Unit) {
}
Don't use string-based routes
Don't navigate in ViewModels
// Bad: ViewModel shouldn't know about navigation
class ItemViewModel(private val navController: NavController) : ViewModel() {
fun onBackClick() {
navController.navigateUp()
}
}
// Good: Use events or rely on callbacks from composables
class ItemViewModel : ViewModel() {
// ViewModel focuses on business logic only
// Navigation is handled by composables via callbacks
}
Don't forget state preservation for top-level navigation
// Avoid: Loses state when switching tabs
navController.navigate(Profile)
// Use: Preserves state
navController.navigate(Profile) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
Troubleshooting
Common Issues
Issue: Arguments not accessible in ViewModel
Problem: Navigation arguments are null in ViewModel.
Solution:
// Use SavedStateHandle.toRoute<T>() to extract arguments
@HiltViewModel
class ItemViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val repository: JetpackRepository
) : ViewModel() {
// Extract route arguments
private val itemId: String? = savedStateHandle.toRoute<Item>().itemId
}
Issue: Back stack not clearing
Problem: Back button doesn't behave as expected.
Solution:
// Clear specific destinations
navController.navigate(Home) {
popUpTo(Login) {
inclusive = true
}
}
// Clear entire back stack
navController.navigate(Home) {
popUpTo(navController.graph.id) {
inclusive = true
}
}
Issue: Nested graph not found
Problem: Can't navigate to screens within nested graph.
Solution:
// Ensure nested graph is registered in NavHost
NavHost(navController = navController, startDestination = startDestination) {
authNavGraph(
nestedNavGraphs = {
signInScreen(/* ... */)
signUpScreen(/* ... */)
}
)
homeNavGraph(
nestedNavGraphs = {
homeScreen(/* ... */)
itemScreen(/* ... */)
}
)
}
Issue: State not preserved in bottom navigation
Problem: Screen state is lost when switching tabs.
Solution:
// Enable state saving in NavOptions
navController.navigate(destination) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
restoreState = true
launchSingleTop = true
}
Issue: Navigation UI showing on non-top-level screens
Problem: Bottom bar or navigation rail visible on detail screens.
Solution:
// Check currentTopLevelDestination in JetpackApp
if (appState.currentTopLevelDestination == null) {
// No navigation UI for auth screens, detail screens, etc.
JetpackScaffold(/* ... */)
return
}
// Show NavigationSuiteScaffold only for top-level destinations
JetpackNavigationSuiteScaffold(/* ... */)
Summary
This guide covered:
- Type-safe navigation with Kotlin Serialization
- Navigation patterns used in this template
- Simple and complex navigation with arguments
- Nested navigation graphs (template pattern)
- Adaptive navigation with NavigationSuiteScaffold
- JetpackAppState pattern for centralized navigation
- Back stack management
- Navigation testing strategies
- Common navigation patterns
- Best practices specific to this template
- Troubleshooting common issues
Key Takeaways:
- Always use type-safe navigation with
@Serializableroutes - Follow the template's nested graph pattern with
nestedNavGraphslambda - Use JetpackAppState for centralized top-level navigation logic
- Use NavigationSuiteScaffold for adaptive navigation UI
- Access navigation arguments in ViewModel via
SavedStateHandle.toRoute<T>() - Pass callbacks to composables, never NavController
- Use conditional start destination for auth flows
- Preserve state with saveState/restoreState for top-level navigation
Further Reading
Concept Guides
- Architecture Overview - Understand where navigation fits in the architecture
- State Management - Learn how to pass state through navigation
- Adding Features - Step-by-step guide including navigation setup
- Quick Reference - Navigation patterns cheat sheet
Module Documentation
- App Module - Main app navigation setup and JetpackAppState
- Feature Auth Module - Example of authentication navigation flow
- Feature Home Module - Example of nested navigation graph