Files
vnidrop/.codex/skills/compose-skill/references/navigation-3.md
Hammed Abass fd4fff2c86 docs: add compose development guidelines and skills
Added a comprehensive collection of Compose development guidelines, best practices, and skill definitions in the .codex directory.

Co-authored-by: Junie <junie@jetbrains.com>
2026-07-07 16:55:43 +02:00

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:

  1. Keys@Serializable types identifying destinations
  2. Back stack — a SnapshotStateList you own and mutate directly
  3. NavEntry — wraps a key with composable content and optional metadata
  4. 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

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.