Deeplink with coordinators

5 minute read

There are countless articles about Coordinator pattern and how great it is to manage deeplinking, but not many go in-depth on how to use them in real production apps.

Coordinators

The coordinator pattern is wildly known and there are many articles about that. That’s why I will not describe how to implement them.

So why coordinators? You don’t want to end up with something like this…

guard let rootVC = AppDelegate.shared.window?.rootViewController else { return }
if viewController is UIAlertController {
    rootVC.present(viewController, animated: true, completion: nil)
    return
}

if viewController is WebViewVC {
    (viewController as? WebViewVC)?.onCloseTap = {
        rootVC.dismiss(animated: true, completion: nil)
    }
    let navCtrl = BaseNavigationController(rootViewController: viewController)
    rootVC.present(navCtrl, animated: true, completion: nil)
    return
}

if let tabBarCtrl = rootVC as? UITabBarController,
    let navCtrl = tabBarCtrl.selectedViewController as? UINavigationController
        ?? tabBarCtrl.selectedViewController?.navigationController {
    navCtrl.pushViewController(viewController, animated: true)
    return
}

if let navCtrl = rootVC as? UINavigationController ?? rootVC.navigationController {
    navCtrl.pushViewController(viewController, animated: true)
    return
}

I strongly encourage using Reactive approach with MVVM. For the sake of simplicity, I will skip the ViewModel part and only continue using Coordinators and ViewControllers.

App structure

We will implement deeplinks for the above app structure. Our main goal is to be able to deeplink to any part of the app. There are multiple layers of coordinators/childCoordinators and our job is to load the correct screen and keep the same navigation hierarchy.

Go with the flow

Let’s start by separating our app into flows. We can create one flow per coordinator. So our AppFlow should look like this:

enum AppFlow: String {
    case home
    case login
}

In this case we either navigate the user to the login screen or the main dashboard.

And our Home and Login parts should look like this. The other flows should be implemented in the same manner.

enum HomeFlow: String {
    case dashboard
    case profile
    case settings
} 

enum LoginFlow: String {
    case login
    case registration
}

enum DashboardFlow: String {
    case card
}

enum CardFlow: String {
    case cardDetail
}

The fun part

Now that we had defined several flows, how can we pass the deeplink from AppFlow to other child flows? How do we now that cardDetail belongs to CardFlow, or that we have to go through

AppFlow → HomeFlow → DashboardFlow → CardFlow to show CardDetailVC?

Recursion

We can use a simple recursion with custom inits for the flows. Something like this


enum AppFlow: String {
    case home
    case login

    init?(deeplink: String) {
        if let flow = AppFlow(rawValue: deeplink) {
            self = flow
            return
        }

        if HomeFlow(deeplink: deeplink) != nil {
            self = .home
        } else if LoginFlow(deeplink: deeplink) != nil {
            self = .login
        } else {
            return nil
        }
    }
}

enum HomeFlow: String {
    case dashboard
    case profile
    case settings

    init?(deeplink: String) {
        if let flow = HomeFlow(rawValue: deeplink) {
            self = flow
            return
        }

        if DashboardFlow(deeplink: deeplink) != nil {
            self = .dashboard
        } else if ProfileFlow(deeplink: deeplink) != nil {
            self = .profile
        } else if SettingsFlow(deeplink: deeplink) != nil {
            self = .settings
        } else {
            return nil
        }
    }
}

...

enum CardFlow: String {
    case cardDetail

    init?(deeplink: String) {
        if let flow = CardFlow(rawValue: deeplink) {
            self = flow
        } else {
            return nil
        }
    }
}

Pay attention to init parts, there are differences between rawValue and deeplink

Let’s walk through how our flow logic should work right now.

Now given the route AppFlow → HomeFlow → DashboardFlow → CardFlow, we can go through each corresponding coordinator and handle it separately.

In AppCoordinator we will initialize Appflow(deeplink: "cardDetail"). By the above-defined recursion, it should return .home case, so we know that we should push the HomeCoordinator.

We will do the same in HomeFlow(deeplink: "cardDetail") and it should return .dashboard case. Our HomeCoordinator has a UITabBarController so it will select the Dashboard. After that, we pass the deeplink deeper into the DashboardCoordinator

