Navigation System with KMM (Part 2)

In the previous article of this series about my KMM Navigation system I talked about the Android part mainly, and I showed a little bit of the iOS side.

I talked about NavInstance and NavBuilder, two helper classes to manage the navigation stack on the iOS side of the app. Now I’m going to link all pieces together to get a global vision of how everything works

import SwiftUI
import shared

struct KMMNavigationView: View {
    
    let associations: [String : NavBuilder]
    @ObservedObject var navigationBridge : NavigationBridge
    @State var navigator: INavigator
    
    init(associations: [String : NavBuilder]){
        let navigator = DIPresentationHelper.shared.getNavigator()
        
        self.navigator = navigator
        self.associations = associations
        self.navigationBridge = NavigationBridge(associations: associations, navigator: navigator)
        navigator.navigatorBridge = self.navigationBridge
    }
    
    var body: some View {
        NavigationStack(path: self.navigationBridge.navPath) {
            getRootView()
                .navigationDestination(
                    for: NavInstance.self,
                    destination: { instance in AnyView(
                            instance.viewBuilder(instance.viewModel)
                        )
                    }
                ).navigationViewStyle(.stack)
        }
    }
    
    @ViewBuilder
    func getRootView() -> some View {
        let instance = navigationBridge.navInstances.first(where: { $0.request == navigationBridge.rootRoute })
        AnyView(instance!.viewBuilder(instance!.viewModel))
    }
}

This is the place where everything gathers and starts to make sense. Let’s dive in the code:

  • There are 3 field here: the navigation bridge, the navigator and a map of associations.
  • This is a view so it has a body, which is a NavigationStack which has a list of NavInstance as parameter.
  • That NavigationStack has a getRootView viewBuilder as child. That method “renders” the root view, the view which we have selected as initial one.
  • Finally, we have two modifiers applied here (sorry if that has a different name on iOS, I use Android way to name things :P). The first one is .navigationDestination, which links the NavInstances created by the NavigationBridge and stored in the navigationPath with their corresponding views. The last one is to set that our navigation behavior is a stack (last in, first out).

Ok, but how do I set up that associations?, let’s check it:

import SwiftUI
import shared
import KMPObservableViewModelCore

struct ContentView: View {	

	var body: some View {      
        
        KMMNavigationView(
            associations: [
                MainScreenNavigationRequest.shared.route : NavBuilder({ request in DIPresentationHelper.shared.getMainViewModel(request: request) }, { viewModel in MainScreenView(viewModel: viewModel as! MainViewModel) }),
                Test1ScreenNavigationRequest.shared.route : NavBuilder({ request in DIPresentationHelper.shared.getMainViewModel(request: request) }, { viewModel in Test1ScreenView(viewModel: viewModel as! MainViewModel) }),
                Test2ScreenNavigationRequest.shared.route : NavBuilder({ request in DIPresentationHelper.shared.getMainViewModel(request: request) }, { viewModel in Test2ScreenView(viewModel: viewModel as! MainViewModel) })
            ]
        )
	}
}

Here you have a map which links a route with a NavBuilder. NavBuilder is a container which has all needed information to create a NavInstance (how to create or obtain the viewModel and how to build the UI).

DIPresentationHelper is a helper class of the KMM side to be able to get instances of classes created by the dependency injection system (Koin, in this case).

How everything works?

Everything is kind of abstract or complex at this point, so I’m going to explain step by step how everything works:

  • Let’s start from the end: ContentView. Here we set up the associations route<->views (check NavBuilder class in the previous article of this series)
  • Now we have the associations!. When we start the app, we will only have one NavInstance in our stack, the root one. If you look in NavigationBridge yo will see that it is defined in let rootRoute = NavigationRequestsKt.rootRequest. I will dive in NavigationRequests in the next section.
  • Ok, now, we navigate from root to another screen. Let’s assume that we press a button to navigate. In that moment, navigateTo method of NavigationBridge is called, and getOrCreateNavInstance is also called. If the NavInstance associated with the provided navigation request exists, we will send the existing one, otherwise, we will create a new one using the provided associations in the first step (remember, NavBuilder -> NavInstance)
  • When the new NavInstance is created, we add it to navPathStack (which is a @Published var, and the whole Navigator is an ObservableObject).
  • Since we have updated navPathStack, the UI is notified about it because we marked navigationBridge as @ObservedObject in KMMNavigationView. In this moment, navPathStack has one more element, and we have configured the navigation as a ‘stack’ so with the new NavInstance previously created, we build the new view on top of the previous one and that’s it!

Navigation requests

A navigation request is a data model which has:

  • A route. For example /home/screen1
  • A list of arguments to be consumed by the destination. For example, an id to be requested to a backend.
  • A continuation to be used when the intent is to navigate for a result. For example, to pick an element from a list and add it to a form.
open class NavigationRequest<ARGS>(
    val route: String,
    val arguments: ARGS? = null,
    var continuation: Continuation<Any?>? = null
) {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other == null || this::class != other::class) return false

        other as NavigationRequest<*>

        if (route != other.route) return false
        if (arguments != other.arguments) return false
        if (continuation != other.continuation) return false

        return true
    }

    override fun hashCode(): Int {
        var result = route.hashCode()
        result = 31 * result + (arguments?.hashCode() ?: 0)
        result = 31 * result + (continuation?.hashCode() ?: 0)
        return result
    }
}

But how all this is used? Ok, do you remember the INavigator interface described in the previous article? Let’s check a ‘real’ implementation of it: the Navigator class.

package com.codepredator.navigation

import com.codepredator.navigation.models.NavigationRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine

class Navigator(override val initialRoute: NavigationRequest<*>) : INavigator {

    override lateinit var navigatorBridge: INavigatorBridge

    override var currentRequest : NavigationRequest<*>? = null
    override var stack = mutableListOf(initialRoute)
    
    override suspend fun navigateTo(request: NavigationRequest<*>){
        stack.add(request)

        navigatorBridge.navigateTo(request)
    }

    override suspend fun <T> navigateToForResult(request: NavigationRequest<*>) : T {
        return withContext(Dispatchers.Main){
            suspendCoroutine {
                if (navigatorBridge.navigateTo(request)) {
                    request.continuation = it as Continuation<Any?>?

                    currentRequest = request

                    stack.add(request)
                }
            }
        }
    }

    override fun popBack(result: Any?) : Boolean {
        val success = navigatorBridge.popBack()

        if (success) {
            stack.remove(currentRequest)
            currentRequest?.continuation?.resume(result)
        }

        return success
    }

    override suspend fun popUpToRoot() = withContext(Dispatchers.Main) {
        val success = navigatorBridge.popUpToRoot()

        if (success){
            while (stack.size > 1){
                val request = stack.last()
                request.continuation?.resume(null)
                stack = stack.dropLast(1).toMutableList()
                currentRequest = stack.last()
            }
        }

        success
    }


}

This class is the way to connect the shared login written in KMM with each platform navigation bridge. Let’s analyze it method by method:

  • navigateTo: it adds the request to the stack and send it to the bridge.
  • navigateToForResult: same as previous one but populating the ‘continuation’ parameter of the request object.
  • popBack: it sends the popBack request to the navigator and ,if the popBack is possible, the request is removed from the stack and the result (if any) is sent back.
  • popUpToRoot: it is similar to the previous one but it ‘consumes’ all requests except the first one (the initial route).

And that’s it. I know this is a pretty complex topic and these two articles are not very comprehensive, but making step by step guides is not my plan now. I want to publish my navigation library on GitHub and Maven. I’ll keep you posted!

, , , ,

One response to “Navigation System with KMM (Part 2)”