Chapters

Hide chapters

Design Patterns by Tutorials

Third Edition · iOS 13 · Swift 5 · Xcode 11

23. Coordinator Pattern
Written by Joshua Greene

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

The coordinator pattern is a structural design pattern for organizing flow logic between view controllers. It involves the following components:

  1. The coordinator is a protocol that defines the methods and properties all concrete coordinators must implement. Specifically, it defines relationship properties, children and router. It also defines presentation methods, present and dismiss.

    By holding onto coordinator protocols, instead of onto concrete coordinators directly, you can decouple a parent coordinator and its child coordinators. This enables a parent coordinator to hold onto various concrete child coordinators in a single property, children.

    Likewise, by holding onto a router protocol instead of a concrete router directly, you can decouple the coordinator and its router.

  2. The concrete coordinator implements the coordinator protocol. It knows how to create concrete view controllers and the order in which view controllers should be displayed.

  3. The router is a protocol that defines methods all concrete routers must implement. Specifically, it defines present and dismiss methods for showing and dismissing view controllers.

  4. The concrete router knows how to present view controllers, but it doesn’t know exactly what is being presented or which view controller will be presented next. Instead, the coordinator tells the router which view controller to present.

  5. The concrete view controllers are typical UIViewController subclasses found in MVC. However, they don’t know about other view controllers. Instead, they delegate to the coordinator whenever a transition needs to performed.

This pattern can be adopted for only part of an app, or it can be used as an “architectural pattern” to define the structure of an entire app.

You’ll see both of these at work in this chapter: In the Playground example, you’ll call a coordinator from an existing view controller, and in the Tutorial Project, you’ll adopt this pattern across the entire app.

When should you use it?

Use this pattern to decouple view controllers from one another. The only component that knows about view controllers directly is the coordinator.

Consequently, view controllers are much more reusable: If you want to create a new flow within your app, you simply create a new coordinator!

Playground example

Open AdvancedDesignPatterns.xcworkspace in the starter directory, and then open the Coordinator page.

Creating the Router Protocol

First, open Router.swift. This is where you’ll implement the Router protocol.

import UIKit

public protocol Router: class {
  // 1
  func present(_ viewController: UIViewController, 
                animated: Bool)
                
  func present(_ viewController: UIViewController,
               animated: Bool,
               onDismissed: (()->Void)?)
  // 2
  func dismiss(animated: Bool)
}

extension Router {
  // 3
  public func present(_ viewController: UIViewController,
                      animated: Bool) {
    present(viewController, 
            animated: animated, 
            onDismissed: nil)
  }
}

Creating the Concrete Router

You next need to implement the Concrete Router. Open NavigationRouter.swift, and add the following code to it:

import UIKit

// 1
public class NavigationRouter: NSObject {

  // 2
  private let navigationController: UINavigationController
  private let routerRootController: UIViewController?
  private var onDismissForViewController:
    [UIViewController: (() -> Void)] = [:]

  // 3
  public init(navigationController: UINavigationController) {
    self.navigationController = navigationController
    self.routerRootController =
      navigationController.viewControllers.first
    super.init()
  }
}
// MARK: - Router
extension NavigationRouter: Router {

  // 1
  public func present(_ viewController: UIViewController,
                      animated: Bool,
                      onDismissed: (() -> Void)?) {
    onDismissForViewController[viewController] = onDismissed
    navigationController.pushViewController(viewController,
                                            animated: animated)
  }

  // 2
  public func dismiss(animated: Bool) {
    guard let routerRootController = routerRootController else {
      navigationController.popToRootViewController(
        animated: animated)
      return
    }
    performOnDismissed(for: routerRootController)
    navigationController.popToViewController(
      routerRootController,
      animated: animated)
  }

  // 3
  private func performOnDismissed(for
    viewController: UIViewController) {

    guard let onDismiss =
      onDismissForViewController[viewController] else {
      return
    }
    onDismiss()
    onDismissForViewController[viewController] = nil
  }
}
// MARK: - UINavigationControllerDelegate
extension NavigationRouter: UINavigationControllerDelegate {

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

    guard let dismissedViewController =
      navigationController.transitionCoordinator?
        .viewController(forKey: .from),
      !navigationController.viewControllers
        .contains(dismissedViewController) else {
      return
    }
    performOnDismissed(for: dismissedViewController)
  }
}
navigationController.delegate = self

Creating the Coordinator

Your next task is to create the Coordinator protocol. Open Coordinator.swift and add the following to it:

public protocol Coordinator: class {

  // 1
  var children: [Coordinator] { get set }
  var router: Router { get }

