Lifecycle, ViewModel and Navigation in Compose Multiplatform
|
This content was generated with the assistance of AI and should be verified against the official Kotlin Multiplatform documentation before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
A screen in a shared Compose Multiplatform (CMP) app needs three things that Android developers get from the
framework: a lifecycle that says when it is visible and active, a ViewModel that survives configuration
changes and owns its state, and a navigation layer that moves between screens, handles the back button and
accepts links from the outside. In shared code none of these may depend on an Activity. CMP therefore ships
multiplatform builds of the Jetpack Lifecycle, ViewModel and Navigation libraries and maps the Android concepts onto
UIKit, Swing and the browser. This page explains the mappings, the places where multiplatform differs from Android
(no reflection on Kotlin/Native, no browser history in Navigation 3 yet, no Activity for deep links), and how
TrailMate uses them.
The baseline is the one used across this section: Kotlin 2.4.20, Compose Multiplatform 1.12.1, AGP 9.4,
Xcode 26.4, Ktor 3.6.0, Koin 4.2.2, SKIE 0.10.15, KMP-NativeCoroutines 1.0.6, SQLDelight 2.4.0,
Room 3 (androidx.room3) 3.0.3, Mokkery 3.5.0, vanniktech 0.37.0, Dokka 2.2.0 and Turbine 1.2.1.
Swift export and SwiftPM import are Alpha; Kotlin/Wasm and Compose Multiplatform for web are Beta. Library versions in
the Gradle snippets below are the ones the Kotlin Multiplatform documentation lists for each artifact; check the
current release before copying them. The examples are slices of TrailMate Everywhere (package com.example.trailmate,
modules sharedLogic, sharedUI, androidApp, iosApp, desktopApp, webApp). The Android-only treatment of the same
libraries is on Navigation in Jetpack Compose and
ViewModel, Lifecycle and Coroutines; this page does not
repeat them and concentrates on what changes once the code is shared. The Kotlin language itself is covered in the
Kotlin section.
How the Pieces Fit Together
Compose Multiplatform does not reimplement these libraries; JetBrains builds the AndroidX sources for the non-Android
targets and publishes them under the org.jetbrains.androidx.* group together with a reference to the original
Android artifact. The Compose Multiplatform Gradle plugin resolves the right variant per target, so common code
declares one dependency and Android still uses Google’s binary.
| Concern | Common-code API | Android behaviour | Non-Android behaviour |
|---|---|---|---|
Lifecycle |
|
The |
One shared owner per Compose host, driven by UIKit, Swing or browser events |
ViewModel |
|
|
A common |
Navigation |
|
Back stack saved in the instance state |
Saved through the common saved-state registry; browser history only with an opt-in call |
Back events |
|
System back and predictive back |
iOS swipe-from-edge gesture, desktop and web handled by the host |
Lifecycle in Common Code
On Android a composable reads the lifecycle of its host Activity. A shared composable cannot do that, so Compose
Multiplatform provides a common LifecycleOwner. By default every composable created by one Compose host (an
Activity, a ComposeUIViewController, a Window, a ComposeViewport) shares the same owner, exposed through the
LocalLifecycleOwner composition local. The states and events are the Jetpack ones: INITIALIZED, CREATED,
STARTED, RESUMED and DESTROYED, with the events ON_CREATE, ON_START, ON_RESUME, ON_PAUSE, ON_STOP and
ON_DESTROY. You can still create a separate owner for a subtree when it genuinely needs its own lifecycle, which is
how NavHost gives each back stack entry its own.
// sharedLogic/build.gradle.kts (excerpt)
kotlin {
sourceSets {
commonMain.dependencies {
implementation("org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.11.0")
}
// Dispatchers.Main is missing on desktop JVM by default
// (needed by Lifecycle.coroutineScope and viewModelScope).
jvmMain.dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.10.2")
}
}
}
Mapping Platform Events to Lifecycle Events
The platform events are translated as follows (this is the documented mapping; the exact callbacks can be refined in later releases).
| Lifecycle event | iOS | Desktop (Swing) | Web (Wasm) |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Never reached |
Three consequences follow. On iOS the app is RESUMED only while it is the active app, so a phone call or the
notification shade pauses it; this is the signal you use to stop location updates. On desktop the lifecycle follows the
window, so minimising pauses and stops it, while merely clicking another window only pauses it. On the web the
CREATED state is skipped and DESTROYED never arrives, because the page is always attached and is simply discarded
when the tab closes; do not rely on ON_DESTROY for cleanup in webApp, use DisposableEffect instead.
Reacting to the Lifecycle
// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/TrackingEffect.kt
package com.example.trailmate.ui
import androidx.compose.runtime.Composable
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.LifecycleResumeEffect
import com.example.trailmate.location.LocationProvider
@Composable
fun TrackingEffect(locationProvider: LocationProvider) {
// Runs once per ON_START, whatever the platform
LifecycleEventEffect(Lifecycle.Event.ON_START) {
locationProvider.refreshPermissionState()
}
// Start when RESUMED, stop when PAUSED (phone call, window blur, app switch)
LifecycleResumeEffect(locationProvider) {
locationProvider.startUpdates()
onPauseOrDispose { locationProvider.stopUpdates() }
}
}
LifecycleEventEffect runs a block for one event; LifecycleResumeEffect pairs a start action with a stop action, in
the same shape as DisposableEffect but tied to RESUMED. Both work in commonMain because they only depend on
LocalLifecycleOwner. The effects themselves are explained on
Compose State and Side Effects.
ViewModel in Common Code
The ViewModel class and viewModelScope are the same as on Android: state that outlives the composition and
cancels its coroutines when its owner is gone. Compose Multiplatform supplies a common ViewModelStoreOwner per host,
so a ViewModel created from a composable is retained as long as the host lives. The state-holder design, StateFlow
exposure and unit testing of ViewModels are covered on
Architecture and Shared ViewModels; here the
focus is on creating and scoping them from Compose.
# gradle/libs.versions.toml (excerpt)
[versions]
androidx-viewmodel = "2.10.0"
koin = "4.2.2"
[libraries]
androidx-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-viewmodel" }
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel" }
Why viewModel() Needs an Initializer
On Android, viewModel<T>() can build T by reflection when it has a no-argument constructor. Kotlin/Native has no
JVM-style runtime reflection, so common code cannot call viewModel() without parameters: you must provide an
initializer (or a factory) every time. If you pass only an initializer, Compose Multiplatform creates a default
factory around it.
// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/TrailListRoute.kt
package com.example.trailmate.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.trailmate.di.appContainer
import com.example.trailmate.presentation.TrailListViewModel
@Composable
fun TrailListRoute(onTrailClick: (String) -> Unit) {
// Initializer lambda: no reflection required, works on iOS, desktop and web
val viewModel: TrailListViewModel = viewModel { TrailListViewModel(appContainer.trailRepository) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
TrailListScreen(uiState = uiState, onTrailClick = onTrailClick)
}
collectAsStateWithLifecycle() is the multiplatform counterpart of the Android function of the same name: it stops
collecting while the common lifecycle is below STARTED, so a backgrounded iOS app does not keep a network refresh
rendering into nothing. For a ViewModel with no dependencies, viewModel \{ OrderViewModel() } is enough; when it needs
a SavedStateHandle or CreationExtras, use the viewModel(factory = …) overload with a viewModelFactory built
from initializer \{ … } blocks.
Constructor Injection with koinViewModel()
Hand-written initializers duplicate the wiring that a DI container already owns. With Koin, add
koin-compose-viewmodel and ask the container for the ViewModel; the dependencies come from trailMateModule. How the
module is declared and started on every platform is on
Dependency Injection in KMP.
// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/TrailDetailRoute.kt
package com.example.trailmate.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.trailmate.presentation.TrailDetailViewModel
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@Composable
fun TrailDetailRoute(trailId: String, onBack: () -> Unit) {
// The id is passed to the viewModel { params -> ... } definition in trailMateModule
val viewModel: TrailDetailViewModel = koinViewModel(parameters = { parametersOf(trailId) })
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
TrailDetailScreen(uiState = uiState, onBack = onBack)
}
Metro offers the same shape with metroViewModel() from metrox-viewmodel-compose. Both integrations create the
instance through the DI graph and register it in the same common ViewModelStore, so the lifecycle behaviour
below is identical.
Scoping a ViewModel to a Screen
A ViewModel is scoped to the nearest ViewModelStoreOwner. Under Navigation Compose, every back stack entry is one,
so viewModel \{ … } inside a composable<…> destination is scoped to that destination and cleared when it is popped.
Under Navigation 3 it is different: ViewModels are not scoped to entries by default, and without the decorators
shown later they belong to the host (the Activity on Android), staying alive after the user has left the screen.
This is the most common source of "my ViewModel never resets" reports after moving to Navigation 3.
Where viewModel \{ … } is called |
Scope | Cleared when |
|---|---|---|
Directly in |
The Compose host |
The host is destroyed (Activity finished, window closed, tab closed) |
Inside a Navigation Compose destination |
That back stack entry |
The entry is popped (or the nav graph is, with |
Inside a Navigation 3 entry with |
That |
The key is removed from the back stack |
Inside a Navigation 3 entry without the decorator |
The Compose host |
The host is destroyed |
Sharing Levels: Compose ViewModel Versus SwiftUI
The setup above is for the shared UI path. When TrailMate keeps SwiftUI and shares only the ViewModel, switch the
dependency from the Compose artifact to plain lifecycle-viewmodel, export it with api(…) so it appears in the
framework, and let Swift own the lifecycle with SKIE, KMP-NativeCoroutines or KMP-ObservableViewModel. The Android side
keeps using viewModel() as usual. Stability of the Swift-facing tools is described on
Coroutines and Flow from Swift; the trade-offs of
the whole spectrum are on iOS Integration Options.
// sharedLogic/build.gradle.kts (excerpt) -- native UI path (SwiftUI keeps the screens)
kotlin {
listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
target.binaries.framework {
baseName = "SharedLogic"
export(libs.androidx.lifecycle.viewmodel) // ViewModel must be visible to Swift
}
}
sourceSets {
commonMain.dependencies {
api(libs.androidx.lifecycle.viewmodel) // not the -compose artifact
}
}
}
Navigation Compose: Type-Safe Routes
Compose Multiplatform adopts the Jetpack Compose navigation model: a NavController owns a back stack of entries, a
NavHost renders the top entry, and a destination graph maps routes to composables. The multiplatform artifact is
org.jetbrains.androidx.navigation:navigation-compose and supports Android, iOS, desktop and web. The concepts
(NavHost, popUpTo, launchSingleTop, nested graphs) are the same as on
Navigation in Jetpack Compose and are not repeated; what matters here is that
routes are @Serializable types, because string routes with navArgument are the legacy approach and
type-safe routes work without reflection on every target.
// sharedUI/src/commonMain/kotlin/com/example/trailmate/navigation/Routes.kt
package com.example.trailmate.navigation
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable @SerialName("trails")
data object TrailList
@Serializable @SerialName("trail")
data class TrailDetail(val id: String)
@Serializable @SerialName("settings")
data object Settings
The kotlinx.serialization plugin (org.jetbrains.kotlin.plugin.serialization) must be applied to the module that
declares the routes. @SerialName is optional but valuable: it fixes the name that appears in saved state, deep link
base paths and, on the web, in the URL fragment, so renaming a class does not silently break stored back stacks or
shared links.
// sharedUI/src/commonMain/kotlin/com/example/trailmate/navigation/TrailMateNavHost.kt
package com.example.trailmate.navigation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import com.example.trailmate.ui.SettingsRoute
import com.example.trailmate.ui.TrailDetailRoute
import com.example.trailmate.ui.TrailListRoute
@Composable
fun TrailMateNavHost(
modifier: Modifier = Modifier,
navController: NavHostController = rememberNavController(),
) {
NavHost(navController = navController, startDestination = TrailList, modifier = modifier) {
composable<TrailList> {
TrailListRoute(
onTrailClick = { id -> navController.navigate(TrailDetail(id)) },
onSettings = { navController.navigate(Settings) },
)
}
composable<TrailDetail> { entry ->
val route: TrailDetail = entry.toRoute()
TrailDetailRoute(trailId = route.id, onBack = { navController.popBackStack() })
}
composable<Settings> { SettingsRoute(onBack = { navController.popBackStack() }) }
}
}
Each back stack entry implements LifecycleOwner. When a screen is covered by another, it moves from RESUMED to
STARTED, and back when the user returns; "settled" (navigation is finished and the screen is active) is the
RESUMED state. This is why LifecycleResumeEffect inside a destination stops location updates as soon as
TrailDetail is pushed on top.
Deep Links in Compose Multiplatform
A deep link lets the operating system open TrailMate on a specific trail, for example
trailmate://trail/42. Compose Multiplatform handles the navigation part in common code; registering the scheme
and delivering the URI is platform work. Three steps are needed.
Step 1: Register the Scheme with Each Platform
| Platform | Where the scheme is declared | Delivery to your code |
|---|---|---|
Android |
An intent filter in |
The |
iOS |
|
SwiftUI |
Desktop macOS |
The Compose Gradle DSL writes values into the app’s |
|
Desktop Windows and Linux |
The installer (registry, package manifest, or a |
Command-line argument passed to |
Web |
Not applicable: the URL is the app address; parameters go in the fragment after |
Read by the browser navigation binding |
<!-- iosApp/iosApp/Info.plist (excerpt) -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>trailmate</string>
</array>
</dict>
</array>
Step 2: Assign Deep Links to Destinations
Each composable<T> takes a deepLinks list of NavDeepLink objects. You can write a general URI pattern, or let the
library generate one from the route type with navDeepLink<T>(basePath = …). In the generated form, required
parameters become path segments, parameters with defaults become query parameters, and collections become repeated query
parameters, all in the field order of the class.
// sharedUI/src/commonMain/kotlin/com/example/trailmate/navigation/TrailMateNavHost.kt (excerpt)
composable<TrailDetail>(
deepLinks = listOf(
// Generated: trailmate://trail/{id}
navDeepLink<TrailDetail>(basePath = "trailmate://trail"),
// Hand-written, for an https link (an App Link on Android, a universal link on iOS)
navDeepLink { uriPattern = "https://trailmate.example.com/trails/{id}" },
),
) { entry ->
val route: TrailDetail = entry.toRoute()
TrailDetailRoute(trailId = route.id, onBack = { navController.popBackStack() })
}
Overlapping patterns are a bug: if several destinations match the same URI the behaviour is indeterminate. A URI with
no scheme is assumed to be http:// or https://. Always treat the received URI as untrusted input and validate the
trail id before it reaches the repository.
Step 3: Handle the URI the App Receives
The common NavHost has no Intent or URL to read, so the usual pattern is a small singleton in common code that
platform code feeds and the NavHost observes.
// sharedUI/src/commonMain/kotlin/com/example/trailmate/navigation/DeepLinkBus.kt
package com.example.trailmate.navigation
import androidx.navigation.NavController
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
object DeepLinkBus {
// replay = 1 keeps a link received before the UI was composed (cold start)
private val _links = MutableSharedFlow<String>(replay = 1, extraBufferCapacity = 1)
val links: SharedFlow<String> = _links
fun onUri(uri: String) { _links.tryEmit(uri) }
}
// Used from App(): LaunchedEffect(navController) { DeepLinkBus.links.collect { navController.navigate(...) } }
// iosApp/iosApp/iOSApp.swift
import SwiftUI
import SharedUI
@main
struct iOSApp: App {
var body: some Scene {
WindowGroup {
ComposeHost()
.ignoresSafeArea()
.onOpenURL { url in
// Forward to the shared singleton; the NavHost reacts
DeepLinkBus.shared.onUri(uri: url.absoluteString)
}
}
}
}
On Android the same call goes in MainActivity (intent.dataString?.let(DeepLinkBus::onUri)). On the web, deep links
live in the URL fragment, so all parameters of the pattern must be URL-encoded and placed after #; toRoute() still
parses them when the fragment follows the pattern rules. The Apple-side registration details (universal links,
associated domains) are on App Structure and Lifecycle.
Browser History: bindToBrowserNavigation()
A Compose Multiplatform web app is a single page, so by default the browser’s Back button leaves the app instead of
popping a screen. NavController.bindToBrowserNavigation() connects the nav graph to the browser: the address bar shows
the current route in the URL fragment, Back and Forward move through the app’s back stack, and typing or pasting a URL
navigates to the matching destination. It is part of an experimental API surface (@ExperimentalBrowserHistoryApi),
so it needs an opt-in, and web itself is Beta.
// sharedUI/src/commonMain/kotlin/com/example/trailmate/App.kt
@Composable
fun App(onNavHostReady: suspend (NavController) -> Unit = {}) {
TrailMateTheme {
val navController = rememberNavController()
TrailMateNavHost(navController = navController)
LaunchedEffect(navController) { onNavHostReady(navController) }
}
}
// webApp/src/wasmJsMain/kotlin/com/example/trailmate/main.kt
package com.example.trailmate
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import androidx.navigation.bindToBrowserNavigation
import kotlinx.browser.document
@OptIn(ExperimentalComposeUiApi::class, ExperimentalBrowserHistoryApi::class)
fun main() {
val body = document.body ?: return
ComposeViewport(body) {
App(onNavHostReady = { it.bindToBrowserNavigation() })
}
}
With type-safe routes the default URL is the serial name followed by the arguments, for example
trailmate.example.com/#trail/42, which is why @SerialName matters. The same binding exists for Kotlin/JS, wrapped in
onWasmReady. A custom mapping between routes and URLs is available when the default fragment is not acceptable. The
web target itself is covered on
Web Targets: Kotlin/Wasm, Kotlin/JS and Compose for Web.
Navigation 3 in Compose Multiplatform
Navigation 3 is a redesign of Android’s navigation library and, since Compose Multiplatform 1.10, works on Android,
iOS, desktop and web. It is a new library rather than a new version of Navigation Compose. The key difference: you
own the back stack. Instead of a NavController hiding a stack, you hold a SnapshotStateList of keys, mutate it with
ordinary list operations, and NavDisplay renders whatever is on top. The Android description of the model, with
entry decorators, scenes and results, is on
Navigation in Jetpack Compose;
this section covers the multiplatform specifics.
# gradle/libs.versions.toml (excerpt)
[versions]
multiplatform-nav3-ui = "1.1.1"
compose-multiplatform-lifecycle = "2.10.0"
[libraries]
jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "multiplatform-nav3-ui" }
jetbrains-lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "compose-multiplatform-lifecycle" }
Only navigation3-ui has a separate multiplatform build; navigation3-common arrives transitively.
Routes as NavKey with Polymorphic Serializers
On Android, rememberNavBackStack(RouteA) saves keys with reflection-based serialization. On iOS and the web there is
no such reflection, so the saved back stack needs an explicit SerializersModule that lists every NavKey subtype
(open polymorphism). Without it the app compiles but fails at runtime on non-JVM targets when the stack is saved or
restored. The simplest layout for TrailMate is one sealed interface.
// sharedUI/src/commonMain/kotlin/com/example/trailmate/navigation/Nav3Keys.kt
package com.example.trailmate.navigation
import androidx.navigation3.runtime.NavKey
import androidx.savedstate.serialization.SavedStateConfiguration
import kotlinx.serialization.Serializable
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
@Serializable
sealed interface TrailKey : NavKey
@Serializable data object TrailListKey : TrailKey
@Serializable data class TrailDetailKey(val id: String) : TrailKey
@Serializable data object SettingsKey : TrailKey
// Required on iOS and web: tells the saved-state machinery how to (de)serialize NavKey
val trailMateSavedState = SavedStateConfiguration {
serializersModule = SerializersModule {
polymorphic(NavKey::class) {
subclass(TrailListKey::class, TrailListKey.serializer())
subclass(TrailDetailKey::class, TrailDetailKey.serializer())
subclass(SettingsKey::class, SettingsKey.serializer())
}
}
}
The documentation recommends three layouts depending on modularization.
| Approach | Use when | Registration |
|---|---|---|
Single module, sealed type |
Every route is in one module (TrailMate today) |
|
Multi-module, aggregated sealed types |
Each feature module owns a sealed type |
The app module lists |
Multi-module, individual registration |
Routes cannot share a sealed parent |
Each module exports a |
rememberNavBackStack and NavDisplay
// sharedUI/src/commonMain/kotlin/com/example/trailmate/navigation/TrailMateNav3.kt
package com.example.trailmate.navigation
import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import com.example.trailmate.ui.SettingsRoute
import com.example.trailmate.ui.TrailDetailRoute
import com.example.trailmate.ui.TrailListRoute
@Composable
fun TrailMateNav3() {
// The overload with SavedStateConfiguration is the multiplatform-safe one
val backStack = rememberNavBackStack(trailMateSavedState, TrailListKey)
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(), // rememberSaveable per entry
rememberViewModelStoreNavEntryDecorator(), // ViewModel per entry
),
entryProvider = entryProvider {
entry<TrailListKey> {
TrailListRoute(
onTrailClick = { id -> backStack.add(TrailDetailKey(id)) },
onSettings = { backStack.add(SettingsKey) },
)
}
entry<TrailDetailKey> { key ->
TrailDetailRoute(trailId = key.id, onBack = { backStack.removeLastOrNull() })
}
entry<SettingsKey> { SettingsRoute(onBack = { backStack.removeLastOrNull() }) }
},
)
}
backStack.add(…) is a push and removeLastOrNull() is a pop; any list operation is a legal navigation (clearing
the stack and adding the home key is "pop to root"). The two decorators are what give Navigation 3 the per-screen
behaviour Navigation Compose provides automatically: the first keeps rememberSaveable state for each entry, the
second creates one ViewModelStore per entry so koinViewModel() inside entry<TrailDetailKey> returns a fresh
TrailDetailViewModel that is cleared when the key leaves the stack.
The Browser-History Gap
Navigation 3 in CMP does not yet integrate with browser history: there is no bindToBrowserNavigation() for
NavDisplay, so Back and Forward on the web leave the app and the address bar does not reflect the screen. The
Kotlin documentation says support is planned for the base library and tracks it as CMP-8924. Until then a community
proof-of-concept library (navigation3-browser, by a JetBrains engineer) adds the binding, but it is not official;
treat it as Experimental in your risk assessment. Deep links are likewise something you implement yourself by mapping
the incoming URI to keys and pushing them on the back stack, rather than declaring navDeepLink per destination.
Choosing Between Navigation Compose and Navigation 3
| Criterion | Navigation Compose (navigation-compose) |
Navigation 3 (navigation3-ui) |
|---|---|---|
Back stack |
Owned by |
Owned by you, a |
Routes |
|
|
ViewModel per screen |
Automatic |
Opt in with |
Deep links |
|
Hand-written URI-to-keys mapping |
Browser history |
|
Not available; community library only |
Adaptive layouts |
Manual |
Scenes and the adaptive Navigation 3 artifact |
Choose it when |
You need web history and declarative deep links today |
You want full control, list-detail scenes, or you share a navigation model with a new Android app |
For TrailMate the shipped app uses Navigation Compose because the webApp target needs the browser Back button;
a project without a web target, or one that accepts the gap, can adopt Navigation 3 and keep the same screens.
Back Handling and the iOS Back Gesture
Android’s BackHandler and PredictiveBackHandler are Android-specific. The multiplatform replacement is the
navigationevent library: a common model of navigation events (back, forward, progress of a gesture) that
Compose Multiplatform wires to the Android system back and predictive back, to the iOS edge-swipe gesture, and to
desktop and web input. In common code you use NavigationEventHandler, which replaces PredictiveBackHandler: it
receives the completion of the back event and, in its progress-aware overloads, the gesture progress so a screen can
animate along with the finger. The library is published by the AndroidX team for Kotlin Multiplatform; check its
release notes for its stability level before relying on progress-aware overloads.
// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/DiscardChangesHandler.kt
package com.example.trailmate.ui
import androidx.compose.runtime.Composable
import androidx.navigationevent.compose.NavigationEventHandler
@Composable
fun DiscardChangesHandler(hasUnsavedChanges: Boolean, onConfirmDiscard: () -> Unit) {
// While enabled, back is intercepted instead of popping the screen.
// Signature shown in its simplest form; progress-aware overloads also exist.
NavigationEventHandler(
isBackEnabled = hasUnsavedChanges,
onBackCompleted = onConfirmDiscard,
)
}
On iOS there is no back button. Users expect to swipe from the left edge. Compose Multiplatform for iOS supports this
gesture for Navigation Compose and Navigation 3 and drives the same navigation event stream, so a
NavigationEventHandler that is enabled also blocks the swipe, and the back animation follows the finger. When
TrailMate hosts the CMP controller inside a SwiftUI NavigationStack, two back gestures compete: SwiftUI’s own pop and
Compose’s. Keep one owner: either let SwiftUI own the stack and make each CMP screen a leaf, or let Compose own
everything beneath a single SwiftUI hosting screen. Mixing the two is described on the iOS interop page of this
section (see iOS Integration Options for the options, and
SwiftUI Navigation and Presentation for the SwiftUI side).
Alternatives: Decompose and Voyager
Two community libraries predate or sit beside the official ones and are worth knowing when you evaluate options.
| Aspect | Decompose | Voyager |
|---|---|---|
Idea |
Navigation and lifecycle as plain Kotlin components ( |
|
Strength |
Navigation logic is testable without UI and can drive a SwiftUI UI too (useful on the native UI path) |
Very small API, quick to start |
Weakness |
More ceremony; a different mental model from Jetpack |
Compose-only; less aligned with the AndroidX direction |
Official docs |
Choose them deliberately: the official libraries gain Android features first and share tooling with the rest of the AndroidX ecosystem, while Decompose is the strongest option when the navigation state must be shared with a SwiftUI UI.
The TrailMate Navigation Graph and Back Stack
(start destination)"] TD["TrailDetail(id)
deep link trailmate://trail/id"] ST["Settings"] TL -->|"navigate(TrailDetail(id))"| TD TL -->|"navigate(Settings)"| ST TD -->|"popBackStack()"| TL ST -->|"popBackStack()"| TL end DL["Deep link
iOS onOpenURL / Android Intent / web #fragment"] --> TD subgraph stack["Back stack after a deep link (top at right)"] S1["TrailList"] --> S2["TrailDetail(42)"] end BACK["Back: system, iOS swipe,
browser Back (bindToBrowserNavigation)"] -->|"pops"| S2
Common Mistakes
| Mistake | Fix |
|---|---|
Calling |
Always pass an initializer or factory, or use |
Navigation 3 ViewModels that never reset |
Add |
Navigation 3 back stack crashes on iOS or web when saved |
Use the |
Coroutines in a ViewModel do nothing on desktop |
Add |
Relying on |
It is never reached on web; use |
Browser Back leaves the web app |
Call |
Two overlapping |
Make the patterns disjoint; overlapping matches behave unpredictably. |
Trusting a deep link URI blindly |
Validate path segments and ids; treat the URI as untrusted input. |
Keeping both SwiftUI and Compose in charge of the back gesture |
Choose one owner of the stack per flow. |