We will repeat the above step for the Dashboard and now finally in CardCoordinator we initialize CardFlow(deeplink: "cardDetail") and push the corresponding CardDetailVC

Real application

I am going to try this out with reactive approach using Combine. So our base Coordinator part with deeplink handling now looks like this

class Coordinator {
    let deeplinkSubject = CurrentValueSubject<String?, Never>(nil)
    var deeplinkDisposeBag = Set<AnyCancellable>()

    func resetDeeplink() {
        for child in childCoordinators {
            child.deeplinkDisposeBag = Set<AnyCancellable>()
        }
        deeplinkSubject.send(nil)
    }

    func addChild(_ coordinator: Coordinator) {
        deeplinkSubject
            .subscribe(coordinator.deeplinkSubject)
            .store(in: &coordinator.deeplinkDisposeBag)

        childCoordinators.append(coordinator)
    }
}

We are passing deeplinks deeper into child coordinators by binding deeplinkSubjects in addChild function.

Note that we used CurrentValueSubject (BehaviorSubject in RxSwift) instead of PassthroughSubject (PublishSubject in RxSwift) because in the time the deeplinkSubject gets emitted, we may not have our child Coordinators initialized.

We have to resetDeeplink after we process the deeplink. That should solve the cases when the deeplink gets emitted again and we are already deeper in the navigation.

In this implementation, we will always reset the navigation when the deeplink gets emitted.

The actual implementation in the coordinators:

// AppCoordinator
private func bindDeeplink() {
    deeplinkSubject
        .unwrap()
        .map(AppFlow.init(deeplink:))
        .unwrap()
        .receive(on: DispatchQueue.main)
        .sink { [weak self] deeplink in
            guard let self = self else { return }
            switch deeplink {
            case .home:
                self.setHome()
            case .login:
                self.setLogin()
            }
            self.resetDeeplink()
        }.store(in: &disposeBag)
}

private func setHome() {
    let homeCoordinator = HomeCoordinator(router: router, navigationType: .newFlow(hideBar: true))
    setRootChild(coordinator: homeCoordinator, hideBar: true)
}

private func setLogin() {
    let loginCoordinator = LoginCoordinator(router: router, navigationType: .newFlow(hideBar: false), userManager: userManager)
    setRootChild(coordinator: loginCoordinator, hideBar: false)
}

// HomeCoordinator
private func bindDeeplink() {
    deeplinkSubject
        .unwrap()
        .map(HomeFlow.init(deeplink:))
        .unwrap()
        .receive(on: DispatchQueue.main)
        .sink { [weak self] deeplink in
            guard let self = self else { return }
            switch deeplink {
            case .dashboard:
                self.tabBarController.selectedViewController = self.dashboardCoordinator.toPresentable()
            case .profile:
                self.tabBarController.selectedViewController = self.profileCoordinator.toPresentable()
            case .settings:
                self.tabBarController.selectedViewController = self.settingsCoordinator.toPresentable()
            }
            self.resetDeeplink()
        }.store(in: &disposeBag)
}

// DashboardCoordinator
private func bindDeeplink() {
    deeplinkSubject
        .unwrap()
        .map(DashboardFlow.init(deeplink:))
        .unwrap()
        .receive(on: DispatchQueue.main)
        .sink { [weak self] deeplink in
            guard let self = self else { return }
            switch deeplink {
            case .card:
                self.showCard(animated: false)
            }
            self.resetDeeplink()
        }.store(in: &disposeBag)
}

private func showCard(animated: Bool = true) {
    let cardCoordinator = CardCoordinator(router: router, navigationType: .currentFlow)
    pushChild(coordinator: cardCoordinator, animated: animated)
}

Other dependencies

There will often be times when you need to fetch and load some data to continue or that you need to wait for some other asynchronous operations (eg. wait when the user logs in). That is where the reactive approach comes in handy. You can apply all sorts of operations (combineLatest, zip, withLatestFrom, flatMap, etc.) to those signals and then process them when everything is loaded.

You can find a complete example here.