Navigation

SkillDev tools

Navigation on the KMP Starter Template using navigation3 — AppScreens, polymorphic registration, AppNavigationModule routes, StarterNavigator, nested navigation (scoped navigators, Nav3Transitions, entry decorators), and ResultStore.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Navigation skill

What this skill tells your AI

The instructions your AI receives, as published by devatrii/kmp-starter-template in .agents/skills/kmp-starter/navigation/SKILL.md and read by ahel’s review.

Uses navigation3 + Koin. Global navigation lives in composeApp/core/navigation.

1. Define screens

composeApp/src/commonMain/kotlin/<your-package>/core/navigation/AppScreens.kt:

@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 classes (e.g. App, Web, Plugins) to keep one hierarchy.

2. Register for serialization

In composeApp/.../App.kt, AppConfig.navigationPolymorphicBuilder:

val navigationPolymorphicBuilder: PolymorphicModuleBuilder<NavKey>.() -> Unit = {
    subclass(AppScreens.Home::class)
    // ... every screen
}

Missing registration → state restoration fails.

3. Define route (Koin)

composeApp/src/commonMain/kotlin/<your-package>/core/navigation/AppNavigationModule.kt:

val appNavigationModule = module {
    includes(navigationCoreModule)

    navigation<AppScreens.Home> { route ->
        val navigator = StarterNavigator.getCurrent()
        HomeScreen(onTaskComplete = { navigator.navigateUp() })
    }
}

4. Navigate

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.ktStarterNavigation(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):

@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:

internal object FeatureNavScope {
    private val QUALIFIER = qualifier<FeatureNavScope>()
    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:

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:

    private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
    private val _events = MutableSharedFlow<FeatureNestedNavigationEvents>(replay = 0)
    val events: SharedFlow<FeatureNestedNavigationEvents> = _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<FeatureNavScope> { scoped { Navigator() } + navigation<Screen> { ... } }:

val featureNestedNavigationModule = module {
    scope<FeatureNavScope> {
        scoped { FeatureNestedNavigator() }
        navigation<FeatureScreens.Home> {
            val navigator = FeatureNestedNavigator.getCurrent()
            HomeScreen(
                onNavigateToDetail = { id ->
                    navigator.emitEvent(FeatureNestedNavigationEvents.OnNavigateToDetail(id))
                },
                onNavigateToPurchases = {
                    navigator.emitEvent(FeatureNestedNavigationEvents.OnNavigateToPurchases)
                },
            )
        }
        navigation<FeatureScreens.History> { HistoryScreen() }
        navigation<FeatureScreens.Settings> {
            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:

@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<Any>(scope = koinScope)
    val backStack: NavBackStack<NavKey> = 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<Any>(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).

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<Boolean>("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/

Signals

GitHub stars
162
Forks
28
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
kmp-starter-navigation
Source
github.com/devatrii/kmp-starter-template