agentleFS
KushpawanKushpawan/kmp-mobile-skills/skills/compose-multiplatform-navigation/SKILL.md

compose-multiplatform-navigation

Skill15 starsChanged 22 days agoApache-2.0View on GitHub

Use when building or debugging navigation in Compose Multiplatform commonMain — type-safe @Serializable routes, NavHost, nested graphs, splash/login gate, session expiry, bottom tabs with per-tab back stacks, deep links on Android and iOS, back handling and iOS swipe-back, returning results. Also for back stacks resetting, tabs losing state, double navigation, deep links that crash or skip login.

The file

We split this skill into the rules it gives an agent

5 rules. About 0 are common, 0 are distinctive, and 5 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 5

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Screen ↔ navigation contract: Do screens receive lambdas, a NavController, or a navigator interface?

    line 56
  2. Very specific:

    Read arguments with backStackEntry.toRoute<NoteDetailRoute>().

    line 75
  3. Very specific:

    Keep the OS splash up while the session is unknown (Android installSplashScreen() + setKeepOnScreenCondition) instead of flashing the …

    line 124
  4. Very specific:

    Check navController.graph.hasDeepLink(NavUri(uri)) first: navigate(NavUri) throws IllegalArgumentException for a URI no destination matches.

    line 173
  5. Very specific:

    Prefer, in order: (1) the data layer — the editor saves, the list observes a Flow and updates itself.

    line 199

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Compose Multiplatform navigation

One NavController per host, typed @Serializable routes, each feature registering its own destinations, and every navigation call made from one place per screen (its effect handler) or from the app shell. Session, tabs and deep links are app-shell concerns, not screen concerns.

When to use

  • Adding destinations, routes or a nested graph to a Compose Multiplatform app.
  • Building the app shell: splash → signed-out graph vs signed-in graph, reacting to session expiry.
  • Bottom navigation that keeps each tab's back stack.
  • Deep links (custom scheme, App Links, Universal Links) into shared navigation.
  • Intercepting back (unsaved changes) on Android and the iOS swipe-back gesture.
  • Passing a result back to the previous screen.
  • Symptoms: back stack resets after rotation, tab switch loses scroll/stack, a deep link opens a screen before login or crashes with IllegalArgumentException, a transition fires on the wrong edge, navigation happens twice.

When not to use

  • The screen itself (state, ViewModel, effects, tests) → compose-multiplatform-screen.
  • Android-only app on Jetpack Navigation 3 → Google's navigation-3 skill.
  • Native SwiftUI navigation (NavigationStack) over shared logic → SwiftUI skills in Related.
  • Projects on Voyager, Decompose, Appyx or another navigation library: follow that library; only the session/deep-link principles here transfer.

