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
}