A comprehensive guide to Coordinator Pattern in Swift

You can download the source code from https://github.com/maysamsh/coordinator

In the past two years I worked on projects with complex navigation, things like nested screens, presenting modal screens with custom transitions, deep-linking, etc. Now that I’ve been laid off I can finally write about it. In the early days of one of our projects I was looking for a flexible solution for the navigation and I came across the Coordinator Pattern. The concept is simple, instead of using segues you create a flexible set of classes and protocols to take over app’s flow. During the past two years I hammered the original concept to support advanced features which will be explained in the article. There are lots of tutorials and articles but here I am trying to demonstrate a practical demo with supporting multiple parent coordinators and an example of sharing a view controller between multiple parent coordinators.

Before diving into the code I’ll briefly explain what this demo is doing. In this app we have a root coordinator which sits at the highest level of hierarchy. Then there are 6 other coordinators, two of which have their own children and one is shared between the RootCoordinator and MerchCoordinator.

Here is a visual representation of the coordinators hierarchy:

Coordinators

As you can see here, RootCoordinator has 6 direct decedents. Later you will see that Auth and Merch coordinators have their own children, isolated from the rest. AuthCoordinator encloses the view controllers for authenticating the user, login, signup, etc. One distinct feature of this coordinator is the user could have only once instance of it running, it covers the entire screen with a top-to-bottom transition. MerchCoordinator, however, could be instantiated anywhere and multiple times, but an extra effort is required to make sure all of its children are de-initialized properly when the screens are dismissed.

Demo

I’d recommend downloading the source code and running it, looking around the code for some time before reading the rest of this article, because it won’t be a step by step tutorial, I’ll only explain important bits and I’d assume you already know what’s the gist of it.

We will have three protocols, a Coordinator which is the base one, ParentCoordinator and ChildCoordinator. Coordinator will have a UINavigationController property and two functions, one to start the coordinator and another one to pop it from the stack, we will call them start(animated:) and popViewController(animated:, useCustomAnimation:, transitionType:) respectively:

protocol Coordinator: AnyObject {
    /// The navigation controller for the coordinator
    var navigationController: UINavigationController { get set }
    
    /**
     The Coordinator takes control and activates itself.
     - Parameters:
        - animated: Set the value to true to animate the transition. Pass false if you are setting up a navigation controller before its view is displayed.
     
    */
    func start(animated: Bool)
    
    /**
        Pops out the active View Controller from the navigation stack.
        - Parameters:
            - animated: Set this value to true to animate the transition.
     */
    func popViewController(animated: Bool, useCustomAnimation: Bool, transitionType: CATransitionType)
}

Coordinator is inheriting from AnyObject which will help us to identify objects for a variety of operations such as adding or removing them from the stack. In the next step we will add two additional functions and a default implementation for popping the view controller from the stack:

extension Coordinator {
    /**
     Pops the top view controller from the navigation stack and updates the display.
     
     - Parameters:
        - animated: Set this value to true to animate the transition.
        - useCustomAnimation: Set to true if you want a transition from top to bottom.
     */
    func popViewController(animated: Bool, useCustomAnimation: Bool = false, transitionType: CATransitionType = .push) {
        if useCustomAnimation {
            navigationController.customPopViewController(transitionType: transitionType)
        } else {
            navigationController.popViewController(animated: animated)
        }
    }
    
    /**
     Pops view controllers until the specified view controller is at the top of the navigation stack.
     - Parameters:
        - ofClass: The view controller that you want to be at the top of the stack. This view controller must currently be on the navigation stack.
        - animated: Set this value to true to animate the transition.
     */
    func popToViewController(ofClass: AnyClass, animated: Bool = true) {
        navigationController.popToViewController(ofClass: ofClass, animated: animated)
    }
    
    /**
    Pops view controllers until the specified view controller is at the top of the navigation stack.
     
    - Parameters:
       -  viewController: The view controller that you want to be at the top of the stack. This view controller must currently be on the navigation stack.
       - animated: Set the value to true to animate the transition.
       - useCustomAnimation: Set to true if you want a transition from top to the bottom.
    */
    func popViewController(to viewController: UIViewController, animated: Bool, useCustomAnimation: Bool, transitionType: CATransitionType = .push) {
        if useCustomAnimation {
            navigationController.customPopToViewController(viewController: viewController, transitionType: transitionType)
        } else {
            navigationController.popToViewController(viewController, animated: animated)
        }
    }
}

Now that we have the Coordinator we can create ParentCoordinator and ChildCoordinator protocols. There are certain features for each one of these two, a parent can have children, then it needs to add a new child to the array and remove them after they are not needed. A child should tell to its parent when it’s done its job and could keep a weak reference to its view controller which will be used to clean up later.