  // 2
  func present(animated: Bool, onDismissed: (() -> Void)?)
  func dismiss(animated: Bool)
  func presentChild(_ child: Coordinator,
                    animated: Bool,
                    onDismissed: (() -> Void)?)
}
extension Coordinator {

  // 1
  public func dismiss(animated: Bool) {
    router.dismiss(animated: true)
  }

  // 2
  public func presentChild(_ child: Coordinator,
                           animated: Bool,
                           onDismissed: (() -> Void)? = nil) {
    children.append(child)
    child.present(
      animated: animated,
      onDismissed: { [weak self, weak child] in
        guard let self = self,
          let child = child else {
            return
        }
        self.removeChild(child)
        onDismissed?()
    })
  }
  
  private func removeChild(_ child: Coordinator) {
    guard let index = children.firstIndex(
      where: { $0 === child }) else {
        return
    }
    children.remove(at: index)
  }
}

Creating the concrete coordinator

The last type you need to create is the Concrete Coordinator. Open HowToCodeCoordinator.swift and add the following code, ignoring any compiler errors you get for now:

import UIKit

public class HowToCodeCoordinator: Coordinator {

  // MARK: - Instance Properties
  // 1
  public var children: [Coordinator] = []
  public let router: Router

  // 2
  private lazy var stepViewControllers = [
    StepViewController.instantiate(
      delegate: self,
      buttonColor: UIColor(red: 0.96, green: 0, blue: 0.11,
                           alpha: 1),
      text: "When I wake up, well, I'm sure I'm gonna be\n\n" +
      "I'm gonna be the one writin' code for you",
      title: "I wake up"),

    StepViewController.instantiate(
      delegate: self,
      buttonColor: UIColor(red: 0.93, green: 0.51, blue: 0.07,
                           alpha: 1),
      text: "When I go out, well, I'm sure I'm gonna be\n\n" +
      "I'm gonna be the one thinkin' bout code for you",
      title: "I go out"),

    StepViewController.instantiate(
      delegate: self,
      buttonColor: UIColor(red: 0.23, green: 0.72, blue: 0.11,
                           alpha: 1),
      text: "Cause' I would code five hundred lines\n\n" +
      "And I would code five hundred more",
      title: "500 lines"),

    StepViewController.instantiate(
      delegate: self,
      buttonColor: UIColor(red: 0.18, green: 0.29, blue: 0.80,
                           alpha: 1),
      text: "To be the one that wrote a thousand lines\n\n" +
      "To get this code shipped out the door!",
      title: "Ship it!")
  ]

  // 3
  private lazy var startOverViewController =
    StartOverViewController.instantiate(delegate: self)

  // MARK: - Object Lifecycle
  // 4
  public init(router: Router) {
    self.router = router
  }

  // MARK: - Coordinator
  // 5
  public func present(animated: Bool, 
                      onDismissed: (() -> Void)?) {
    let viewController = stepViewControllers.first!
    router.present(viewController,
                   animated: animated,
                   onDismissed: onDismissed)
  }
}
// MARK: - StepViewControllerDelegate
extension HowToCodeCoordinator: StepViewControllerDelegate {
  
  public func stepViewControllerDidPressNext(
    _ controller: StepViewController) {
    if let viewController =
      stepViewController(after: controller) {
      router.present(viewController, animated: true)
    } else {
      router.present(startOverViewController, animated: true)
    }
  }
  
  private func stepViewController(after
    controller: StepViewController) -> StepViewController? {
    guard let index = stepViewControllers
      .firstIndex(where: { $0 === controller }),
      index < stepViewControllers.count - 1 else { return nil }
    return stepViewControllers[index + 1]
  }
}
// MARK: - StartOverViewControllerDelegate
extension HowToCodeCoordinator:
  StartOverViewControllerDelegate {

  public func startOverViewControllerDidPressStartOver(
    _ controller: StartOverViewController) {
    router.dismiss(animated: true)
  }
}

Trying out the playground example

You’ve created all of the components, and you’re ready to put them into action!

import PlaygroundSupport
import UIKit

// 1
let homeViewController = HomeViewController.instantiate()
let navigationController = UINavigationController(rootViewController: homeViewController)

// 2
let router = NavigationRouter(navigationController: navigationController)
let coordinator = HowToCodeCoordinator(router: router)

// 3
homeViewController.onButtonPressed = { [weak coordinator] in
  coordinator?.present(animated: true, onDismissed: nil)
}

// 4
PlaygroundPage.current.liveView = navigationController

What should you be careful about?

