--- name: kmp-starter-navigation description: Navigation on the KMP Starter Template using navigation3 — AppScreens, polymorphic registration, AppNavigationModule routes, StarterNavigator, nested navigation (scoped navigators, Nav3Transitions, entry decorators), and ResultStore. author: DevAtrii license: MIT --- # Navigation Uses **navigation3** + Koin. Global navigation lives in `composeApp/core/navigation`. ## 1. Define screens `composeApp/src/commonMain/kotlin//core/navigation/AppScreens.kt`: ```kotlin @Serializable @Stable sealed class AppScreens : NavKey { @Serializable data object Home : AppScreens() } ``` Rules: must be `@Serializable`; must extend `NavKey`; one `sealed class` per feature. Mark `@Stable` too. Group many screens into nested `sealed class`es (e.g. `App`, `Web`, `Plugins`) to keep one hierarchy. ## 2. Register for serialization In `composeApp/.../App.kt`, `AppConfig.navigationPolymorphicBuilder`: ```kotlin val navigationPolymorphicBuilder: PolymorphicModuleBuilder.() -> Unit = { subclass(AppScreens.Home::class) // ... every screen } ``` Missing registration → state restoration fails. ## 3. Define route (Koin) `composeApp/src/commonMain/kotlin//core/navigation/AppNavigationModule.kt`: ```kotlin val appNavigationModule = module { includes(navigationCoreModule) navigation { route -> val navigator = StarterNavigator.getCurrent() HomeScreen(onTaskComplete = { navigator.navigateUp() }) } } ``` ## 4. Navigate ```kotlin val navigator = StarterNavigator.getCurrent() navigator.navigateTo(route) // push navigator.popAndNavigate(route) // pop current, then push navigator.popAllAndNavigate(route) // clear stack, then push navigator.navigateOrBringToTop(route) // single instance, bring to top navigator.navigateUp() // pop navigator.remove(route) // remove a destination ``` `StarterNavigator` extends `BaseNavigator` (`features/navigation/.../BaseNavigator.kt`) and is provided by Koin via `navigationCoreModule`. ## 5. Change initial screen `composeApp/.../App.kt` → `StarterNavigation(AppScreens.Splash, ...)`. First argument is the start screen. ## Rules - Feature modules **never** contain app navigation logic; they expose composables only. - `composeApp` owns navigation. - ViewModels **never** navigate directly; screens call navigator from callbacks. ## Nested navigation When a feature owns multiple internal screens (e.g. a bottom-nav root with several tabs), it defines its own navigation graph inside the feature. Isolate everything in a `nested_navigation` package. ### 1. Define screens A sealed class in the feature's nested-nav package (`@Serializable`, extend `NavKey`): ```kotlin @Serializable sealed class FeatureScreens : NavKey { @Serializable data object Home : FeatureScreens() @Serializable data object History : FeatureScreens() @Serializable data object Settings : FeatureScreens() } ``` ### 2. Create the Koin scope A scope object + `getOrCreateScope()` so the nested navigator (and any scoped deps) live/die with the feature: ```kotlin internal object FeatureNavScope { private val QUALIFIER = qualifier() private val ID = QUALIFIER.toString() @Composable fun getOrCreateScope() = getKoin().getOrCreateScope(scopeId = ID, qualifier = QUALIFIER) } ``` ### 3. Create the nested navigator Extend `BaseNavigator` and add a `getCurrent()` companion that injects from the scope: ```kotlin internal class FeatureNestedNavigator : BaseNavigator() { companion object { @Composable fun getCurrent(): FeatureNestedNavigator = koinInject(scope = FeatureNavScope.getOrCreateScope()) } } ``` To signal the parent (e.g. "open a screen owned by the app, with args"), expose events from the navigator itself: ```kotlin private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private val _events = MutableSharedFlow(replay = 0) val events: SharedFlow = _events.asSharedFlow() fun emitEvent(event: FeatureNestedNavigationEvents) { scope.launch { _events.emit(event) } } internal sealed class FeatureNestedNavigationEvents { data class OnNavigateToDetail(val id: String) : FeatureNestedNavigationEvents() data object OnNavigateToPurchases : FeatureNestedNavigationEvents() } ``` ### 4. Register routes in a Koin module One module per feature graph, `scope { scoped { Navigator() } + navigation { ... } }`: ```kotlin val featureNestedNavigationModule = module { scope { scoped { FeatureNestedNavigator() } navigation { val navigator = FeatureNestedNavigator.getCurrent() HomeScreen( onNavigateToDetail = { id -> navigator.emitEvent(FeatureNestedNavigationEvents.OnNavigateToDetail(id)) }, onNavigateToPurchases = { navigator.emitEvent(FeatureNestedNavigationEvents.OnNavigateToPurchases) }, ) } navigation { HistoryScreen() } navigation { val navigator = FeatureNestedNavigator.getCurrent() SettingsScreen( onNavigateToPurchases = { navigator.emitEvent(FeatureNestedNavigationEvents.OnNavigateToPurchases) }, ) } } } ``` ### 5. Root screen The root composable holds the back stack, wires the navigator, and observes navigator events to bubble navigation up to the app: ```kotlin @Composable fun FeatureRootScreen( onNavigateToDetail: (id: String) -> Unit, onNavigateToPurchases: () -> Unit, ) { FeatureRootScreenContent() val navigator = FeatureNestedNavigator.getCurrent() ObserveAsEvents(flow = navigator.events) { event -> when (event) { is FeatureNestedNavigationEvents.OnNavigateToDetail -> onNavigateToDetail(event.id) FeatureNestedNavigationEvents.OnNavigateToPurchases -> onNavigateToPurchases() } } } @Composable private fun FeatureRootScreenContent() { val koinScope = rememberKoinScope(scope = FeatureNavScope.getOrCreateScope()) val entryProvider = koinEntryProvider(scope = koinScope) val backStack: NavBackStack = rememberNavBackStack(FeatureScreens.Home) { subclass(FeatureScreens.Home::class) subclass(FeatureScreens.History::class) subclass(FeatureScreens.Settings::class) } val navigator = FeatureNestedNavigator.getCurrent() LaunchedEffect(backStack) { navigator.provideBackStack(backStack) } Scaffold(bottomBar = { // tab bar; each tab -> navigator.navigateOrBringToTop(item.route) }) { innerPaddings -> NavDisplay( modifier = Modifier.padding(bottom = innerPaddings.calculateBottomPadding()), backStack = backStack, entryProvider = entryProvider, transitionSpec = Nav3Transitions.horizontalSlideParallax(), popTransitionSpec = Nav3Transitions.horizontalSlideParallaxPop(), predictivePopTransitionSpec = Nav3Transitions.predictiveHorizontalSlideParallax(), ) } } ``` Key points: - Use `rememberKoinScope(scope = FeatureNavScope.getOrCreateScope())` + `koinEntryProvider(scope = koinScope)` — the entry provider is scoped, so screen-level ViewModels resolve inside the feature scope. - Bind navigator with `scoped { FeatureNestedNavigator() }` in the scope; retrieve with `getCurrent()` (which injects from the same scope). - Tab switching uses `navigator.navigateOrBringToTop(route)` (single instance per tab). - Bubble "leave the feature" actions up via navigator `events` + `ObserveAsEvents` — feature modules never touch app navigation directly. - Transitions via `Nav3Transitions` (`horizontalSlideParallax` / `fade` / etc.). Pick per platform if needed (`platform.isAndroid` / `platform.isIos`). Register the feature's `*NestedNavigationModule` in `InitKoin` (see koin skill). ## Flow 1. Create screen (`@Serializable @Stable sealed class : NavKey`) 2. Register in `navigationPolymorphicBuilder` 3. Add route in `AppNavigationModule` (or custom module) 4. Use `StarterNavigator` 5. Change initial screen if needed ## Screen-to-screen results (ResultStore) To send a result back or signal a parent without a direct nav-arg (e.g. "open the drawer" from a child), use `ResultStore` (`features/navigation/.../utils/ResultStore.kt`). ```kotlin val resultStore = rememberResultStore() ProvideResultStore(resultStore) { ... } // in the parent // child: set a result val store = LocalResultStore.current scope.launch { store.setResult("open_drawer", true) } // parent: observe it ObserveAsEvents(store.observeResult("open_drawer")) { open -> if (open) drawerState.open() } ``` Use a constant string key per result. Results are ephemeral (not saved across process death). ## Reference - `features/navigation/.../BaseNavigator.kt`, `StarterNavigator.kt`, `StarterNavigation.kt` - `composeApp/.../core/navigation/AppScreens.kt`, `AppNavigationModule.kt` - Docs: `https://starter.atherio.dev/fundamentals/08-navigation/`