iOS Coordinators: A Storyboard Approach

There’s been a lot of talk in the iOS development community about the coordinator pattern ever since Soroush Khanlou gave a talk at NSSpain 2015 and presented his interpretation - and rightfully so. It aims to solve common problems plaguing many iOS developers who follow MVC/MVVM architectures but find that their view controllers are taking on too many responsibilities, often resulting in large view controllers that are difficult to debug or comprehend at a glance. Let’s examine the pros and cons of the coordinator pattern, Xcode storyboards, and how to leverage the benefits of both!

Coordinator Pattern

There are numerous benefits to adopting the coordinator pattern. These include:

  • Moving view controller creation, configuration, and presentation away from view controller level and into coordinator level (limit responsibilities of view controllers).
  • View controllers no longer have to tell their navigation controller (parent) to push or present another VC.
  • Enable developers to decouple view controllers so that each one has no idea what view controller came before or comes next – or even that a chain of view controllers exists. Isolating this logic makes the application easier to understand and debug as complexity grows.
  • The coordinator is in charge of managing the view controller hierarchy so that the view controllers and view models only need to be concerned about their particular scenes.
  • Separation of responsibilities (coordinators, child coordinators).
  • Isolate flows of the app so they may be more reusable, testable, and scalable.

There are some downsides that this pattern can bring, however:

  • More code (or at least the same amount as before, but spread among other objects and segmented by their corresponding responsibilities!)
  • Potentially have to rely on multi-level delegation, which can be cumbersome.

As long as we’re aware of these potential downsides and try our best to limit them, then employing the coordinator pattern can serve as a wise choice.

Most examples of the coordinator pattern are with projects that manage the user interface in code, with little or no storyboard usage. At first glance, it may appear storyboards and coordinators don’t seem to play along too nicely - however, this does not have to be the case. There are a lot of benefits with using storyboards, but some downsides as well.

Storyboards

Storyboards in Xcode offer a good deal of benefits, including:

  • Visualization - can glance at storyboard and have a general understanding of the application and its flows
  • Well-supported by Apple
  • Robust suite of visual design tools
  • Friendly to use for designers who are not comfortable designing in code

However, Storyboards do come with their own set of downsides:

  • Merge conflicts can be tricky to resolve
  • Higher level of customization still requires underlying code in order to design via IB
  • Large storyboards can have long parsing processes and slow down development time
  • Large storyboards with segues can quickly become confusing
  • Limited control over navigation / presentation logic, have to rely on wiring up segues and using prepareForSegue

We’ll examine how using the coordinator pattern in tandem with storyboards can help to alleviate some of the downsides of storyboards while leveraging their benefits.

Overview and Setup

Let’s walk through an example application where the user can sign in or sign out. Depending on the complexity of the application, the “signed-in” portion of the app, as well as the “signed-out” portion of the app, can be encapsulated by view controllers or coordinators. For simplicity, we’ll use instances of UIViewController to be the entry point into each flow. These can easily be changed to Coordinator later, which we’ll demonstrate.

We’ll start by creating our two flows in storyboard. We’ll utilize Main.storyboard for the “signed-in” flow, and create a new storyboard file, Auth.storyboard for the “signed-out” flow.

Main.storyboard Auth.storyboard
Main View Controller in Interface Builder Auth View Controller in Interface Builder

We’ll position a UIButton and a UILabel within each view controller and create a corresponding .swift file for each: MainViewController.swift and AuthViewController.swift We’ll then finish by wiring up the @IBAction for each button and create a corresponding delegate protocol for each view controller - allowing them to communicate the user interaction event with another object.

// MainViewController.swift
import UIKit

protocol MainViewControllerDelegate: AnyObject {
  func didSignOut()
}

final class MainViewController: UIViewController {
  weak var delegate: MainViewControllerDelegate?

  override func viewDidLoad() {
    super.viewDidLoad()
  }

  @IBAction func signOutPressed(_ sender: Any) {
    delegate?.didSignOut()
  }
}
// AuthViewController.swift
import UIKit

protocol AuthViewControllerDelegate: AnyObject {
  func didSignIn()
}

final class AuthViewController: UIViewController {
  weak var delegate: AuthViewControllerDelegate?

  override func viewDidLoad() {
    super.viewDidLoad()
  }

  @IBAction func signInPressed(_ sender: Any) {
    delegate?.didSignIn()
  }
}

Introducing Coordinators