Make sure you handle going-back functionality when using this pattern. Specifically, make sure you provide any required teardown code passed into onDismiss on the coordinator’s present(animated:onDismiss:).

Tutorial project

You’ll build an app called RayPets in this chapter. This is a “pet” project by Ray: an exclusive pets-only clinic for savvy iOS users.

Creating AppDelegateRouter

You’ll first implement a new concrete router. Within the Routers group, create a new file called AppDelegateRouter.swift, and replace its contents with the following:

import UIKit

public class AppDelegateRouter: Router {

  // MARK: - Instance Properties
  public let window: UIWindow

  // MARK: - Object Lifecycle
  public init(window: UIWindow) {
    self.window = window
  }

  // MARK: - Router
  public func present(_ viewController: UIViewController,
                      animated: Bool,
                      onDismissed: (()->Void)?) {
    window.rootViewController = viewController
    window.makeKeyAndVisible()
  }

  public func dismiss(animated: Bool) {
    // don't do anything
  }
}

Creating HomeCoordinator

You next need to create a coordinator to instantiate and display the HomeViewController. Within the Coordinators group, create a new file named HomeCoordinator.swift. Replace its contents with the following, ignoring the compiler error for now:

import UIKit

public class HomeCoordinator: Coordinator {

  // MARK: - Instance Properties
  public var children: [Coordinator] = []
  public let router: Router

  // MARK: - Object Lifecycle
  public init(router: Router) {
    self.router = router
  }

  // MARK: - Instance Methods
  public func present(animated: Bool,
                      onDismissed: (() -> Void)?) {
    let viewController = 
      HomeViewController.instantiate(delegate: self)
    router.present(viewController,
                   animated: animated,
                   onDismissed: onDismissed)
  }
}
// MARK: - HomeViewControllerDelegate
extension HomeCoordinator: HomeViewControllerDelegate {

  public func homeViewControllerDidPressScheduleAppointment(
    _ viewController: HomeViewController) {
    // TODO: - Write this
  }
}

Using HomeCoordinator

You also need to actually use HomeCoordinator. To do so, open AppDelegate.Swift and replace its contents with the following:

import UIKit

@UIApplicationMain
public class AppDelegate: UIResponder, UIApplicationDelegate {

  // MARK: - Instance Properties
  // 1
  public lazy var coordinator = HomeCoordinator(router: router)
  public lazy var router = AppDelegateRouter(window: window!)
  public lazy var window: UIWindow? =
    UIWindow(frame: UIScreen.main.bounds)

  // MARK: - Application Lifecycle
  // 2
  public func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions
    launchOptions: [UIApplication.LaunchOptionsKey: Any]?)
    -> Bool {
    coordinator.present(animated: true, onDismissed: nil)
    return true
  }
}

Creating PetAppointmentBuilderCoordinator

Open Models ▸ PetAppointment.swift, and you’ll see a model and related builder has already been defined: PetAppointment and PetAppointmentBuilder.

import UIKit

public class PetAppointmentBuilderCoordinator: Coordinator {

  // MARK: - Instance Properties
  public let builder = PetAppointmentBuilder()
  public var children: [Coordinator] = []
  public let router: Router

  // MARK: - Object Lifecycle
  public init(router: Router) {
    self.router = router
  }

