Sharing navigation logic with Kotlin Multiplatform

Sep 03, 2025
When developing applications with Kotlin Multiplatform, we want to achieve the highest degree of code sharing between platforms.
While Compose Multiplatform allows all of our UI to be shared, using just Kotlin Multiplatform with native UIs can make sharing some code more difficult.
Navigation in Compose and in SwiftUI, while different, shares some common ideas. In Compose we can work with the Jetpack Navigation library, and in SwiftUI we would use a NavigationStack
Defining our navigator interface
The first part is to define our navigator interface and routes on the common source set. There will be one native implementation for Android and another for iOS.
public interface AppNavigator {
public fun goTo(route: AppRoute)
}
@Serializable
public sealed class AppRoute {
@Serializable
public data object Home: AppRoute()
@Serializable
public data class Detail1(val text: String): AppRoute()
@Serializable
public object Detail2: AppRoute()
}
Then on our Android source set an implementation to the interface can be written as:
public class AppNavigatorImpl(
private val navController: NavController,
): AppNavigator {
public override fun goTo(route: AppRoute) {
when (route) {
AppRoute.Home -> { /* no-op */ }
else -> navController.navigate(route)
}
}
}
And our Swift navigator can be written as:
import Foundation
import Shared
import SwiftUI
class AppNavigatorImpl : ObservableObject, AppNavigator {
@Published var navigationPath: NavigationPath = NavigationPath()
func goTo(route: AppRoute) {
switch onEnum(of: route) {
case .home:
break
case .detail1(let detail1):
navigationPath.append(detail1)
case .detail2(let detail2):
navigationPath.append(detail2)
}
}
}
Calling our navigator from a shared Presenter/ViewModel
Now that we have set up our navigator implementation on both platforms, we can add the interface as a constructor parameter for our Presenter or ViewModel. I’m using a simple plain presenter here because even if the Jetpack libraries have recently added Multiplatform support for their ViewModel, most of the time a simple presenter gets the job done.
public class HomePresenter(
private val navigator: AppNavigator,
) {
public fun onAction(action: Action) {
when (action) {
is Action.NavigateToDetail1 ->
navigator.goTo(AppRoute.Detail1(action.text))
Action.NavigateToDetail2 ->
navigator.goTo(AppRoute.Detail2)
}
}
public sealed class Action {
public data class NavigateToDetail1(val text: String) : Action()
public data object NavigateToDetail2 : Action()
}
}
Integrating the presenter
Android integration
On Android Jetpack Navigation will be used. Jetpack Navigation works with a NavController and a NavHost which handles each one of the routes the NavController will get pushed.
@Composable
private fun AppNavHost() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = AppRoute.Home
) {
composable<AppRoute.Home> { backStackEntry ->
val presenter = remember {
HomePresenter(
navigator = AppNavigatorImpl(navController)
)
}
HomeView(presenter = presenter)
}
composable<AppRoute.Detail1> { backStackEntry ->
val route = backStackEntry.toRoute<AppRoute.Detail1>()
Detail1View(text = route.text)
}
composable<AppRoute.Detail2> { backStackEntry ->
Detail2View()
}
}
}
@Composable
private fun HomeView(
presenter: HomePresenter,
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Button(onClick = {
presenter.onAction(HomePresenter.Action.NavigateToDetail1("Navigating from Android"))
}) {
Text("Destination 1")
}
Button(onClick = {
presenter.onAction(HomePresenter.Action.NavigateToDetail2)
}) {
Text("Destination 2")
}
}
}
@Composable
private fun Detail1View(
text: String
) {
Text(text = text)
}
@Composable
private fun Detail2View() {
Text(text = "Destination 2")
}
iOS integration
For the iOS app we’ll be using NavigationStack. It works quite similar to the Android implementation, but the routes/destinations are declared as navigationDestination modifiers.
import SwiftUI
import Shared
struct ContentView: View {
@State private var showContent = false
@ObservedObject private var navigator: AppNavigatorImpl
private let presenter: HomePresenter
init(
navigator: AppNavigatorImpl
) {
self.navigator = navigator
self.presenter = HomePresenter(navigator: navigator)
}
var body: some View {
NavigationStack(path: $navigator.navigationPath) {
VStack {
Button("Destination 1") {
presenter.onAction(
action: HomePresenter.ActionNavigateToDetail1(text: "Navigating from iOS")
)
}
.buttonStyle(.bordered)
Button("Destination 2") {
presenter.onAction(
action: HomePresenter.ActionNavigateToDetail2()
)
}
.buttonStyle(.bordered)
}
.navigationDestination(for: AppRoute.Detail1.self) { route in
Destination1(text: route.text)
}
.navigationDestination(for: AppRoute.Detail2.self) { _ in
Destination2()
}
}
}
}
struct Destination1: View {
private let text: String
init(text: String) {
self.text = text
}
var body: some View {
Text(text)
}
}
struct Destination2: View {
var body: some View {
Text("Destination 2")
}
}
Example source code
The source code for this example is available at GitHub.