Building a Cross-Platform Navigation Framework with Kotlin Multiplatform

Motivation for a Custom Solution

Kotlin Multiplatform (KMP) web support is still in its infancy, and the iOS target is in beta. The ecosystem currently lacks mature, open-source cross-platform navigation libraries, forcing developers to rely on platform-specific implementations. A custom router bridges this gap, providing shared navigation logic across targets until the community produces standardized solutions.

Architecture and Design Goals

Different platforms have distinct routing paradigms, but shared Kotlin code can unify the core logic.

Mobile Targets

  • Forward and backward screen transitions.
  • Passing arguments between screens during entry and exit.
  • Popping multiple screens from the backstack simultaneously.
  • Replacing the current screen while launching a new one.
  • Determining the origin screen that triggered the current navigation.

Desktop Targets

  • Support for multiple application windows.
  • Independent navigation stacks within each window.
  • All features listed for mobile targets.

Implementation: Single-Window Navigation

Component Overview

NavHost.kt

The primary entry point for rendering navigation. To accommodate desktop scenarios, it accepts a scopeId parameter. Since mobile applications typically operate with a single window, the default value is Unit, simplifying the API for mobile consumers. Theoretically, it supports composable-level routing by assigning distinct scopeId values, allowing desktop apps to swap local UI sections independently.

ScreenDef.kt

Defines the mapping between a navigation route and its corresponding Composable content. The content lambda is an extension function on NavController, giving direct access to navigation actions and data retrieval.

NavRoute.kt

Manages the navigation state, including initialization, fetching the active route, and tracking transition statuses. It relies on Compose's SnapshotStateMap for reactive state management.

NavController.kt

Exposes the user-facing API, such as navigateTo and goBack, with argument passing support. Data is retrieved through onEntry and onReturn callbacks. Because of Compose recomposition behaviors, registering multiple callbacks within the same NavController requires unique keys to prevent overrides.

Code Implementation

NavHost.kt

@Composable
fun NavHost(
    startDestination: NavRoute,
    vararg screens: ScreenDef,
    scopeId: Any = Unit,
) {
    initializeNavStack(scopeId, startDestination)
    screens.forEach { screen ->
        val transition = when (observeNavStatus(scopeId)) {
            TransitionType.Enter -> SlideInTransition
            TransitionType.Exit -> SlideOutTransition
        }
        val activeRoute = resolveCurrentRoute(scopeId)
        AnimatedVisibility(
            visible = screen.route == activeRoute,
            enter = transition.enter,
            exit = transition.exit,
            label = screen.route.path
        ) {
            val controller = NavController.provide(scopeId, screen.route)
            screen.content(controller)
        }
    }
}

ScreenDef.kt

data class ScreenDef(
    val route: NavRoute,
    val content: @Composable NavController.() -> Unit
)

NavRoute.kt

interface NavRoute {
    val path: String
}

enum class TransitionType {
    Enter, Exit
}

private val activeRouteStateMap: SnapshotStateMap<Any, NavRoute>? = null

private val transitionStatusMap = mutableMapOf<Any, TransitionType>()

private val navigationBackstack = mutableMapOf<Any, Stack<NavRoute>>()

private val originRouteMap = mutableMapOf<Any, NavRoute>()

internal fun updateCurrentRoute(scopeId: Any, destination: NavRoute) {
    activeRouteStateMap!![scopeId] = destination
}

fun resolveCurrentRoute(scopeId: Any): NavRoute {
    return activeRouteStateMap!![scopeId]!!
}

internal fun recordTransitionStatus(scopeId: Any, status: TransitionType) {
    transitionStatusMap[scopeId] = status
}

Tags: kotlin Kotlin Multiplatform Navigation Jetpack Compose cross-platform

Posted on Sat, 15 Aug 2026 15:59:26 +0000 by alpine