  // MARK: - Instance Methods
  public func present(animated: Bool,
                      onDismissed: (() -> Void)?) {
    let viewController =
      SelectVisitTypeViewController.instantiate(delegate: self)
    router.present(viewController,
                   animated: animated,
                   onDismissed: onDismissed)
  }
}
// MARK: - SelectVisitTypeViewControllerDelegate
extension PetAppointmentBuilderCoordinator:
  SelectVisitTypeViewControllerDelegate {

  public func selectVisitTypeViewController(
    _ controller: SelectVisitTypeViewController,
    didSelect visitType: VisitType) {

    // 1
    builder.visitType = visitType
    
    // 2
    switch visitType {
    case .well:
      // 3
      presentNoAppointmentViewController()
    case .sick:
      // 4
      presentSelectPainLevelCoordinator()
    }
  }

  private func presentNoAppointmentViewController() {
    let viewController =
      NoAppointmentRequiredViewController.instantiate(
        delegate: self)
    router.present(viewController, animated: true)
  }

  private func presentSelectPainLevelCoordinator() {
    let viewController =
      SelectPainLevelViewController.instantiate(delegate: self)
    router.present(viewController, animated: true)
  }
}
// MARK: - SelectPainLevelViewControllerDelegate
extension PetAppointmentBuilderCoordinator:
  SelectPainLevelViewControllerDelegate {

  public func selectPainLevelViewController(
    _ controller: SelectPainLevelViewController,
    didSelect painLevel: PainLevel) {

    // 1
    builder.painLevel = painLevel

    // 2
    switch painLevel {

    // 3
    case .none, .little:
      presentFakingItViewController()

    // 4
    case .moderate, .severe, .worstPossible:
      presentNoAppointmentViewController()
    }
  }

  private func presentFakingItViewController() {
    let viewController =
      FakingItViewController.instantiate(delegate: self)
    router.present(viewController, animated: true)
  }
}
// MARK: - FakingItViewControllerDelegate
extension PetAppointmentBuilderCoordinator:
  FakingItViewControllerDelegate {

  public func fakingItViewControllerPressedIsFake(
    _ controller: FakingItViewController) {
    router.dismiss(animated: true)
  }

  public func fakingItViewControllerPressedNotFake(
    _ controller: FakingItViewController) {
    presentNoAppointmentViewController()
  }
}
// MARK: - NoAppointmentRequiredViewControllerDelegate
extension PetAppointmentBuilderCoordinator:
  NoAppointmentRequiredViewControllerDelegate {

  public func noAppointmentViewControllerDidPressOkay(
    _ controller: NoAppointmentRequiredViewController) {
    router.dismiss(animated: true)
  }
}

Creating ModalNavigationRouter

You may be wondering, “Couldn’t I just use NavigationRouter from the playground example?”

import UIKit

// 1
public class ModalNavigationRouter: NSObject {

  // MARK: - Instance Properties
  // 2
  public unowned let parentViewController: UIViewController

  private let navigationController = UINavigationController()
  private var onDismissForViewController:
    [UIViewController: (() -> Void)] = [:]

  // MARK: - Object Lifecycle
  // 3
  public init(parentViewController: UIViewController) {
    self.parentViewController = parentViewController
    super.init()
  }
}
// MARK: - Router
extension ModalNavigationRouter: Router {

  // 1
  public func present(_ viewController: UIViewController,
                      animated: Bool,
                      onDismissed: (() -> Void)?) {
    onDismissForViewController[viewController] = onDismissed
    if navigationController.viewControllers.count == 0 {
      presentModally(viewController, animated: animated)
    } else {
      navigationController.pushViewController(
        viewController, animated: animated)
    }
  }

  private func presentModally(
    _ viewController: UIViewController,
    animated: Bool) {
    // 2
    addCancelButton(to: viewController)

    // 3
    navigationController.setViewControllers(
      [viewController], animated: false)
    parentViewController.present(navigationController,
                                 animated: animated,
                                 completion: nil)
  }

  private func addCancelButton(to
    viewController: UIViewController) {
    viewController.navigationItem.leftBarButtonItem =
    UIBarButtonItem(title: "Cancel",
                    style: .plain,
                    target: self,
                    action: #selector(cancelPressed))
  }

  @objc private func cancelPressed() {
    performOnDismissed(for:
      navigationController.viewControllers.first!)
    dismiss(animated: true)
  }

  // 4
  public func dismiss(animated: Bool) {
    performOnDismissed(for:
      navigationController.viewControllers.first!)
    parentViewController.dismiss(animated: animated,
                                 completion: nil)
  }

  // 5
  private func performOnDismissed(for 
    viewController: UIViewController) {
    guard let onDismiss = 
      onDismissForViewController[viewController] else { return }
    onDismiss()
    onDismissForViewController[viewController] = nil
  }
}
// MARK: - UINavigationControllerDelegate
extension ModalNavigationRouter: 
  UINavigationControllerDelegate {

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

    guard let dismissedViewController =
      navigationController.transitionCoordinator?
        .viewController(forKey: .from),
      !navigationController.viewControllers
        .contains(dismissedViewController) else {
      return
    }
    performOnDismissed(for: dismissedViewController)
  }
}
navigationController.delegate = self

Using the PetAppointmentBuilderCoordinator

Fantastic! You’ve created all of the necessary pieces to display the schedule-a-visit flow. You now just need to put them all together.

let router =
  ModalNavigationRouter(parentViewController: viewController)
let coordinator =
  PetAppointmentBuilderCoordinator(router: router)
presentChild(coordinator, animated: true)

Key points

You learned about the coordinator pattern in this chapter. Here are its key points:

Where to go from here?

The coordinator pattern is a great pattern for organizing long-term or very complex apps. It was first introduced to the iOS community by Soroush Khanlou. You can learn more about this pattern’s roots in his blog post about it here:

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now