/// All the top-level coordinators should conform to this protocol
protocol ParentCoordinator: Coordinator {
    /// Each Coordinator can have its own children coordinators
    var childCoordinators: [Coordinator] { get set }
    /**
     Adds the given coordinator to the list of children.
     - Parameters:
        - child: A coordinator.
     */
    func addChild(_ child: Coordinator?)
    /**
     Tells the parent coordinator that given coordinator is done and should be removed from the list of children.
     - Parameters:
        - child: A coordinator.
     */
    func childDidFinish(_ child: Coordinator?)
}

extension ParentCoordinator {
    //MARK: - Coordinator Functions
    /**
     Appends the coordinator to the children array.
     - Parameters:
     - child: The child coordinator to be appended to the list.
     */
    func addChild(_ child: Coordinator?){
        if let _child = child {
            childCoordinators.append(_child)
        }
    }
    
    /**
     Removes the child from children array.
     - Parameters:
     - child: The child coordinator to be removed from the list.
     */
    func childDidFinish(_ child: Coordinator?) {
        for (index, coordinator) in childCoordinators.enumerated() {
            if coordinator === child {
                childCoordinators.remove(at: index)
                break
            }
        }
    }
}

/// All Child coordinators should conform to this protocol
protocol ChildCoordinator: Coordinator {
    /**
     The body of this function should call `childDidFinish(_ child:)` on the parent coordinator to remove the child from parent's `childCoordinators`.
     */
    func coordinatorDidFinish()
    /// A reference to the view controller used in the coordinator.
    var viewControllerRef: UIViewController? {get set}
}

Now that we have our coordinators, we can initiate the app Storyboard-free. For this demo I created a tabbed screen app with three tabs called Green, Blue, Lavender. In the following chart you can see the relation between screen. On the most left-hand side you have the tabs.

Tabs and screens

I will create a RootCoordinator which is the entry point of the app, from there other coordinators will be accessed.

import Foundation
import UIKit

final class RootCoordinator: NSObject, Coordinator, ParentCoordinator {
    var childCoordinators = [Coordinator]()
    var navigationController: UINavigationController
    /// A reference to `BaseTabBarController` to hide its navigation controller after dismissing Auth screens
    var baseTabBarController: BaseTabBarController?
    
    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }
    
    func start(animated: Bool) {
        baseTabBarController = BaseTabBarController(coordinator: self)
        baseTabBarController!.coordinator = self
        navigationController.pushViewController(baseTabBarController!, animated: animated)
    }
}

Since we are not using storyboard we have to use dependency injection to assign the coordinator to our TabBarViewController which is called BaseTabBarController here:

final class BaseTabBarController: UITabBarController {
    weak var coordinator: RootCoordinator?
    
    init(coordinator: RootCoordinator) {
        self.coordinator = coordinator
        super.init(nibName: "BaseTabBarController", bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) is not supported")
    }
    
    override func viewDidLoad() { 
        // Initializing variables 
    }
}

Now that we don’t have any storyboard, we have to remove it from the target settings, make sure it’s empty:

Targets > App Icons and Launch Screen > Launch Screen File:

Since I’m using Xcode 14 and Swift 5, the main entry of the app is SceneDelegate and that’s where we should tell the app what to load and how to load it. Here is an article about it in details. In SceneDelegate.swift we add a new variable to keep a reference to the UIWindow object, just in case, then in scene(_ scene:, willConnectTo:, options:) method we create our coordinator and window:

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
    // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
    // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
    
    // Thanks to Marks Truzinski for his article on using Coordinator with Scene Delegate
    // https://markstruzinski.com/2019/08/using-coordinator-with-scene-delegates/
    guard let windowScene = (scene as? UIWindowScene) else {
        return
    }
    
    /// make sure to remove "Storyboard" item from info plist
    let appWindow = UIWindow(frame: windowScene.coordinateSpace.bounds)
    appWindow.windowScene = windowScene
    
    let navigationController = UINavigationController()
    navigationController.setNavigationBarHidden(true, animated: false)
    let coordinator = RootCoordinator(navigationController: navigationController)
    
    // An instance of RootCoordinator takes control of the app flow
    coordinator.start(animated: false)
    
    appWindow.rootViewController = navigationController
    appWindow.makeKeyAndVisible()
    
    window = appWindow
}

In this article I won’t go into the details of the UI or view controllers, you can study them in the source code, but I felt I needed to mention this one.

When a coordinator is started and children added to it, the owner needs to make sure when the coordinator is done the children are removed and the resources associated with them are de-initialized. If you look at the GreenViewController or the chart above you will see that it’s connected to the BlueViewController which means it will start MerchCoordinator as well if the user proceeds, hence needs cleaning up later on. In our app we start MerchCoordinator from two different places, GreenViewController and BlueViewController.