Currently, neither of the new view controllers we’ve created actually have a delegate to inform about the sign in and sign out events, which means there’s presently no way to switch between the Main flow and the Auth flow. This is the perfect opportunity for us to introduce the topmost Coordinator. Let’s begin with a simple protocol to define the interface of any Coordinator.

protocol Coordinator {
  func start()
}

The start() function implemented by each type conforming to Coordinator will serve as the entry point for that flow - this means instantiating, configuring, and presenting the necessary view controllers.

Our first implementation will be an AppCoordinator that sits in the window as a property of AppDelegate. This can serve to offload high-level context switches and serve as the entry point to the application after didFinishLaunchingWithOptions is called.

// AppCoordinator.swift

import UIKit

final class AppCoordinator: Coordinator {
  // MARK: - Properties
  private let navController: UINavigationController
  private let window: UIWindow

  // MARK: - Initializer
  init(navController: UINavigationController, window: UIWindow) {
    self.navController = navController
    self.window = window
  }

  func start() {
    window.rootViewController = navController
    window.makeKeyAndVisible()
    showMain()
  }

  // MARK: - Navigation
  private func showMain() {
    let mainVC = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "mainViewController") as! MainViewController
    mainVC.delegate = self
    navController.setViewControllers([mainVC], animated: true)
  }

  private func showAuth() {
    let authVC = UIStoryboard(name: "Auth", bundle: nil)
    .instantiateViewController(withIdentifier: "authViewController") as! AuthViewController
    authVC.delegate = self
    navController.setViewControllers([authVC], animated: true)
  }
}

// MARK: - MainViewControllerDelegate
extension AppCoordinator: MainViewControllerDelegate {
  func didLogOut() {
    User.logout()
    showAuth()
  }
}
// AppDelegate.swift

import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
  var window: UIWindow?
  var app: AppCoordinator?

  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    let window = UIWindow(frame: UIScreen.main.bounds)
    let navController = UINavigationController()
    let appCoordinator = AppCoordinator(navController: navController, window: window)
    app = appCoordinator

    appCoordinator.start()
    return true
  }
}

Here, we instantiate a UIWindow and UINavigationController in the AppDelegate, injecting it into an instance of AppCoordinator that will be a property of the AppDelegate. Finally, we call start to begin actually displaying content.

Within the AppCoordinator, we implement the start function by assigning the window‘s root view controller to our injected navController, make window the key window and show it. Finally, within showMain, we instantiate the MainViewController and set it as the presented controller of the navController. Calling setViewControllers:animated: on the AppCoordinator’s navController will ultimately serve as our way to change flows in the application, deallocating unused coordinators and their subsequent view controllers when necessary - such as an application with an onboarding flow, authentication flow, and a main (authenticated) flow. Each of the aforementioned flows would have their own set of coordinators and child coordinators.

We’ll then conform AppCoordinator to the two delegate protocols we defined earlier - MainViewControllerDelegate and AuthViewControllerDelegate. Conforming to these delegate protocols now allows the touch events from each flow to bubble up to the AppCoordinator, allowing it to perform the necessary navigation action in response.

The final step will be disabling the application from launching into the Main storyboard by leaving “Main Interface” blank.

Disabling the initial storyboard in
Xcode

Build and run the app, and we’ll see we’re able to switch between different flows of the app with ease - all while offloading navigation and view controller setup to our Coordinator!

Animation showing signing in and signing
out

Now that the AppCoordinator is set up, let’s examine one way to use this pattern with Storyboards to actually construct and design our view controllers and views.

Using UIStoryboard extension to init view controllers

The way we are currently instantiating view controllers isn’t optimal. Why? As a developer, we’re required to be aware of which view controller resides in which storyboard. This may be easy right now since the application is very simple, but that can easily change over time. It’s important to think about scalability sooner than later. We also have to rely on knowing the string identifier of the view controller at the call site. This also isn’t ideal.

In this case, one potential solution is to create an extension on UIStoryboard and have it serve as a factory for view controllers. We’ll start by creating static variables to reference all the storyboards in use in the application. Then, we’ll expose static factory functions that return the desired subclass of UIViewController. This results in a method signature that feels very similar to that which we had previously, but removes the unnecessary responsibility of the coordinator/view controller having to have as much context in order to instantiate the desired view controller. Here, we’re using SOLID design principles, like the single responsibility principle, and containing responsibilities to those which are more apt to have context about the particular problem we’re solving.

// UIStoryboard.swift

import UIKit

extension UIStoryboard {
  // MARK: - Storyboards
  private static var main: UIStoryboard {
    return UIStoryboard(name: "Main", bundle: nil)
  }

