Navigation system with KMM (Part 1)

One of the most critical parts of any slightly complex app is the navigation system, and unifying its logic to be able to use it in Kotlin Multiplatform is a challenge. First of all, this article (an all next parts) are only my way to do this. Probably there are better ways to do it so feel free to give me feedback, specially if you are an iOS developer 🙂

In this first article I’m going to talk about the shared part between Android and iOS and the basic part of both platforms.

For my navigation system I need several basic things like:

  • Navigate to a destination
  • Navigate to a destination to get a result (pick and image, a file, info, etc.)
  • Pop back to the previous screen
  • Pop back to root

So, I have created this class to abstract my requirements

package com.codepredator.navigation

import com.codepredator.navigation.models.NavigationRequest

interface INavigator {

    var navigatorBridge: INavigatorBridge

    val initialRoute: NavigationRequest<*>
    var stack : MutableList<NavigationRequest<*>>
    var currentRequest: NavigationRequest<*>?

    suspend fun navigateTo(request: NavigationRequest<*>)

    suspend fun <T> navigateToForResult(request: NavigationRequest<*>): T

    fun popBack(result: Any? = null) : Boolean

    suspend fun popUpToRoot() : Boolean

}

Here, you can see all my requierements as interface’s methods as well as other vals and vars:

  • The navigator bridge is an abstraction of the specific platform navigator, I will share the content of that interface in the next piece of code.
  • Initial route is the enthropy of… no, it is just the initial screen I want to show when the app starts.
  • The stack is the current navigation stack of the app.
  • Current request is the last navigation step you did while navigating through the app. It is used mainly for navigateToForResult (spoiler: it stores a Continuation)

So… what is INavigatorBridge? It’s an interface used to abstract the “navigator controller” of each platform.

interface INavigatorBridge {

    fun navigateTo(request: NavigationRequest<*>) : Boolean
    fun popBack(): Boolean
    fun popUpToRoot(): Boolean
    fun systemJustPopBack()

}

In the case of Android, it is just a wrapper around Compose’s NavHostController

class AndroidNavigatorBridge(private val navHostController: NavHostController) : INavigatorBridge {
  
    override fun navigateTo(request: NavigationRequest<*>): Boolean {
        navHostController.navigate(request.route)

        return true
    }

    override fun popBack(): Boolean = navHostController.navigateUp()
    
    @SuppressLint("RestrictedApi")
    override fun popUpToRoot(): Boolean =
        navHostController.currentBackStack.value[1].let {
            navHostController.popBackStack(
                it.destination.id,
                false
            )
        }
    override fun systemJustPopBack() {

    }
}

I’m not going to enter in details about the code because I think it’s pretty self-expanatory.

Ok but what about iOS? Well, the bridge implementation for iOS is slightly more complex.

import shared
import SwiftUI
import KMPObservableViewModelCore

class NavigationBridge : INavigatorBridge, ObservableObject {
    
    var navPath: Binding<NavigationPath>{
        Binding(
            get: {
                return NavigationPath(self.navPathStack)
            },
            set: { path in               
                self.systemJustPopBack()
            }
        )
    }
    
    @Published var navPathStack: [NavInstance] = []
    
    let associations: [String : NavBuilder]
    var navInstances: [NavInstance]
    
    let rootRoute = NavigationRequestsKt.rootRequest
    
    let navigator: INavigator
    
    init(associations: [String : NavBuilder], navigator: INavigator) {
        self.associations = associations
        self.navInstances = []
        self.navigator = navigator
        
        let _ = getOrCreateNavInstance(request: rootRoute)
    }
    
    func getOrCreateNavInstance(request : NavigationRequest<AnyObject>) -> NavInstance {
        let instance = navInstances.first(where: { $0.request == request })
        
        if (instance != nil){
            return instance!
        }
        
        let builder = associations[request.route]
        let newInstance = NavInstance((builder?.viewModelBuilder(request))!, builder!.viewBuilder, request)
        
        navInstances.append(newInstance)
        
        return newInstance
    }
    
    
    func navigateTo(request : NavigationRequest<AnyObject>) -> Bool {
        navPathStack.append(getOrCreateNavInstance(request: request))
        
        updateObservable()
        
        return true
    }
    
    func popBack() -> Bool{
        removeLastInstance()
        
        updateObservable()
        
        return true
    }
    
    func popUpToRoot() -> Bool {
        while (navPathStack.count > 0){
            print("count \(navPathStack.count)")
            removeLastInstance()
        }
        return true
    }
    
    func removeLastInstance(){
        let request = navPathStack.last!.request       
        
        navInstances = navInstances.filter{ navInstance in navInstance.request != request }        
        
        navPathStack.removeLast()
    }
    
    func systemJustPopBack(){
        navigator.popBack(result: nil)
    }
    
    private func updateObservable(blocking: Bool = false) {
        if (blocking) {
            let group = DispatchGroup()
            group.enter()

            DispatchQueue.main.async { [weak self] in
                self?.objectWillChange.send()
                group.leave()
            }

            group.wait()
        } else {
            DispatchQueue.main.async { [weak self] in
                self?.objectWillChange.send()
            }
        }
    }
    
}

The main idea here is to have the navigation request, the viewModel builder and the UI builder linked in a data structure (NavInstance). If we want to navigate to a destination, we append a NavInstance (we create one or get an existing one) to the navPathStack. With the data stored in NavInstance, we can create a Swift UI view associated to a viewModel which will receive the request parameters (If we send them). The published var navPathStack will be used by a Swift UI’s NavigationStack. Why that ugly updateObservable method?. Well, it didn’t work if I don’t use it. I’m not an iOS programmer so maybe there is a better solution, don’t hesitate and send me an email please!

Ok but what are NavBuilder and NavInstance? Let see:

class NavBuilder {
    
    let viewModelBuilder: (NavigationRequest<AnyObject>) -> any ViewModel
    let viewBuilder: (any ViewModel) -> any View
    
    init(
        _ viewModelBuilder: @escaping (NavigationRequest<AnyObject>) -> any ViewModel,
        _ viewBuilder: @escaping (any ViewModel) -> any View
    ) {
        self.viewModelBuilder = viewModelBuilder
        self.viewBuilder = viewBuilder
    }
    
}

class NavInstance : Hashable {
    
    let request: NavigationRequest<AnyObject>
    let viewModel: any ViewModel
    let viewBuilder: (any ViewModel) -> any View
    
    init(
        _ viewModel: any ViewModel,
        _ viewBuilder: @escaping (any ViewModel) -> any View,
        _ request: NavigationRequest<AnyObject>
    ) {
        self.viewModel = viewModel
        self.viewBuilder = viewBuilder
        self.request = request
    }
    
    static func == (lhs: NavInstance, rhs: NavInstance) -> Bool {
        return lhs.request == rhs.request
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(request)
    }
    
}

NavBuilder is just a “container” of two lambdas: viewModelBuilder, which “generates” a viewModel using a NavigationRequest, and viewBuilder, which “generates” a view using the previously generated viewModel. I know this sounds very abstract at this point, but in the next chapter of this articles series I will show KMMNavigationView, which links all these concepts together.

What about NavInstance? well, objects of this class are created when navigation happens, and it links three things: the navigation request which started the navigation, the viewModel created using that request and the viewBuilder which we sent to NavBuilder to be able to create the view associated with that viewModel.

As I said, everything will be clearer in the next article. Hope you like this!

See you in the next chapter of Navigation with KMM series!

Last edited: 22nd November 2024

, , ,

One response to “Navigation system with KMM (Part 1)”