Added a comprehensive collection of Compose development guidelines, best practices, and skill definitions in the .codex directory. Co-authored-by: Junie <junie@jetbrains.com>
8.3 KiB
Navigation 3
Navigation 3 for Compose and CMP: you own the back stack as state, the library renders it. Verify artifact maturity before production use.
For shared navigation concepts (MVI rules, anti-patterns, version decision guide), see navigation.md. For DI wiring (Hilt/Koin + Nav 3), see navigation-3-di.md. For migrating from Nav 2, see navigation-migration.md.
References:
Core Architecture
Nav 3 has four building blocks:
- Keys —
@Serializabletypes identifying destinations - Back stack — a
SnapshotStateListyou own and mutate directly - NavEntry — wraps a key with composable content and optional metadata
- NavDisplay — observes back stack, resolves keys via entry provider, picks a Scene, renders
User interaction
-> backStack.add(key) / backStack.removeLastOrNull()
-> NavDisplay observes change
-> entryProvider resolves key -> NavEntry
-> SceneStrategy picks layout
-> Scene renders content
| Type | Role |
|---|---|
NavKey |
Marker interface for serializable destination keys |
NavEntry |
Key + composable content + metadata map |
NavDisplay |
Observes back stack, manages scenes and animations |
Scene / SceneStrategy |
Decides layout (single pane, list-detail, dialog) |
NavEntryDecorator |
Cross-cutting concern (ViewModel scoping, saveable state) |
Route Definition
Define routes as @Serializable data classes/objects. Group with sealed interfaces for type safety:
@Serializable sealed interface AppRoute : NavKey
@Serializable data object Home : AppRoute
@Serializable data class Details(val id: String) : AppRoute
@Serializable data object Settings : AppRoute
For platform-specific types in route arguments, provide a custom KSerializer. In CMP, prefer String paths or expect/actual wrappers.
Back Stack Creation and Persistence
// Recommended — persists across config changes and process death (keys must be @Serializable + NavKey)
val backStack = rememberNavBackStack(Home)
// Simple — no persistence, prototyping only
val backStack = remember { mutableStateListOf<Any>(Home) }
CMP: Polymorphic serialization for non-JVM
Non-JVM CMP targets need SavedStateConfiguration plus a SerializersModule with polymorphic NavKey subclasses (e.g. subclassesOfSealed<AppRoute>()).
Details: Nav 3 state management.
NavDisplay Configuration
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
sceneStrategy = listDetailStrategy,
transitionSpec = { slideInHorizontally(initialOffsetX = { it }) togetherWith slideOutHorizontally(targetOffsetX = { -it }) },
popTransitionSpec = { slideInHorizontally(initialOffsetX = { -it }) togetherWith slideOutHorizontally(targetOffsetX = { it }) },
entryProvider = entryProvider {
entry<Home> {
HomeScreen(onNavigateToDetails = { id -> backStack.add(Details(id)) })
}
entry<Details>(metadata = mapOf("pane" to "detail")) { key ->
DetailScreen(id = key.id, onNavigateBack = { backStack.removeLastOrNull() })
}
},
)
Each entry<Key> receives the typed key. Pass metadata to control scene placement and per-entry animations. For ViewModel/state wiring inside entries, see navigation.md and navigation-3-di.md.
Top-Level Tabs and Dashboard Navigation
data class TopLevelNavItem(val selectedIcon: ImageVector, val unselectedIcon: ImageVector, val label: String)
val TOP_LEVEL_ITEMS = mapOf(
Home to TopLevelNavItem(Icons.Filled.Home, Icons.Outlined.Home, "Home"),
Search to TopLevelNavItem(Icons.Filled.Search, Icons.Outlined.Search, "Search"),
Profile to TopLevelNavItem(Icons.Filled.Person, Icons.Outlined.Person, "Profile"),
)
@Stable
class NavigationState(val backStack: SnapshotStateList<NavKey>, val topLevelKeys: Set<NavKey>) {
val currentKey: NavKey get() = backStack.last()
val currentTopLevelKey: NavKey? get() = backStack.lastOrNull { it in topLevelKeys }
}
class Navigator(private val state: NavigationState) {
fun navigate(key: NavKey) {
if (key in state.topLevelKeys) {
while (state.backStack.size > 1) state.backStack.removeLast()
if (state.backStack.lastOrNull() != key) state.backStack[0] = key
} else { state.backStack.add(key) }
}
fun goBack() { state.backStack.removeLastOrNull() }
}
Use NavigationSuiteScaffold (or custom scaffold) with NavDisplay inside.
ViewModel Scoping
Always include both entry decorators:
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(), // preserves rememberSaveable while on stack
rememberViewModelStoreNavEntryDecorator(), // per-entry ViewModelStoreOwner
)
VMs created when entry added, cleared when popped. For DI-specific injection patterns, see navigation-3-di.md.
Scenes and Adaptive Layouts
DialogSceneStrategy
entry<ConfirmDialog>(metadata = DialogSceneStrategy.dialog()) { key ->
AlertDialog(onDismissRequest = { backStack.removeLastOrNull() }, /* ... */)
}
BottomSheetSceneStrategy
entry<FilterSheet>(metadata = BottomSheetSceneStrategy.bottomSheet()) { key ->
FilterContent(onApply = { backStack.removeLastOrNull() })
}
Material 3 Adaptive list-detail
val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()
NavDisplay(
sceneStrategy = listDetailStrategy,
entryProvider = entryProvider {
entry<ConversationList>(metadata = ListDetailSceneStrategy.listPane(
detailPlaceholder = { Text("Select a conversation") }
)) { ConversationListScreen(onSelect = { backStack.add(ConversationDetail(it)) }) }
entry<ConversationDetail>(metadata = ListDetailSceneStrategy.detailPane()) { key ->
ConversationDetailScreen(key.id)
}
},
)
Automatically adapts: side-by-side on wide screens, single pane on narrow.
Chaining strategies
val strategy = dialogStrategy then bottomSheetStrategy then listDetailStrategy
// First match wins. SinglePaneSceneStrategy is always implicit fallback.
Animations
Global transitions on NavDisplay
Set transitionSpec, popTransitionSpec, and predictivePopTransitionSpec on NavDisplay (see configuration example above).
Per-entry overrides via metadata
entry<ModalRoute>(
metadata = NavDisplay.transitionSpec {
slideInVertically(initialOffsetY = { it }) togetherWith ExitTransition.KeepUntilTransitionsFinished
} + NavDisplay.popTransitionSpec {
EnterTransition.None togetherWith slideOutVertically(targetOffsetY = { it })
}
) { ModalScreen() }
Back Stack Manipulation Patterns
backStack.add(Details("123")) // forward
backStack.removeLastOrNull() // back
backStack.removeAll { it is Details }; backStack.add(Details(newId)) // replace duplicate Details
backStack.clear(); backStack.addAll(listOf(Home, Details(deepLinkId))) // synthetic stack (e.g. deep link)
while (backStack.size > 1) backStack.removeLast(); backStack[0] = targetKey // tabs: pop to root, swap root key
Deep Links
Nav 3 does not parse deep links — you own this. Pattern: parse URI → extract args into NavKey → build synthetic back stack → set before first composition.
// Android Activity or CMP entry point
val backStack = rememberNavBackStack(Home)
LaunchedEffect(deepLinkId) {
if (deepLinkId != null) {
backStack.clear()
backStack.addAll(listOf(Home, Details(deepLinkId)))
}
}
Registration lives in platform entry points: AndroidManifest.xml intent filters, App Delegate/SceneDelegate on iOS, URL handlers on Desktop. Back stack construction logic can live in shared commonMain.