| name | compose-multiplatform-navigation |
| description | Navigation in Compose Multiplatform with Voyager or Decompose, including deep links, back-handling, and nested graphs. Use when designing multi-screen KMP apps. |
Compose Multiplatform Navigation
Instructions
androidx.navigation:navigation-compose is Android-only. For CMP, use Voyager (screen-model centric, Compose-first) or Decompose (lifecycle + state-holder centric, better for complex nav / multi-window). Pick one per app.
1. Voyager — minimal, idiomatic Compose
commonMain.dependencies {
implementation(libs.voyager.navigator)
implementation(libs.voyager.screenmodel)
implementation(libs.voyager.transitions)
}
data object HomeScreen : Screen {
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
Column {
Text("Home")
Button(onClick = { navigator.push(DetailScreen(id = 42)) }) { Text("Open 42") }
}
}
}
data class DetailScreen(val id: Long) : Screen {
@Composable
override fun Content() {
val screenModel = rememberScreenModel { DetailScreenModel(id) }
val state by screenModel.state.collectAsState()
Text("Detail $id: ${state.title}")
}
}
@Composable
fun App() = Navigator(HomeScreen) { nav ->
SlideTransition(nav)
}
Back handling works automatically via BackHandler on Android and via the Compose iOS runtime's system back. For deep links, resolve the URL at your entry point and navigator.replaceAll(...) the target screen:
fun handleDeepLink(url: String, nav: Navigator) {
val uri = Url(url)
when (uri.pathSegments.firstOrNull()) {
"item" -> nav.replaceAll(listOf(HomeScreen, DetailScreen(uri.pathSegments[1].toLong())))
}
}
2. Decompose — component tree, great for tablet / master-detail
sealed interface RootComponent {
val stack: Value<ChildStack<*, Child>>
fun onBackClicked()
sealed interface Child {
class Home(val component: HomeComponent) : Child
class Detail(val component: DetailComponent) : Child
}
}
class DefaultRootComponent(componentContext: ComponentContext) :
RootComponent, ComponentContext by componentContext {
private val nav = StackNavigation<Config>()
override val stack: Value<ChildStack<*, RootComponent.Child>> = childStack(
source = nav,
serializer = Config.serializer(),
initialConfiguration = Config.Home,
handleBackButton = true,
) { config, ctx ->
when (config) {
Config.Home -> RootComponent.Child.Home(DefaultHomeComponent(ctx, ::openDetail))
is Config.Detail -> RootComponent.Child.Detail(DefaultDetailComponent(ctx, config.id))
}
}
private fun openDetail(id: Long) = nav.pushNew(Config.Detail(id))
override fun onBackClicked() = nav.pop()
@Serializable
private sealed interface Config {
@Serializable data object Home : Config
@Serializable data class Detail(val id: Long) : Config
}
}
@Composable
fun RootUi(component: RootComponent) {
Children(stack = component.stack, animation = stackAnimation(slide())) {
when (val child = it.instance) {
is RootComponent.Child.Home -> HomeUi(child.component)
is RootComponent.Child.Detail -> DetailUi(child.component)
}
}
}
Decompose integrates with Android SavedStateHandle, iOS lifecycle (via LifecycleRegistry wired in the iOS entry point), and produces a serializable back stack — robust against process death.
3. Deep links
Centralize URL parsing in commonMain. Emit typed intents that navigation code consumes:
sealed interface DeepLink {
data object Home : DeepLink
data class Item(val id: Long) : DeepLink
}
fun parseDeepLink(raw: String): DeepLink? = runCatching {
val url = Url(raw)
when (url.pathSegments.firstOrNull()) {
"item" -> DeepLink.Item(url.pathSegments[1].toLong())
else -> DeepLink.Home
}
}.getOrNull()
Android consumes via Activity.onNewIntent; iOS via SceneDelegate/@main App.
4. Back press & gesture-back
- Android: the navigator library registers a
BackHandler; don't register your own globally.
- iOS: Compose MP 1.7+ honours interactive pop gestures automatically when a
Navigator/Decompose stack is non-empty.
- Desktop: wire your own keyboard shortcut (e.g.
Esc) using onPreviewKeyEvent.
Checklist