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)”
[…] the previous article of this series about my KMM Navigation system I talked about the Android part mainly, and I showed […]