  private static var auth: UIStoryboard {
    return UIStoryboard(name: "Auth", bundle: nil)
  }

  // MARK: - View Controllers
  static func instantiateMainViewController(delegate: MainViewControllerDelegate) -> MainViewController {
    let mainVC = main.instantiateViewController(withIdentifier: "mainViewController") as! MainViewController
    mainVC.delegate = delegate
    return mainVC
  }

  static func instantiateAuthViewController(delegate: AuthViewControllerDelegate) -> AuthViewController {
    let authVC = auth.instantiateViewController(withIdentifier: "authViewController") as! AuthViewController
    authVC.delegate = delegate
    return authVC
  }
}

We can then refactor showMain() and showAuth() in our AppCoordinator to use this extension.

// MARK: - Navigation
private func showMain() {
  let mainVC = UIStoryboard.instantiateMainViewController(delegate: self)
  navController.setViewControllers([mainVC], animated: true)
}

private func showAuth() {
  let authVC = UIStoryboard.instantiateAuthViewController(delegate: self)
  navController.setViewControllers([authVC], animated: true)
}

Child Coordinators

We’re currently using UIViewControllers as the entry point to each flow. As applications grow in complexity, these view controllers are bound to have their own navigation component. We can refactor showMain and showAuth in our AppCoordinator to instead call start on two new child coordinators: AuthCoordinator and MainCoordinator. These coordinators can then be responsible for handling the navigation within each subsequent flow. For brevity, we’ll just focus on implementing the AuthCoordinator in this post.

// AuthCoordinator.swift

import UIKit

protocol AuthCoordinatorDelegate: AnyObject {
  func didAuthenticate()
}

final class AuthCoordinator: Coordinator {
  private let navController: UINavigationController
  weak var delegate: AuthCoordinatorDelegate?

  init(navController: UINavigationController, delegate: AuthCoordinatorDelegate) {
    self.navController = navController
    self.delegate = delegate
  }

  func start() {
    let authVC = UIStoryboard.instantiateAuthViewController(delegate: self)
    navController.setViewControllers([authVC], animated: true)
  }
}

extension AuthCoordinator: AuthViewControllerDelegate {
  func didSignIn() {
    // Authenticate via API, etc...
    delegate?.didAuthenticate()
  }
}

We’ll create the AuthCoordinator by conforming to the Coordinator protocol we defined earlier. We’ll create an initializer that accepts a UINavigationController along with an object that conforms to a newly-defined protocol, AuthCoordinatorDelegate. The navController injected into AuthCoordinator will be used to mutate the navigation stack when this coordinator wants to start.

Instead of AppCoordinator being the delegate for the AuthViewController, the AuthCoordinator will. Conforming to AuthViewControllerDelegate lets us implement didSignIn, which is where we could perform a network request to authenticate the user - offloading this responsibility to initiate this request from the AuthViewController itself. Depending on the outcome of the authentication request, we could then inform the delegate that authentication was successful via didAuthenticate.

We will then make the necessary changes in AppCoordinator. We start by adding a private variable to retain AppCoordinator’s child coordinators, which for now will be AuthCoordinator. We need to hold a reference to these child coordinators as we do not want them to be deallocated too soon.

private var childCoordinators: [Coordinator] = []

We then modify showAuth to make use of the AuthCoordinator, instead of instantiating the AuthViewController directly.

private func showAuth() {
  let authCoordinator = AuthCoordinator(navController: navController, delegate: self)
  childCoordinators.append(authCoordinator)
  authCoordinator.start()
}

Updating showMain will also be necessary, as we want to release the AuthCoordinator and its children when we switch to the main, signed-in flow of the application. In order to achieve this, we’ll remove all instances of AuthCoordinator from childCoordinators.

private func showMain() {
  let mainVC = UIStoryboard.instantiateMainViewController(delegate: self)
  navController.setViewControllers([mainVC], animated: true)
  childCoordinators.removeAll { $0 is AuthCoordinator }
}

Finally, we’ll no longer conform to AuthViewControllerDelegate, but instead, AuthCoordinatorDelegate. Conforming to this protocol allows the AppCoordinator to change flows of the app depending on the authentication request result within AuthCoordinator.

extension AppCoordinator: AuthCoordinatorDelegate {
  func didAuthenticate() {
    showMain()
  }
}

Wrap Up

We now have an excellent starting point to further develop these flows. Responsibilities of each Coordinator are clearly defined and we’ve prevented navigation and view controller configuration/instantiation logic from entering the view controllers themselves.

The code for this project can be found here.