Coordinator & MVVM - Clean Navigation and Back Button in Swift

After introducing how to implement Coordinator pattern with an MVVM structure, it feels natural for me to go further and cover some of the blank spots of Coordinator and how to fix along the way.

Before going further, if you didn’t get chance to read about Coordinator pattern in Swift, I would recommend to start here. The following code is based on previous post.

Motivation

If Coordinator pattern has many advantages, it still has one main blank spot, the back button. It doesn’t seem to be a big problem, but not knowing when the user navigates back doesn’t help to deallocate the right coordinator. Re-implementing the back button could be a good alternative, but then user will lose the smooth swipe back if he wants to navigate back: no way I’ll choose easy code over poor UX.

Good thing is there is a third option, although it’s a bit further down the road of effort: create your own stack to keep track of the navigation. It’s sometimes called Router or NavigationController but the idea is the same. Let’s see how clean it becomes.

Implementation

First we need to abstract how to get access to our view we’re going to present

protocol Drawable {
    var viewController: UIViewController? { get }
}

extension UIViewController: Drawable {
    var viewController: UIViewController? { return self }
}

At the same time, I define a Router protocol and its responsibility: a similar approach of a UINavigationController.

typealias NavigationBackClosure = (() -> ())

protocol RouterProtocol: class {
    func push(_ drawable: Drawable, isAnimated: Bool, onNavigateBack: NavigationBackClosure?)
    func pop(_ isAnimated: Bool)
    func popToRoot(_ isAnimated: Bool)
}

After that, the Router class is fairly simple: we keep reference of closures to execute foreach UIViewController pushed forward to deallocate matching Coordinator when navigating back.

The key is to implement UINavigationControllerDelegate protocol to detect when a view pop to execute the matching closure if one exist.

class Router : NSObject, RouterProtocol {

    let navigationController: UINavigationController
    private var closures: [String: NavigationBackClosure] = [:]

    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
        super.init()
        self.navigationController.delegate = self
    }

    func push(_ drawable: Drawable, isAnimated: Bool, onNavigateBack closure: NavigationBackClosure?) {
        guard let viewController = drawable.viewController else {
            return
        }

        if let closure = closure {
            closures.updateValue(closure, forKey: viewController.description)
        }
        navigationController.pushViewController(viewController, animated: isAnimated)
    }

    private func executeClosure(_ viewController: UIViewController) {
        guard let closure = closures.removeValue(forKey: viewController.description) else { return }
        closure()
    }
}

extension Router : UINavigationControllerDelegate {

    func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {

        guard let previousController = navigationController.transitionCoordinator?.viewController(forKey: .from),
            !navigationController.viewControllers.contains(previousController) else {
                return
        }
        executeClosure(previousController)
    }
}

Wait, how do we use this Router with our previous Coordinator classes?

So we’ll need to adapt our Coordinator pattern to include it.

class AppCoordinator : BaseCoordinator {
     // ...

     override func start() {

        let navigationController = UINavigationController()
        let router = Router(navigationController: navigationController)
        let myCoordinator = MyCoordinator(router: router)
        // ...
     }
}

class MyCoordinator : BaseCoordinator {

    let router: RouterProtocol
    init(router: RouterProtocol) {
        self.router = router
    }

    override func start() {
        let viewController = MyViewController()
        // ...
        router.push(viewController, isAnimated: true, onNavigateBack: isCompleted)
    }

With a protocol oriented approach and dependency injection, the Coordinator class is still clean and testable, keeping separation of concern. The Router on its side doesn’t have reference to the Coordinator which is also a good thing to avoid mixing responsibilities.

In short, the Coordinator still prepare Views and ViewModels and can easily reduce complexity of interaction with other Views, the Router will manage only the navigation to it.

Does the Router compatible with Modal display?

It can fit that purpose if you adapt the protocol or create a different one: why not having RouterNavigationProtocol and RouterModalProtocol. Although, dismiss a modal display can’t be done without us noticing, as opposite to the back button so maybe it’s not as necessary.

Apart from the back button, what other benefits can bring the Router ?

It’s going to depend on your code architecture. To me, it felt first like an extra step maybe overkilled, but I quickly see the benefice of maintainability and testability of that approach: we can unit test Coordinator by mocking Router and easily test edge cases without the whole stack navigation.

It also and mainly avoid memory leaks. By keeping an eye on which flow has been completed, it make it easier to test when and where the View+ViewModel and Coordinator should be deallocated.

We could even go further and extend Coordinator to our Drawable protocol.

class MyCoordinator: BaseCoordinator {
    // ...

    lazy var myViewController: MyViewController = {
        let viewController = MyViewController()
        viewController.viewModel = MyViewModel()

        return viewController
    }()

    override func start() {

        myViewController.viewModel.didSelect = { [weak self] product in
            guard let strongSelf = self else { return }
            strongSelf.showDetail(product, in: strongSelf.router)
        }
    }
}
extension MyCoordinator : Drawable {
    var viewController: UIViewController? { return myViewController }
}

Then we’ll update the main AppCoordinator

class AppCoordinator : BaseCoordinator {
    // ...
    override func start() {

        let navigationController = UINavigationController()
        let router = Router(navigationController: navigationController)
        let myCoordinator = MyCoordinator(router: router)

        self.store(coordinator: myCoordinator)
        myCoordinator.start()
        router.push(myCoordinator, isAnimated: true) { [weak self, weak myCoordinator] in
            guard let `self` = self, let myCoordinator = myCoordinator else { return }
            self.free(coordinator: myCoordinator)
        }

        // ...
    }
}

With this approach, we got rid of the closure used in BaseCoordinator to automatically deallocate the Coordinator once the View stack has been deallocated. Although, we trade him against a strong reference between Coordinator and View layer.

There is pro and cons for bot approach: pushing Coordinator over View, I personally prefer pushing Views through the Router over Coordinators because there might be other events that you want to subscribe into the Coordinator for a pop / push navigation. Again, it’s up to each project and developers to find the right solution fitting its problem.

Thanks for reading

© 2023 Benoit Pasquier. All Rights Reserved
Author's picture

Benoit Pasquier

Software Engineer 🇫🇷, writing about career development, mobile engineering and self-improvement

ShopBack 💰

Singapore 🇸🇬