Introduction to Coordinator pattern in Swift

After some times creating different iOS apps following an MVVM pattern, I’m often not sure how to implement the navigation. If the View handles the rendering and user’s interactions and the ViewModel the service or business logic, where does the navigation sit? That’s where Coordinator pattern takes place.

The best way to introduce Coordinator is probably to introduce the problem it aims to solve. As an iOS developer, we’ve all crossed path with some code where a UIViewController create the next one in the user flow.

class MyViewController : UIViewController {

    // ...
    @IBAction func didTap(_ button: Any) {

        let newViewController = NextViewController()
        self.navigationController?.pushViewController(viewController, animated: true)
    }
}

This works great in simple app with a simple architecture. However, apps evolve, and we need a view model for our NextViewController, it now needs different kind of dependencies to work, something we want to inject at creation. It doesn’t sound right to do that here.

At the same time, the app grows, we’ve got different user flow going to this view controller, as well as the next one. Maybe one requires to have a logged in user. Still doesn’t sound right to check those conditions here.

That’s where Coordinator pattern takes place, keeping a clean architecture and separation of concern.

Coordinator Pattern

Coordinator pattern isn’t something really new, it got introduced in 2015 by Soroush Khanlou while looking for a way to make app more scalable and lighter the ViewController responsibility which handled view logic, business logic as well as user flow logic.

Coordinator responsibility is to handle navigation flow: the same way that UINavigationController keeps reference of its stack, Coordinator do the same with its children.

protocol Coordinator : class {
    var childCoordinators : [Coordinator] { get set }
    func start()
}

We can store new coordinators to our stack and remove those one when the flow has been completed (i.e.: user navigated back, view has been dismissed, etc).

extension Coordinator {

    func store(coordinator: Coordinator) {
        childCoordinators.append(coordinator)
    }

    func free(coordinator: Coordinator) {
        childCoordinators = childCoordinators.filter { $0 !== coordinator }
    }
}

I’m going to create a base class to build my flow coupled with Closures to know when the flow is completed and I need to free the coordinator.

class BaseCoordinator : Coordinator {
    var childCoordinators : [Coordinator] = []
    var isCompleted: (() -> ())?

    func start() {
        fatalError("Children should implement `start`.")
    }
}

That’s about it, we’ve got our first layer ready to use. Let’s create our AppCoordinator to kick off the first step from the AppDelegate.

class AppCoordinator : BaseCoordinator {

    let window : UIWindow

    init(window: UIWindow) {
        self.window = window
        super.init()
    }

    override func start() {
        // preparing root view
        let navigationController = UINavigationController()
        let myCoordinator = MyCoordinator(navigationController: navigationController)

        // store child coordinator
        self.store(coordinator: myCoordinator)
        myCoordinator.start()

        window.rootViewController = navigationController
        window.makeKeyAndVisible()

        // detect when free it
        myCoordinator.isCompleted = { [weak self] in
            self?.free(coordinator: myCoordinator)
        }
    }
}

Now we only need to update the AppDelegate

class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?
    private var appCoordinator : AppCoordinator!

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {


        window = UIWindow()

        let appCoordinator = AppCoordinator(window: window!)
        appCoordinator.start()
        self.appCoordinator = appCoordinator

        return true
    }

Looks great! So we can create and start coordinators for our views and create different flows.

But what’s in my MyCoordinator to see a concrete case?

Sure, let’s see a bit more details about a default coordinator.

class MyCoordinator : BaseCoordinator {

    var navigationController: UINavigationController?

    init(navigationController :UINavigationController?) {
        self.navigationController = navigationController
    }

    override func start() {

        // prepare the associated view and injecting its viewModel
        let viewModel = MyViewModel()
        let viewController = MyViewController(viewModel: viewModel)

        // for specific events from viewModel, define next navigation
        viewModel.didSelect = { [weak self] product in
            guard let strongSelf = self else { return }
            strongSelf.showDetail(product, in: strongSelf.navigationController)
        }

        // if user navigates back, view should be released, so does the coordinator, flow is completed
        viewModel.didTapBack = { [weak self] in
            self?.isCompleted?()
        }

        navigationController?.pushViewController(viewController, animated: true)
    }

    // we can go further in our flow if we need to
    func showDetail(_ product: Product, in navigationController: UINavigationController?) {
        let newCoordinator = NewCoordinator(product: product, navigationController: navigationController)
        self.store(coordinator: newCoordinator)
    }
}

Here is couple things to notice here:

  • the flow is adaptable: AppCoordinator has so far one child which is this MyCoordinator. It can be easy to define another Coordinator if our flow change in the future.
  • the MVVM pattern is respected: View and ViewModel hasn’t been updated, the ViewModel still doesn’t have reference to the view logic or flow logic. If a button is tapped (View), it would trigger a state change (ViewModel) which is observed to navigate further from Coordinator.
  • No memory leak or retain cycle: the current coordinator doesn’t keep reference of its parent and we made sure to call isCompleted when flow is completed to release it.

If not convince by it yet, I found extra use cases where using Coordinator pattern make more sense:

  • Universal link and deep link: when supporting deep link, you might need to open specific view regardless of the navigation history. Coordinator pattern is really helpful to keep things tidy there and avoid creating extra dependencies in the flow
  • A/B testing and feature flag: when you want to trigger different kind of view or a different flow, using Coordinator pattern helps a lot to overload your View layer.
  • Testability: Coordinator pattern is fully testable since it relies to protocol implementation. It can be helpful to create unit test of user flow or bypass specific one during ui tests.

In conclusion, we’ve seen how to implement Coordinator pattern in Swift and that it can work great with an MVVM pattern, respecting single responsibility and separation of concern. It’s a nice approach to test user journey regardless how complex it can be and can help a lot for a/b testing or deep link journey.

What about you? How do you make user flow testable? How do you handle navigation in your iOS app? Let me know in comments.

Happy Coding


Where to go from here

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

Benoit Pasquier

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

ShopBack 💰

Singapore 🇸🇬