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 |
---|---|
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.
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
!
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.