When you are using a tabbed app, tapping twice on a tab will dismiss all the navigation controllers in the stack and go back to the root screen, and by twice I don’t mean double tab gesture, the second tap could be much longer after the first one. The implications of this native behaviour for our app is when user taps twice on either of Blue or Green tabs all the stacked view controllers will disappear but cleaning up the resources is up to us, otherwise if you look at the memory graph you will see multiple instances of hidden view controllers are still alive in the heap memory.

Memory Graph of the app which does not handle cleaning up resources

The highlighted items are the ones which were supposed to be released or had one or two instances live in the memory, not 11 (ProductsViewController) or 4 (MerchCoordinator).

Since the BlueViewController is one of the tabs, it could always have an instance of MerchCoordinator connected to it at all times. For this reason I have added an instance of MerchCoordinator to BaseTabBarController so it will be present as long as the app is present, and for other occasions, from GreenViewController in this case, a new instance of MerchCoordinator will be initialized. To distinguish between these two cases I added a parameter to the function which is responsible for instantiating the MerchCoordinator and called it useTheMainMerchCoordinator in our RootCoordinator:

func products(useTheMainMerchCoordinator: Bool) {
        var topNavigationController: UINavigationController?
        topNavigationController = UIApplication.shared.topNavigatioinController()
        
        if let topNavigationController {
            if useTheMainMerchCoordinator {
                for child in childCoordinators {
                    if child is MerchCoordinator {
                        let merchCoordinator = child as! MerchCoordinator
                        /// If you want to use the main navigation controller, you can use `navigationController` which will replace the entire screen
                        /// If you want this behaviour, you can get rid of extra `setNavigationBarHidden(_ ,animated:)` calls
                        merchCoordinator.products(navigationController: topNavigationController, animated: true)
                    }
                }
            } else {
                let newMerchCoordinator = MerchCoordinator(navigationController: UINavigationController())
                addChild(newMerchCoordinator)
                newMerchCoordinator.products(navigationController: topNavigationController, animated: true)
            }
        }
    }

In this demo app I decided to show the merchandise related screens within the active navigation controller as opposed to covering the entire screen so the user could have them in more than one tab at the a time. If I wanted to cover the entire screen (it would have pushed the entire BaseTabBarController) I would used self.navigationController instead of getting the top most one.

As you can see in the above code, when useTheMainMerchCoordinator is true the existing MerchCoordinator which was defined in BaseTabBarController will be used, otherwise a new instance of it will be created.

Back to our problem, cleaning up resources, for BlueViewController we can catch when user tapped twice at the tab at its viewDidAppear(animated:) method. There we can handle cleaning up from BaseTabBarViewController, because it’s the one which owns the coordinators. To do so we can call MerchCoordinator‘s dismissMerchScreens():

//  BlueViewController.swift
override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    coordinator?.cleanUpMerch()
    
}

//  BlueCoordinator.swift
func cleanUpMerch() {
    parent?.cleanUpMerch()
}

//  RootCoordinator.swift
func cleanUpMerch() {
    baseTabBarController?.cleanUpMerch()
}

//  BaseTabBarController.swift
func cleanUpMerch() {
    merchCoordinator.dismissMerchScreens()
}

//  MerchCoordinator.swift
func dismissMerchScreens() {
    let lastCoordinator = childCoordinators.popLast()
    for item in childCoordinators.reversed() {
        if item is ChildCoordinator {
            let childCoordinator = item as! ChildCoordinator
            if let viewController = childCoordinator.viewControllerRef as? DisposableViewController {
                viewController.cleanUp()
            }
            childCoordinator.viewControllerRef?.navigationController?.popViewController(animated: false)
            self.childDidFinish(childCoordinator)
        }
    }
    lastCoordinator?.popViewController(animated: true, useCustomAnimation: false)
    navigationController.customPopToRootViewController()
}

The above code gist shows what calls are made in order.

For when the MerchCoordinator is initialized as a new instance, it’s a bit different. Let’s follow it closer. In GreenViewController when user clicks on Instantiate another blue screen… button it will create a new instance of BlueCoordinator and push it to the stack. Now if you run the app and click that Instantiate another blue screen… button and look at the content of RootCoordinator‘s childCoordinators you will have two BlueCoordinators:

[coordinator.AuthCoordinator, coordinator.MerchCoordinator, coordinator.GreenCoordinator, coordinator.BlueCoordinator, coordinator.LavenderCoordinator, coordinator.BlueCoordinator]