Step 0 — Read the project first

  1. Library and version. In gradle/libs.versions.toml find org.jetbrains.androidx.navigation:navigation-compose (Navigation 2 API), org.jetbrains.androidx.navigation3:* (Navigation 3), or another library. Typed routes need navigation-compose 2.8 or newer. Note the Compose Multiplatform version: it decides the back-handler API (step 8).
  2. Route style. Typed @Serializable routes or string routes? Do not mix; if the project uses strings, ask before migrating.
  3. Host shape. Find every NavHost(. Is there one root host? A nested host for tabs? Where do features register destinations (fun NavGraphBuilder.xxx(...))?
  4. Session model. How the app knows it is signed in (token store, session repository, a Flow) and how expiry is signalled (401 → refresh failed → event).
  5. Transitions. Are enterTransition/exitTransition set on NavHost, on individual composable<...>() calls, or nowhere?
  6. Platform entry points. Android MainActivity (launchMode, onNewIntent, intent filters); iOS entry (ComposeUIViewController in a SwiftUI App or a UIKit AppDelegate/SceneDelegate), Info.plist URL types, Associated Domains entitlement.
  7. Screen ↔ navigation contract. Do screens receive lambdas, a NavController, or a navigator interface? Keep whatever exists.

Workflow

1. Define routes

@Serializable data object NotesListRoute
@Serializable data class NoteDetailRoute(val noteId: String)
@Serializable data class NoteEditorRoute(val noteId: String? = null)   // optional arg = default value
@Serializable data object NotesGraph                                   // nested graph key
  • Pass ids, not objects. Arguments are serialised into the back stack's saved state; large or sensitive payloads do not belong there.
  • Primitives, String, enums and lists of those work out of the box. Any other type needs a NavType passed through typeMap on composable<T>(typeMap = ...) and on toRoute<T>() callers — prefer an id instead.
  • Read arguments with backStackEntry.toRoute<NoteDetailRoute>(); in a ViewModel with SavedStateHandle.toRoute<T>(), or pass them as DI parameters.

Done when every destination has a route type and no string route literal remains in new code.

2. Let each feature own its graph; keep the root host flat

fun NavGraphBuilder.notesGraph(navController: NavController) {
    navigation<NotesGraph>(startDestination = NotesListRoute) {
        composable<NotesListRoute> { NotesListScreen(onOpenNote = { id -> navController.navigate(NoteDetailRoute(id)) }) }
        composable<NoteDetailRoute> { NoteDetailScreen(onBack = navController::popBackStack) }
    }
}

The root NavHost { } then lists one line per feature. Routes live next to the feature (or in its logic module if the project separates Compose-free code).

3. Configure transitions once, on the NavHost

  • Default: set none. Compose Multiplatform then gives iOS its native-like swipe-back animation; per the JetBrains docs, specifying enterTransition/exitTransition on NavHost turns that default off.
  • If the design needs custom transitions: set all four on NavHost only. Do not set them on individual composable<Route>(...) calls: popEnterTransition/popExitTransition default to the enterTransition/exitTransition you pass there, so a fade added for one edge silently applies to every push and pop touching that destination.
  • Need a special case (splash hand-off)? Branch on initialState.destination / targetState.destination inside the single NavHost-level lambda.

4. Gate the app on the session

Shape: SplashRoute (start) → AuthGraph (signed out) or MainRoute (signed in). An app-shell ViewModel observes the session and emits effects for the transitions; the root composable handles them by replacing the whole back stack:

fun NavController.navigateClearingBackStack(route: Any) = navigate(route) {
    popUpTo(graph.id) { inclusive = true }   // clear everything, including the start destination
    launchSingleTop = true
}
  • Startup, sign-in, sign-out and session expiry are all session changes → one place handles them.
  • Drive transitions from one-shot effects (or guard a state observer by checking the current graph). A bare LaunchedEffect(sessionState) { navigateClearingBackStack(...) } re-runs when the composition is recreated (Android configuration change) and wipes the back stack the NavController just restored.
  • Keep the OS splash up while the session is unknown (Android installSplashScreen() + setKeepOnScreenCondition) instead of flashing the sign-in screen.
  • Session expiry: show the "signed out" message from shell state, then navigate to AuthGraph. Remember a pending deep link and replay it after sign-in.

Full shell code (routes, ViewModel, root host, tests): references/app-shell-session-tabs.md.

5. Bottom tabs with a back stack per tab

  • Host the tabs in a nested NavHost inside composable<MainRoute>, with one nested graph per tab. The auth gate then never interferes with the tab host's start destination.
  • Switch tabs with the multiple-back-stacks recipe:
fun NavController.switchTab(tabGraph: Any) = navigate(tabGraph) {
    popUpTo(graph.findStartDestination().id) { saveState = true }
    launchSingleTop = true
    restoreState = true
}
  • Derive the selected tab from currentBackStackEntryAsState() + destination.hierarchy.any { it.hasRoute(tab.graph::class) } — never a separate var selectedTab that can drift from the back stack.
  • Re-selecting the current tab: pop that tab to its root (popBackStack(tabRootRoute, inclusive = false)).
  • Full-screen flows that should cover the bar (editor, checkout) go on the root controller.

6. Share a ViewModel across a nested graph

composable<CheckoutPaymentRoute> { entry ->
    val graphEntry = remember(entry) { navController.getBackStackEntry<CheckoutGraph>() }
    val checkoutViewModel: CheckoutViewModel = koinViewModel(viewModelStoreOwner = graphEntry)
}

The ViewModel lives as long as the graph is on the back stack. Without Koin use viewModel(viewModelStoreOwner = graphEntry) { ... }.

7. Deep links

Two shapes — pick by whether the target needs a signed-in user:

ShapeHowUse when
Declarativecomposable<NoteDetailRoute>(deepLinks = listOf(navDeepLink<NoteDetailRoute>(basePath = "example://notes/note"))) + navController.navigate(NavUri(uri))Destination is reachable without the auth gate
GatedParse the URI in commonMain into a route, hand it to the shell ViewModel, navigate after the session is knownAnything behind sign-in
  • Check navController.graph.hasDeepLink(NavUri(uri)) first: navigate(NavUri) throws IllegalArgumentException for a URI no destination matches.
  • Android: intent filters in the manifest. iOS: CFBundleURLTypes (custom scheme) or Associated Domains (Universal Links), then forward the URL from Swift into a shared handler.
  • In a SwiftUI App (scene-based lifecycle) use .onOpenURL; application(_:open:options:) in an app delegate is not called when the app uses scenes.

Platform code, the shared URI handler, testing commands: references/deep-links.md.

8. Back handling

  • NavHost already maps Android system back and the iOS swipe-back to popBackStack().
  • To intercept (unsaved changes), on Compose Multiplatform 1.10+ use NavigationBackHandler from org.jetbrains.androidx.navigationevent:navigationevent-compose. On 1.8–1.9, use the multiplatform BackHandler (org.jetbrains.compose.ui:ui-backhandler), which later releases deprecate; follow the IDE's deprecation message when upgrading.
  • Enable interception only while needed (isBackEnabled = state.hasUnsavedChanges) and send an action to the ViewModel; never pop inside the handler and again in an effect.
  • Disable the iOS swipe gesture app-wide only if required: ComposeUIViewController(configure = { enableBackGesture = false }) { App() }.

Code: references/back-and-results.md.

9. Return a result to the previous screen

Prefer, in order: (1) the data layer — the editor saves, the list observes a Flow and updates itself; (2) a graph-scoped shared ViewModel; (3) previousBackStackEntry?.savedStateHandle for a small primitive result, read with getStateFlow and cleared after it is consumed. Code in references/back-and-results.md.

10. Navigation 3

Since Compose Multiplatform 1.10, Navigation 3 is available for Android, iOS, desktop and web (org.jetbrains.androidx.navigation3:navigation3-ui). On non-JVM targets it needs polymorphic serializers registered for every NavKey in a SavedStateConfiguration. JetBrains describes moving from Navigation 2 as "less of a migration and more of a rewrite" — use it for new projects that choose it; do not mix it with navigation-compose in one host.

Quick reference

TaskAPI
Typed destinationcomposable<Route> { entry -> entry.toRoute<Route>() }
Nested graphnavigation<GraphKey>(startDestination = FirstRoute) { ... }
Clear stack and gonavigate(route) { popUpTo(graph.id) { inclusive = true }; launchSingleTop = true }
Pop to a typed routepopBackStack<NotesListRoute>(inclusive = false)
Current destination checkdestination.hierarchy.any { it.hasRoute(NotesGraph::class) }
Observe current entrynavController.currentBackStackEntryAsState()
Graph-scoped ownernavController.getBackStackEntry<GraphKey>()
Deep link patternnavDeepLink<NoteDetailRoute>(basePath = "example://notes/note") matches example://notes/note/42
Navigate by URInavController.navigate(NavUri(uri)) (androidx.navigation.NavUri)
Back interception, CMP 1.10+NavigationBackHandler(state = rememberNavigationEventState(...), isBackEnabled, onBackCompleted)
Disable iOS swipe-backComposeUIViewController(configure = { enableBackGesture = false })

Verify

Report every check as PASS, FAIL or NOT RUN (reason); never claim one you did not run.

./gradlew :composeApp:compileKotlinMetadata                 # routes + graphs compile in common code
./gradlew :composeApp:assembleDebug                         # Android build
./gradlew :shared:testAndroidHostTest                       # shell ViewModel / deep-link parser tests (or testDebugUnitTest)
./gradlew :shared:iosSimulatorArm64Test                     # same tests on iOS
adb shell am start -W -a android.intent.action.VIEW -d "example://notes/note/42" <applicationId>
xcrun simctl openurl booted "example://notes/note/42"

Manual run on both platforms (report NOT RUN if no device/simulator):

  1. Cold start signed out → sign-in; sign in → main tabs; back from the first tab exits the app.
  2. Rotate (Android) on a deep screen → the back stack is unchanged.
  3. Tab A → push detail → tab B → tab A: the detail is still there.
  4. Expire the session → message, then sign-in; after sign-in the pending deep link opens.
  5. Deep link while signed out → sign-in first, then the target. Unknown URI → no crash.
  6. iOS swipe-back pops one screen; unsaved-changes guard intercepts it.

Common mistakes

MistakeWhy it bitesFix
enterTransition/exitTransition on one composable<Route>()Pop transitions default to them: the effect leaks onto unrelated push/pop edgesConfigure transitions only on NavHost
Custom NavHost transitions without checking iOSDisables the default iOS swipe-back animationLeave defaults, or accept and test the custom feel on iOS
LaunchedEffect(session) { navigateClearingBackStack(...) }Re-runs after recreation and wipes the restored stackEffect-driven transitions, or guard on the current graph
Tabs as a var selectedTab + whenLoses each tab's stack and state; drifts from back stackNested tab NavHost + saveState/restoreState; derive selection
popUpTo(graph.findStartDestination().id) on a root whose start is the splashStart destination is no longer on the stack; tab stacks accumulatePut tabs in their own nested host
navigate(NavUri(uri)) without hasDeepLinkIllegalArgumentException on unknown linksCheck first, fall back to home
Declarative deep link to a gated screenOpens before session resolution, skipping sign-inGated shape: parse → pending → navigate after sign-in
Forwarding the Android launch intent and letting NavHost handle itThe AndroidX NavController already handles the launching intent's deep link; the screen opens twiceForward only onNewIntent, or use the gated shape end-to-end
application(_:open:options:) in a SwiftUI AppNot called with the scene lifecycle.onOpenURL { }
Passing whole objects as route argumentsSaved-state bloat, custom NavType boilerplate, stale dataPass the id; load in the destination
Navigating from a ViewModelLeaks the controller; untestableEffect → effect handler / shell
Back handler always enabledSwallows back on every screen stateisBackEnabled bound to the state that needs it
savedStateHandle result never clearedRe-applied on every recomposition or returnReset the key after consuming

Related skills

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.