Navigation 3: The Future of Android Navigation
Introduction
Navigation 3 represents a fundamental reimagining of Android navigation for Jetpack Compose. It’s an experimental alpha library that gives developers full control over the back stack, making navigation as simple as adding and removing items from a list. Unlike its predecessor, Navigation 3 embraces Compose’s declarative philosophy by adopting a state-based navigation model instead of an event-based one.
Important Note: Navigation 3 is currently in alpha and experimental. APIs may change in future releases.
Why Navigation 3?
The original Jetpack Navigation library, while functional, had several limitations when working with Compose:
Event-based navigation model didn’t align with Compose’s state-driven paradigm
Limited control over the back stack
Difficulty implementing adaptive layouts for tablets and foldables
Complex API for common navigation patterns
NavHost was a “black box” that only rendered the top item
Navigation 3 solves these issues by providing a flexible, composable approach to navigation that feels natural in Compose applications.
Core Concepts
1. Back Stack as State
In Navigation 3, the back stack represents your app’s navigation state. It’s a developer-owned, snapshot-state backed list where you have complete control.
2. Keys Instead of Content
The back stack doesn’t contain actual content but references to content known as keys. Keys can be any type but are usually simple, serializable data classes. This approach offers several benefits:
Simple navigation by pushing keys
Back stack can be saved to persistent storage
Survives configuration changes and process death
3. NavEntry
Content is modeled using NavEntry, a class containing a composable function that represents a destination.
4. NavDisplay
NavDisplay is a composable that observes your back stack and updates its UI accordingly.
Architecture Overview
Data Flow in Navigation 3
Setting Up Navigation 3
Dependencies
Add these to your build.gradle.kts:
dependencies {
val nav3Version = “1.0.0-alpha01”
// Core Navigation 3
implementation(”androidx.navigation:navigation-runtime3:$nav3Version”)
implementation(”androidx.navigation:navigation-ui3:$nav3Version”)
// ViewModel support
implementation(”androidx.lifecycle:lifecycle-viewmodel-navigation3:2.9.0-alpha01”)
// Material 3 adaptive layouts
implementation(”androidx.compose.material3.adaptive:adaptive-navigation3:1.1.0-alpha01”)
// Kotlin Serialization
implementation(”org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0”)
}
plugins {
id(”org.jetbrains.kotlin.plugin.serialization”) version “1.9.0”
}
Basic Navigation Setup
Step 1: Define Navigation Keys
import kotlinx.serialization.Serializable
import androidx.navigation3.NavKey
// Simple destination
@Serializable
data object HomeScreen : NavKey
// Destination with arguments
@Serializable
data class ProductDetail(val productId: String) : NavKey
@Serializable
data class UserProfile(
val userId: String,
val showEdit: Boolean = false
) : NavKey
Step 2: Create Back Stack
@Composable
fun MyApp() {
// Create back stack with initial key
val backStack = rememberNavBackStack<NavKey>(HomeScreen)
// You can also use simple list for custom persistence
// val backStack = remember { mutableStateListOf<NavKey>(HomeScreen) }
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = { key ->
when (key) {
is HomeScreen -> NavEntry(key) {
HomeContent(
onNavigateToProduct = { id ->
backStack.add(ProductDetail(id))
}
)
}
is ProductDetail -> NavEntry(key) {
ProductDetailContent(productId = key.productId)
}
is UserProfile -> NavEntry(key) {
ProfileContent(
userId = key.userId,
showEdit = key.showEdit
)
}
else -> NavEntry(Unit) {
Text(”Unknown screen”)
}
}
}
)
}
Step 3: Navigation is Just List Operations
// Navigate forward - add to back stack
backStack.add(ProductDetail(productId = “123”))
// Navigate back - remove from back stack
backStack.removeLastOrNull()
// Replace current screen
backStack[backStack.lastIndex] = NewScreen
// Clear stack and navigate
backStack.clear()
backStack.add(HomeScreen)
// Navigate to specific position
backStack.removeLast() // Pop one
backStack.dropLast(2) // Pop multiple
Navigation State Management
NavDisplay Parameters
Complete Setup with All Parameters
@Composable
fun CompleteNavigation() {
val backStack = rememberNavBackStack<NavKey>(HomeScreen)
NavDisplay(
// 1. Back stack to observe
backStack = backStack,
// 2. Back button handler
onBack = { entriesCount ->
repeat(entriesCount) {
backStack.removeLastOrNull()
}
},
// 3. Entry provider - keys to content
entryProvider = entryProvider {
entry<HomeScreen> {
HomeContent()
}
entry<ProductDetail> { key ->
ProductDetailContent(productId = key.productId)
}
},
// 4. Scene strategy for layouts
sceneStrategy = SinglePaneSceneStrategy(),
// 5. Entry decorators
entryDecorators = listOf(
rememberSceneSetupNavEntryDecorator(),
rememberSavedStateNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
// 6. Custom animations
transitionSpec = {
ContentTransform(
fadeIn(tween(300)),
fadeOut(tween(300))
)
},
// 7. Size transform for animations
sizeTransform = SizeTransform { _, _ ->
tween(300)
}
)
}
Entry Decorators Explained
Entry decorators add functionality to NavEntry objects:
entryDecorators = listOf(
// 1. Scene setup - manages lifecycle
rememberSceneSetupNavEntryDecorator(),
// 2. Saved state - persists state across process death
rememberSavedStateNavEntryDecorator(),
// 3. ViewModel retention - scopes ViewModels to NavEntry
rememberViewModelStoreNavEntryDecorator()
)
Persisting Navigation State
Using rememberNavBackStack
@Composable
fun MyApp() {
// Automatically handles configuration changes and process death
val backStack = rememberNavBackStack<NavKey>(
initialKey = HomeScreen
)
NavDisplay(
backStack = backStack,
// ... rest of setup
)
}
Custom Persistence with ViewModel
class NavigationViewModel : ViewModel() {
private val _backStack = MutableStateFlow<List<NavKey>>(listOf(HomeScreen))
val backStack: StateFlow<List<NavKey>> = _backStack.asStateFlow()
fun navigate(key: NavKey) {
_backStack.value = _backStack.value + key
}
fun popBack() {
if (_backStack.value.size > 1) {
_backStack.value = _backStack.value.dropLast(1)
}
}
}
@Composable
fun MyApp(viewModel: NavigationViewModel = viewModel()) {
val backStackState by viewModel.backStack.collectAsState()
val backStack = remember { mutableStateListOf<NavKey>() }
LaunchedEffect(backStackState) {
backStack.clear()
backStack.addAll(backStackState)
}
NavDisplay(backStack = backStack, ...)
}
Scene Strategies
What is a Scene?
A Scene defines how NavEntry objects should be displayed and composed together. SceneStrategy determines which Scene to display based on the back stack entries and device characteristics.
1. SinglePaneSceneStrategy (Default)
NavDisplay(
backStack = backStack,
sceneStrategy = SinglePaneSceneStrategy(),
// Shows only the top entry in back stack
)
2. Two-Pane Layout
class TwoPaneSceneStrategy<T : Any> : SceneStrategy<T> {
@Composable
override fun calculateScene(
entries: List<NavEntry<T>>,
onBack: (Int) -> Unit
): Scene<T>? {
val windowClass = currentWindowAdaptiveInfo().windowSizeClass
// Show two panes in landscape/tablet
if (windowClass.windowWidthSizeClass != WindowWidthSizeClass.COMPACT
&& entries.size >= 2
&& entries.takeLast(2).all {
it.metadata[TwoPaneSceneStrategy.twoPane()] != null
}
) {
val listEntry = entries[entries.size - 2]
val detailEntry = entries.last()
return TwoPaneScene(
key = detailEntry.contentKey,
listEntry = listEntry,
detailEntry = detailEntry,
previousEntries = entries.dropLast(2)
) {
Row(Modifier.fillMaxSize()) {
Box(Modifier.weight(1f)) {
listEntry.Content()
}
Box(Modifier.weight(1f)) {
detailEntry.Content()
}
}
}
}
return null // Fall back to default
}
companion object {
fun twoPane() = “two_pane” to Unit
}
}
data class TwoPaneScene<T : Any>(
override val key: Any,
val listEntry: NavEntry<T>,
val detailEntry: NavEntry<T>,
override val previousEntries: List<NavEntry<T>>,
override val content: @Composable () -> Unit
) : Scene<T> {
override val entries = listOf(listEntry, detailEntry)
}
3. List-Detail Adaptive Layout
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun AdaptiveNavigation() {
val backStack = rememberNavBackStack<NavKey>(ConversationList)
val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()
NavDisplay(
backStack = backStack,
sceneStrategy = listDetailStrategy,
entryDecorators = listOf(
rememberSceneSetupNavEntryDecorator(),
rememberSavedStateNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<ConversationList>(
metadata = ListDetailSceneStrategy.listPane(
detailPlaceholder = {
Text(”Select a conversation”)
}
)
) {
ConversationListScreen(
onSelectConversation = { id ->
backStack.add(ConversationDetail(id))
}
)
}
entry<ConversationDetail>(
metadata = ListDetailSceneStrategy.detailPane()
) { key ->
ConversationDetailScreen(conversationId = key.id)
}
}
)
}
Bottom Navigation with Multiple Back Stacks
Approach 1: Nested Back Stacks
@Serializable data object HomeTab : NavKey
@Serializable data object SearchTab : NavKey
@Serializable data object ProfileTab : NavKey
@Composable
fun BottomNavWithNestedStacks() {
var currentTab by remember { mutableStateOf<NavKey>(HomeTab) }
// Each tab has its own back stack
val homeStack = rememberNavBackStack<NavKey>(HomeScreen)
val searchStack = rememberNavBackStack<NavKey>(SearchScreen)
val profileStack = rememberNavBackStack<NavKey>(ProfileScreen)
Scaffold(
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = currentTab == HomeTab,
onClick = { currentTab = HomeTab },
icon = { Icon(Icons.Default.Home, “Home”) },
label = { Text(”Home”) }
)
NavigationBarItem(
selected = currentTab == SearchTab,
onClick = { currentTab = SearchTab },
icon = { Icon(Icons.Default.Search, “Search”) },
label = { Text(”Search”) }
)
NavigationBarItem(
selected = currentTab == ProfileTab,
onClick = { currentTab = ProfileTab },
icon = { Icon(Icons.Default.Person, “Profile”) },
label = { Text(”Profile”) }
)
}
}
) { padding ->
Box(Modifier.padding(padding)) {
when (currentTab) {
HomeTab -> NavDisplay(
backStack = homeStack,
onBack = { homeStack.removeLastOrNull() },
entryProvider = homeEntryProvider()
)
SearchTab -> NavDisplay(
backStack = searchStack,
onBack = { searchStack.removeLastOrNull() },
entryProvider = searchEntryProvider()
)
ProfileTab -> NavDisplay(
backStack = profileStack,
onBack = { profileStack.removeLastOrNull() },
entryProvider = profileEntryProvider()
)
}
}
}
}
Approach 2: Single Back Stack with Tab State
@Serializable
sealed interface NavKey {
@Serializable data object Tabs : NavKey
@Serializable data class Detail(val id: String) : NavKey
}
@Composable
fun BottomNavSingleStack() {
val backStack = rememberNavBackStack<NavKey>(NavKey.Tabs)
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider {
entry<NavKey.Tabs> {
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
Scaffold(
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = selectedTab == 0,
onClick = { selectedTab = 0 },
icon = { Icon(Icons.Default.Home, “Home”) }
)
NavigationBarItem(
selected = selectedTab == 1,
onClick = { selectedTab = 1 },
icon = { Icon(Icons.Default.Search, “Search”) }
)
}
}
) { padding ->
Box(Modifier.padding(padding)) {
when (selectedTab) {
0 -> HomeTabContent(
onNavigateToDetail = { id ->
backStack.add(NavKey.Detail(id))
}
)
1 -> SearchTabContent()
}
}
}
}
entry<NavKey.Detail> { key ->
DetailScreen(itemId = key.id)
}
}
)
}
Multiple Back Stacks Flow
ViewModel Integration
Scoping ViewModel to NavEntry
@Serializable
data class ProductDetail(val productId: String) : NavKey
class ProductDetailViewModel(
private val productId: String
) : ViewModel() {
private val _product = MutableStateFlow<Product?>(null)
val product = _product.asStateFlow()
init {
loadProduct(productId)
}
private fun loadProduct(id: String) {
viewModelScope.launch {
_product.value = repository.getProduct(id)
}
}
}
@Composable
fun ProductNavigation() {
val backStack = rememberNavBackStack<NavKey>(ProductList)
NavDisplay(
backStack = backStack,
// IMPORTANT: Add ViewModel decorator
entryDecorators = listOf(
rememberSceneSetupNavEntryDecorator(),
rememberSavedStateNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator() // Enables ViewModel scoping
),
entryProvider = entryProvider {
entry<ProductDetail> { key ->
// ViewModel is automatically scoped to this NavEntry
val viewModel: ProductDetailViewModel = viewModel(
factory = viewModelFactory {
addInitializer(ProductDetailViewModel::class) {
ProductDetailViewModel(key.productId)
}
}
)
val product by viewModel.product.collectAsState()
ProductDetailScreen(product = product)
}
}
)
}
Hilt Integration
@HiltViewModel
class ProductDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val repository: ProductRepository
) : ViewModel() {
private val productId: String =
savedStateHandle.get<ProductDetail>(”key”)?.productId ?: “”
val product = repository.getProduct(productId)
.stateIn(viewModelScope, SharingStarted.Lazily, null)
}
@Composable
fun ProductDetailScreen(key: ProductDetail) {
val viewModel: ProductDetailViewModel = hiltViewModel()
val product by viewModel.product.collectAsState()
// UI implementation
}
Custom Animations
NavDisplay(
backStack = backStack,
// Custom transition animations
transitionSpec = {
val isForward = targetState.key != initialState.key
if (isForward) {
slideInHorizontally(
initialOffsetX = { it },
animationSpec = tween(300)
) + fadeIn() togetherWith
slideOutHorizontally(
targetOffsetX = { -it / 2 },
animationSpec = tween(300)
) + fadeOut()
} else {
slideInHorizontally(
initialOffsetX = { -it / 2 },
animationSpec = tween(300)
) + fadeIn() togetherWith
slideOutHorizontally(
targetOffsetX = { it },
animationSpec = tween(300)
) + fadeOut()
}
},
// Size change animation
sizeTransform = SizeTransform { initialSize, targetSize ->
if (targetSize.height > initialSize.height) {
spring(stiffness = Spring.StiffnessMediumLow)
} else {
tween(300)
}
}
)
Dialog Destinations
@Serializable
data class ConfirmDialog(val message: String) : NavKey
NavDisplay(
backStack = backStack,
entryProvider = entryProvider {
entry<ConfirmDialog> { key ->
AlertDialog(
onDismissRequest = { backStack.removeLastOrNull() },
title = { Text(”Confirm”) },
text = { Text(key.message) },
confirmButton = {
Button(onClick = {
// Handle confirmation
backStack.removeLastOrNull()
}) {
Text(”Confirm”)
}
},
dismissButton = {
TextButton(onClick = { backStack.removeLastOrNull() }) {
Text(”Cancel”)
}
}
)
}
}
)
// Show dialog
backStack.add(ConfirmDialog(”Are you sure?”))
Bottom Sheet Destinations
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BottomSheetNavigation() {
val backStack = rememberNavBackStack<NavKey>(HomeScreen)
val sheetState = rememberModalBottomSheetState()
NavDisplay(
backStack = backStack,
entryProvider = entryProvider {
entry<HomeScreen> {
HomeContent(
onShowSheet = {
backStack.add(SettingsSheet)
}
)
}
entry<SettingsSheet> {
ModalBottomSheet(
onDismissRequest = { backStack.removeLastOrNull() },
sheetState = sheetState
) {
SettingsContent()
}
}
}
)
}
Conditional Navigation (Authentication)
@Serializable
sealed interface AppDestination : NavKey {
@Serializable data object Login : AppDestination
@Serializable data object Home : AppDestination
@Serializable data class Profile(val userId: String) : AppDestination
}
@Composable
fun AppNavigation(authViewModel: AuthViewModel = viewModel()) {
val isAuthenticated by authViewModel.isAuthenticated.collectAsState()
val backStack = rememberNavBackStack<AppDestination>(AppDestination.Login)
LaunchedEffect(isAuthenticated) {
if (isAuthenticated && backStack.last() == AppDestination.Login) {
backStack.clear()
backStack.add(AppDestination.Home)
} else if (!isAuthenticated && backStack.last() != AppDestination.Login) {
backStack.clear()
backStack.add(AppDestination.Login)
}
}
NavDisplay(
backStack = backStack,
entryProvider = entryProvider {
entry<AppDestination.Login> {
LoginScreen(
onLoginSuccess = {
authViewModel.login()
}
)
}
entry<AppDestination.Home> {
HomeScreen()
}
entry<AppDestination.Profile> { key ->
ProfileScreen(userId = key.userId)
}
}
)
}
Testing Navigation
@Test
fun testNavigationFlow() {
// Create test back stack
val backStack = mutableStateListOf<NavKey>(HomeScreen)
// Navigate forward
backStack.add(ProductDetail(”123”))
assertEquals(2, backStack.size)
assertEquals(ProductDetail(”123”), backStack.last())
// Navigate back
backStack.removeLastOrNull()
assertEquals(1, backStack.size)
assertEquals(HomeScreen, backStack.last())
}
@Test
fun testConditionalNavigation() {
val backStack = mutableStateListOf<NavKey>(LoginScreen)
val isAuthenticated = mutableStateOf(false)
// Simulate authentication
isAuthenticated.value = true
backStack.clear()
backStack.add(HomeScreen)
assertEquals(HomeScreen, backStack.last())
}
class NavigationViewModelTest {
@Test
fun `navigation state persists in ViewModel`() = runTest {
val viewModel = NavigationViewModel()
viewModel.navigate(ProductDetail(”123”))
viewModel.navigate(UserProfile(”456”))
val backStack = viewModel.backStack.first()
assertEquals(3, backStack.size)
assertEquals(UserProfile(”456”), backStack.last())
viewModel.popBack()
val updatedStack = viewModel.backStack.first()
assertEquals(2, updatedStack.size)
}
}
Best Practices
1. Organize Keys by Feature
object HomeFeature {
@Serializable data object Home : NavKey
@Serializable data class ArticleList(val categoryId: String?) : NavKey
@Serializable data class ArticleDetail(val articleId: String) : NavKey
}
object ProfileFeature {
@Serializable data class Profile(val userId: String) : NavKey
@Serializable data object EditProfile : NavKey
@Serializable data object Settings : NavKey
}
2. Create Navigation Helper
class AppNavigator(private val backStack: SnapshotStateList<NavKey>) {
fun navigateToProduct(productId: String) {
backStack.add(ProductDetail(productId))
}
fun navigateToProfile(userId: String) {
backStack.add(UserProfile(userId))
}
fun navigateBack() {
if (backStack.size > 1) {
backStack.removeLastOrNull()
}
}
fun navigateToHomeAndClearStack() {
backStack.clear()
backStack.add(HomeScreen)
}
fun replaceCurrentWith(key: NavKey) {
if (backStack.isNotEmpty()) {
backStack[backStack.lastIndex] = key
}
}
}
3. Use Entry Provider DSL
fun homeEntryProvider() = entryProvider<NavKey> {
entry<HomeScreen> {
HomeContent()
}
entry<ProductList> { key ->
ProductListContent(categoryId = key.categoryId)
}
entry<ProductDetail> { key ->
ProductDetailContent(productId = key.productId)
}
}
4. Handle System Back Properly
@Composable
fun NavigationWithBackHandler() {
val backStack = rememberNavBackStack<NavKey>(HomeScreen)
val context = LocalContext.current
BackHandler(enabled = backStack.size > 1) {
backStack.removeLastOrNull()
}
// If back stack is empty, finish activity
LaunchedEffect(backStack.size) {
if (backStack.isEmpty()) {
(context as? Activity)?.finish()
}
}
NavDisplay(
backStack = backStack,
onBack = {
if (backStack.size > 1) {
backStack.removeLastOrNull()
} else {
(context as? Activity)?.finish()
}
}
)
}
5. Always Use Entry Decorators
// Standard decorator setup for most apps
val standardDecorators = listOf(
rememberSceneSetupNavEntryDecorator(),
rememberSavedStateNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
)
NavDisplay(
entryDecorators = standardDecorators,
// ... other parameters
)
Migration from Navigation Component
Before (Navigation Component)
@Composable
fun OldNavigation() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = “home”
) {
composable(”home”) {
HomeScreen(
onNavigateToProduct = { id ->
navController.navigate(”product/$id”)
}
)
}
composable(
route = “product/{id}”,
arguments = listOf(navArgument(”id”) { type = NavType.StringType })
) { backStackEntry ->
val id = backStackEntry.arguments?.getString(”id”)
ProductDetailScreen(productId = id)
}
}
}After (Navigation 3)
@Composable
fun NewNavigation() {
val backStack = rememberNavBackStack<NavKey>(HomeScreen)
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider {
entry<HomeScreen> {
HomeScreen(
onNavigateToProduct = { id ->
backStack.add(ProductDetail(id))
}
)
}
entry<ProductDetail> { key ->
ProductDetailScreen(productId = key.productId)
}
}
)
}Migration Comparison
Advanced Patterns
1. Nested Navigation Graphs
sealed interface RootDestination : NavKey {
@Serializable data object Auth : RootDestination
@Serializable data object Main : RootDestination
}
sealed interface AuthDestination : NavKey {
@Serializable data object Login : AuthDestination
@Serializable data object Register : AuthDestination
@Serializable data object ForgotPassword : AuthDestination
}
sealed interface MainDestination : NavKey {
@Serializable data object Home : MainDestination
@Serializable data object Profile : MainDestination
@Serializable data class ProductDetail(val id: String) : MainDestination
}
@Composable
fun NestedNavigation() {
val rootBackStack = rememberNavBackStack<RootDestination>(RootDestination.Auth)
NavDisplay(
backStack = rootBackStack,
entryProvider = entryProvider {
entry<RootDestination.Auth> {
AuthNavigation()
}
entry<RootDestination.Main> {
MainNavigation()
}
}
)
}
@Composable
fun AuthNavigation() {
val authBackStack = rememberNavBackStack<AuthDestination>(AuthDestination.Login)
val rootNavigator = LocalNavigator.current
NavDisplay(
backStack = authBackStack,
entryProvider = entryProvider {
entry<AuthDestination.Login> {
LoginScreen(
onLoginSuccess = {
rootNavigator.navigateTo(RootDestination.Main)
},
onRegisterClick = {
authBackStack.add(AuthDestination.Register)
}
)
}
entry<AuthDestination.Register> {
RegisterScreen()
}
entry<AuthDestination.ForgotPassword> {
ForgotPasswordScreen()
}
}
)
}2. Shared Element Transitions (Experimental)
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun SharedElementNavigation() {
val backStack = rememberNavBackStack<NavKey>(ProductList)
SharedTransitionLayout {
NavDisplay(
backStack = backStack,
entryProvider = entryProvider {
entry<ProductList> {
AnimatedVisibility(visible = true) {
ProductListScreen(
onProductClick = { product ->
backStack.add(ProductDetail(product.id))
},
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this
)
}
}
entry<ProductDetail> { key ->
AnimatedVisibility(visible = true) {
ProductDetailScreen(
productId = key.productId,
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this
)
}
}
}
)
}
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun ProductListItem(
product: Product,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope
) {
with(sharedTransitionScope) {
Card(
onClick = { /* navigate */ }
) {
AsyncImage(
model = product.imageUrl,
contentDescription = null,
modifier = Modifier
.sharedElement(
rememberSharedContentState(key = “product-${product.id}”),
animatedVisibilityScope = animatedVisibilityScope
)
)
}
}
}3. Result Passing Between Destinations
@Serializable
data class EditProfile(val userId: String) : NavKey
@Composable
fun ResultPassingExample() {
val backStack = rememberNavBackStack<NavKey>(ProfileScreen)
var profileUpdateResult by remember { mutableStateOf<String?>(null) }
NavDisplay(
backStack = backStack,
entryProvider = entryProvider {
entry<ProfileScreen> {
ProfileContent(
updateMessage = profileUpdateResult,
onEditProfile = {
profileUpdateResult = null
backStack.add(EditProfile(”user123”))
}
)
}
entry<EditProfile> { key ->
EditProfileScreen(
userId = key.userId,
onSaveSuccess = { message ->
profileUpdateResult = message
backStack.removeLastOrNull()
}
)
}
}
)
}4. Multi-Module Navigation
// :feature:home module
object HomeNavigation {
@Serializable data object Home : NavKey
fun entryProvider() = entryProvider<NavKey> {
entry<Home> {
HomeScreen()
}
}
}
// :feature:profile module
object ProfileNavigation {
@Serializable data class Profile(val userId: String) : NavKey
fun entryProvider() = entryProvider<NavKey> {
entry<Profile> { key ->
ProfileScreen(userId = key.userId)
}
}
}
// :app module
@Composable
fun App() {
val backStack = rememberNavBackStack<NavKey>(HomeNavigation.Home)
NavDisplay(
backStack = backStack,
entryProvider = entryProvider {
include(HomeNavigation.entryProvider())
include(ProfileNavigation.entryProvider())
include(ShoppingNavigation.entryProvider())
}
)
}Performance Optimization
1. Lazy Loading Destinations
@Composable
fun LazyNavigationSetup() {
val backStack = rememberNavBackStack<NavKey>(HomeScreen)
NavDisplay(
backStack = backStack,
entryProvider = entryProvider {
entry<HomeScreen> {
HomeScreen()
}
// Heavy screen - only created when needed
entry<HeavyScreen> {
remember {
HeavyScreenContent()
}
}
}
)
}2. Reusing ViewModels
@Composable
fun SharedViewModelPattern() {
val backStack = rememberNavBackStack<NavKey>(HomeScreen)
// Shared ViewModel across multiple destinations
val sharedViewModel: SharedDataViewModel = viewModel()
NavDisplay(
backStack = backStack,
entryProvider = entryProvider {
entry<Screen1> {
Screen1Content(viewModel = sharedViewModel)
}
entry<Screen2> {
Screen2Content(viewModel = sharedViewModel)
}
}
)
}3. Optimizing Back Stack Size
class NavigationManager(private val backStack: SnapshotStateList<NavKey>) {
private val maxBackStackSize = 20
fun navigateWithLimit(key: NavKey) {
backStack.add(key)
// Keep only recent entries
if (backStack.size > maxBackStackSize) {
backStack.removeAt(1) // Keep first (home) screen
}
}
fun navigateAndClearUpTo(key: NavKey, upTo: NavKey) {
val index = backStack.indexOfLast { it == upTo }
if (index != -1) {
backStack.removeRange(index + 1, backStack.size)
}
backStack.add(key)
}
}Common Pitfalls and Solutions
1. ❌ Not Using Entry Decorators
// Wrong - ViewModels won’t survive configuration changes
NavDisplay(
backStack = backStack,
entryProvider = { /* ... */ }
)
// Correct - Always include decorators
NavDisplay(
backStack = backStack,
entryDecorators = listOf(
rememberSceneSetupNavEntryDecorator(),
rememberSavedStateNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = { /* ... */ }
)2. ❌ Modifying Back Stack from Wrong Thread
// Wrong - modifying from coroutine without proper dispatcher
viewModelScope.launch {
delay(1000)
backStack.add(NextScreen) // May crash
}
// Correct - use Main dispatcher
viewModelScope.launch(Dispatchers.Main) {
delay(1000)
backStack.add(NextScreen)
}3. ❌ Not Handling Empty Back Stack
// Wrong - may crash if back stack is empty
fun navigateBack() {
backStack.removeLastOrNull() // What if size is 1?
}
// Correct - check size first
fun navigateBack(): Boolean {
return if (backStack.size > 1) {
backStack.removeLastOrNull()
true
} else {
false // Signal to finish activity
}
}4. ❌ Forgetting to Mark Classes as @Serializable
// Wrong - will crash at runtime
data class ProductDetail(val id: String) : NavKey
// Correct - always use @Serializable
@Serializable
data class ProductDetail(val id: String) : NavKeyDebugging Tips
1. Log Back Stack Changes
@Composable
fun DebugNavigation() {
val backStack = rememberNavBackStack<NavKey>(HomeScreen)
LaunchedEffect(backStack.toList()) {
Log.d(”Navigation”, “Back stack: ${backStack.joinToString(” -> “)}”)
}
NavDisplay(backStack = backStack, /* ... */)
}2. Visualize Current Route
@Composable
fun NavigationDebugOverlay() {
val backStack = LocalBackStack.current
if (BuildConfig.DEBUG) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.BottomStart
) {
Text(
text = “Current: ${backStack.lastOrNull()?.let { it::class.simpleName }}”,
color = Color.White,
backgroundColor = Color.Black.copy(alpha = 0.7f),
modifier = Modifier.padding(8.dp)
)
}
}
}Real-World Example: E-Commerce App
// Navigation keys
sealed interface NavDestination : NavKey {
@Serializable data object Splash : NavDestination
@Serializable data object Home : NavDestination
@Serializable data object Cart : NavDestination
@Serializable data class Category(val id: String) : NavDestination
@Serializable data class ProductDetail(val id: String) : NavDestination
@Serializable data class Checkout(val cartId: String) : NavDestination
@Serializable data object OrderSuccess : NavDestination
}
@Composable
fun ECommerceApp() {
val backStack = rememberNavBackStack<NavDestination>(NavDestination.Splash)
val context = LocalContext.current
// Handle splash screen
LaunchedEffect(Unit) {
delay(2000)
backStack.clear()
backStack.add(NavDestination.Home)
}
// Handle system back
BackHandler(enabled = backStack.size > 1) {
when (backStack.last()) {
is NavDestination.OrderSuccess -> {
// After order, go to home
backStack.clear()
backStack.add(NavDestination.Home)
}
else -> backStack.removeLastOrNull()
}
}
NavDisplay(
backStack = backStack,
entryDecorators = listOf(
rememberSceneSetupNavEntryDecorator(),
rememberSavedStateNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
transitionSpec = {
slideInHorizontally { it } + fadeIn() togetherWith
slideOutHorizontally { -it / 2 } + fadeOut()
},
entryProvider = entryProvider {
entry<NavDestination.Splash> {
SplashScreen()
}
entry<NavDestination.Home> {
HomeScreen(
onCategoryClick = { id ->
backStack.add(NavDestination.Category(id))
},
onCartClick = {
backStack.add(NavDestination.Cart)
}
)
}
entry<NavDestination.Category> { key ->
CategoryScreen(
categoryId = key.id,
onProductClick = { id ->
backStack.add(NavDestination.ProductDetail(id))
}
)
}
entry<NavDestination.ProductDetail> { key ->
ProductDetailScreen(
productId = key.id,
onAddToCart = {
// Show snackbar and stay on screen
},
onBuyNow = { cartId ->
backStack.add(NavDestination.Checkout(cartId))
}
)
}
entry<NavDestination.Cart> {
CartScreen(
onCheckout = { cartId ->
backStack.add(NavDestination.Checkout(cartId))
}
)
}
entry<NavDestination.Checkout> { key ->
CheckoutScreen(
cartId = key.cartId,
onOrderComplete = {
backStack.add(NavDestination.OrderSuccess)
}
)
}
entry<NavDestination.OrderSuccess> {
OrderSuccessScreen(
onContinueShopping = {
backStack.clear()
backStack.add(NavDestination.Home)
}
)
}
}
)
}E-Commerce Navigation Flow
Conclusion
Navigation 3 represents a significant evolution in Android navigation, offering:
✅ Type-safe navigation with serializable keys
✅ Full back stack control as a simple list
✅ Seamless Compose integration with declarative state
✅ Adaptive layouts for tablets and foldables
✅ Powerful SceneStrategy system for complex UIs
✅ ViewModel scoping to destinations
✅ Easy testing with direct back stack manipulation
✅ Production-ready with stable 1.0 release
When to Use Navigation 3
Use Navigation 3 if:
Building a new Compose-only app
Need adaptive layouts for tablets/foldables
Want full control over navigation state
Prefer declarative, state-based navigation
Need to scope ViewModels to specific screens
Stick with Nav Component if:
Have existing View-based screens
Need fragment-based navigation
App is mostly stable and working well
Navigation 3 is the future of Android navigation, and it’s finally stable and ready for your production apps! 🚀










