Sharing navigation logic with Kotlin Multiplatform

Restia's avatar

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")
}
drawing

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")
    }
}
drawing

Example source code

The source code for this example is available at GitHub.