The last item is the one we created from the GreenViewController. To dismiss it we need to first dismiss its MerchCoordinator screens then remove it from the array:

//  BaseTabBarController.swift
func cleanUpZombieCoordinators() {
    /// Since the `MerchCoordinator` could be initialized from only two places we can assume every other instance of it
    /// existing inside the `childCoordinators` belongs to the `GreenViewController` and is safe to be removed.
    
    if let currentCoordinators = coordinator?.childCoordinators {
        for item in currentCoordinators {
            let contains = initCoordinators.contains(where: {$0 === item})
            if contains == false {
                /// Dismissing newly `MerchCoordinator` children coordinators
                if let merchCoordinator = item as? MerchCoordinator {
                    merchCoordinator.dismissMerchScreens()
                    coordinator?.childDidFinish(merchCoordinator)
                }
                
                /// Removing the `BlueCoordinator` which was added throught the `GreenViewController`
                if let blueCoordinator = item as? BlueCoordinator, let viewController = blueCoordinator.viewControllerRef as? DisposableViewController {
                    viewController.cleanUp()
                    blueCoordinator.viewControllerRef?.navigationController?.popViewController(animated: false)
                    coordinator?.childDidFinish(blueCoordinator)
                }
            }
        }
    }
}

This method would be called through the reference to BaseTabBarController in RootCoordinator from GreenCoordinator.

Now let’s talk about custom transitions for view controllers. For authentication, I’d like to have user’s full attention so I’d like to cover the entire screen for login/signup. To achieve this we would need to add the transition to the view of the view controller which is about to be presented. For this there is a method in UINavigationController+Ext.swift to add custom transition to pushViewController(_: animated:). This method will be used in the entry point of AuthCoordinator, which is LoginCoordinator:

//  LoginCoordinator.swift

func start(animated: Bool) {
    let loginViewController = LoginViewController()
    viewControllerRef = loginViewController
    loginViewController.coordinator = self
    navigationController.customPushViewController(viewController: loginViewController)
}

Here instead of calling pushViewController(_: animated:) we call customPushViewController(_: animated:) which presents the view controller from top to button and when dismissed, it goes from bottom to top. And for cleaning up, there is a similar mechanism as as for MerchCoordinator to dismiss all of its screens.

The last part i’d like to talk about is sharing view controllers between different coordinators. Here we have one view controller, TextViewerViewController, which is being used in that manner.

//  TextViewerViewController.swift

class TextViewerViewController: UIViewController, DisposableViewController {
    weak var coordinator: ChildCoordinator?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Code here
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        
        if self.isMovingFromParent {
            cleanUp()
        }
    }
    
    func cleanUp() {
        coordinator?.coordinatorDidFinish()
    }
}

The difference here between this view controller and other view controllers is its coordinator is defined as a ChildCoordinator which enables us to use this view controller in any coordinator that conforms to the protocol. Here we have two coordinators which use this view controller:

//  CommonTextCoordinator.swift
final class CommonTextCoordinator: ChildCoordinator {
    var viewControllerRef: UIViewController?
    var parent: ParentCoordinator?
    
    var navigationController: UINavigationController
    private var text: String?
    
    init(text: String?, navigationController: UINavigationController) {
        self.navigationController = navigationController
        self.text = text
    }
    
    func start(animated: Bool) {
        let textViewerViewController = TextViewerViewController()
        textViewerViewController.text = self.text
        viewControllerRef = textViewerViewController
        textViewerViewController.coordinator = self
        navigationController.pushViewController(textViewerViewController, animated: animated)
    }
    
    func coordinatorDidFinish() {
        if let parent = parent as? RootCoordinator {
            parent.childDidFinish(self)
        }
    }
}

//  MessageViewerCoordinator.swift
final class MessageViewerCoordinator: ChildCoordinator {
    var viewControllerRef: UIViewController?
    var parent: ParentCoordinator?
    
    var navigationController: UINavigationController
    private var text: String?
    
    init(text: String?, navigationController: UINavigationController) {
        self.navigationController = navigationController
        self.text = text
    }
    
    func start(animated: Bool) {
        let textViewerViewController = TextViewerViewController()
        textViewerViewController.text = self.text
        viewControllerRef = textViewerViewController
        textViewerViewController.coordinator = self
        navigationController.pushViewController(textViewerViewController, animated: animated)
    }
    
    func coordinatorDidFinish() {
        if let parent = parent as? MerchCoordinator {
            parent.childDidFinish(self)
        }
    }
}

As you can see, the parent in both of these coordinators is defined as ParentCoordinator. For actions like calling childDidFinish you have to make sure the parent has a concrete type using if let as statement.

Feel free to ask your question regarding this article here or on Twitter via https://twitter.com/DevMaysam .

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.