Coordinators provide a smart Swifty way to extract all the logic related to view controller creation and their navigation to a separate layer. It helps to maintain thin view controllers, taking away one of their usual responsibilities since they can grow in size very easily and become very complicated to wrap your head around. There are several popular implementations, but we will stick with this one.
protocol Coordinator: AnyObject {
var childCoordinators: [Coordinator] { get set }
func start()
}
var childCoordinators
will contain child coordinators, and we will take a look at them later. func start()
, you guessed it right, will start the flow and do all the needed setup. Usually the protocol will also be extended with:
protocol NavigationControllerCoordinator: Coordinator {
var navigationController: UINavigationController { get }
}
...since a coordinator is associated with a navigation component. A typical coordinator can be implemented as:
class LoginCoordinator: NavigationControllerCoordinator {
func showMainDashboard() { ... }
func cancel() { ... }
var navigationController: UINavigationController = UINavigationController()
var childCoordinators: [Coordinator] = []
func start() {
let signInViewController = makeSignInViewController()
signInViewController.coordinator = self
navigationController.setViewControllers(
[signInViewController],
animated: false
)
}
func makeSignInViewController() -> SignInViewController { … }
}
How will it look like inside our view controllers?
class SignInViewController: UIViewController {
weak var coordinator: LoginCoordinator?
}
Although this works okay, it creates a strong bond between LoginCoordinator
and SignInViewController
. Imagine a situation where SignInViewController
is shared with another coordinator in a different flow. It turns out incompatible because the coordinator type is no longer LoginCoordinator
. Meaning, we can do better — by defining LoginCoordinating
and changing weak var coordinator LoginCoordinator?
property to LoginCoordinating
.
protocol LoginCoordinating: AnyObject {
func showMainDashboard()
func cancel()
}
class SignInViewController: UIViewController {
weak var coordinator: LoginCoordinating?
}
However, I still have a feeling that SignInViewController
sort of knows what is coming next in the login flow. What if we add a little bit of abstraction, reversing the idea of this protocol so that it accepts events generated by SignInViewController
rather than exposing the LoginCoordinator
functionality.
protocol SignInViewControllerEventHandling: AnyObject {
func handle(event: SignInViewController.Event)
}
class SignInViewController: UIViewController {
enum Event {
case login
case dismiss
}
weak var coordinator: SignInViewControllerEventHandling?
}
In this case, LoginCoordinator
conforms to SignInViewControllerEventHandling
and handles all the events coming from the view controller, whereas SignInViewController
just calls its coordinator using coordinator?.handle(event: .login)
and has no information about hierarchy changes. The view controller can even pass data using enum's associated type. The best part is that every coordinator conforming to SignInViewControllerEventHandling
can now manage SignInViewController
by pushing or presenting new screens on top of it.
Some people will argue that this approach creates huge switches inside coordinators managing multiple screens, and this is partially true. Technically speaking, creating a separate method for each event inside the protocol is faster than having a single handle(event:)
, but sometimes a coordinator manages only a subset of events that are produced by a view controller, which can be elegantly handled in a switch. Having many unused methods requires empty implementations in order to conform to the protocol, which is a lot of useless code. In the end, I’d say it’s a matter of personal preference.
It is also possible to define a helper protocol that all view controllers conform to; however, it only enforces creating a coordinator
property of any type.
public protocol Coordinated: AnyObject {
associatedtype Coordinator
var coordinator: Coordinator? { get set }
}
Let's rewind back to child coordinators and var childCoordinators
in the Coordinator
protocol. Application hierarchy consists of many screens and flows; hence there are multiple coordinators. How can we take advantage of this and express flow dependencies using child coordinators? Well, as soon as the new flow starts, we’ll be adding the new coordinator to childCoordinators
.
class MainCoordinator: TabBarControllerCoordinator {
func showSettings() {
let settingsCoordinator = SettingsCoordinator()
settingsCoordinator.start()
childCoordinators.append(settingsCoordinator)
tabBarController.present(
settingsCoordinator.navigationController,
animated: true
)
}
}
This creates a tree-like structure with AppCoordinator
in a root, which becomes a map of the app at any given time. While adding child coordinators seems like a simple task, removing them is a bit tricky. In a nutshell, there has to be a way for each coordinator to delete itself from the parent's childCoordinators
. We can utilize the same idea used for view controllers by defining a new protocol called ParentCoordinated
.
protocol ParentCoordinated: AnyObject {
associatedtype Parent
var parent: Parent? { get set }
}
Then our coordinator can send special events to its parent.
protocol LoginCordinatorEventHandling: AnyObject {
func handle(event: LoginCoordinator.Event)
}
class LoginCoordinator: NavigationControllerCoordinator, ParentCoordinated {
enum Event {
case dismiss(coordinator: Coordinator)
}
weak var parent: LoginCordinatorEventHandling?
func dismiss() {
parent?.handle(event: .dismiss(coordinator: self))
}
}
class AppCoordinator: LoginCordinatorEventHandling {
func handle(event: LoginCoordinator.Event) {
switch event {
case .dismiss(let coordinator):
childCoordinators.removeAll { $0 === coordinator }
navigationController.dismiss(animated: true)
}
}
}
Although we have almost saved ourselves from memory leaks, there should be a place inside view controllers where we send a dismiss event once a hierarchy needs to change. I can see three common scenarios:
- View controller was presented modally
- View controller was pushed with a different coordinator on the same navigation stack
- View controller is shown via a custom transition
In case of modals, the best place to put coordinator?.handle(event: .dismiss)
will be right before the view controller should be dismissed, like in IBAction
or a button's target.
What about iOS 13 modals can be dismissed by a pull, you might ask? There is no silver bullet, but you can implement func presentationControllerDidDismiss(_ presentationController: UIPresentationController)
from UIAdaptivePresentationControllerDelegate
and then assign the navigationController.presentationController?.delegate
to handle it. A custom navigation controller or a coordinator are good candidates for the job.
The second case requires overriding the back button or making use of isMovingFromParent
in viewDidDisappear
, where the event is passed to the coordinator and then forwarded to the parent for coordinator deletion.
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if isMovingFromParent {
coordinator?.handle(event: .dismiss)
}
}
You can also take a look at func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool)
from UINavigationControllerDelegate
and navigationController.transitionCoordinator?.viewController(forKey: .from)
to determine whether the view controller is still present in the hierarchy and delete its coordinator. This approach will handle everything in one place without adding any code to the view controllers, but it will be triggered for every push/pop, making it somewhat less performant.
The last case should be handled once a dismiss transition is over, so it will heavily depend on the implementation.
BONUS: PRACTICAL ADDITIONS
Once everything is in place, coordinators become routes to every part of your app, making them very convenient for deep linking. You can extend the Coordinator
protocol with a method handling a deep link which allows to pass it from AppDelegate/SceneDelegate
to a final screen via AppCoordinator
and its subsequent children.
protocol Coordinator: AnyObject {
var childCoordinators: [Coordinator] { get set }
func start()
func handle(deeplink: Deeplink)
}
Coordinators are also a great starting point for A/B testing whenever a new flow or a screen should be shown based on a particular condition. You can even have a distinct coordinator to abstract the differences of the new flow.
class LoginCoordinator: NavigationControllerCoordinator {
func showOnboarding() {
guard CapabilityService.isEligibleForNewOnboarding else {
navigationController.present(OldOnboardingViewController(), animated: true)
return
}
navigationController.present(NewOnboardingViewController(), animated: true)
}
}
Since one of the coordinator tasks is view controller creation it can provide shelter for your dependency injection container or maybe a factory instance, if you are using one. Last but not least, we can count the number of apps you have seen that used the NSNotificationCenter
API to send notifications to remote parts of the app in order to increment some badges or propagate logout events. I’d bet it’s been a few.
Now you can have a typesafe technique that will distribute a payload to the farthest island screen in your app.
Thank you for sticking with me until the end. I hope you found something inspiring in all the information!
P.S. Check out a sample app showcasing this pattern at https://github.com/arkhigleb/coordinators.