UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

Advanced coordinators in iOS

Child coordinators, navigating backwards, passing data between view controllers, and more.

Paul Hudson       @twostraws

In this article we’ll look at solutions for six common problems people face when adopting the coordinator pattern in iOS apps:

  1. How and when do you use child coordinators?

  2. How do you handle moving back from a navigation controller?

  3. How do you pass data between view controllers?

  4. How do you use tab bar controllers with coordinators?

  5. How do you handle segues?

  6. How do you use protocols or closures instead?

I'll be giving you lots of hands-on code along the way, because I want you to see real-world examples of how these problems are solved.

If you missed my earlier tutorial on the coordinator pattern, you should start there: How to use the coordinator pattern in iOS apps.

 

Prefer video? The screencast below contains everything in this tutorial and more – subscribe to my YouTube channel for more like this.

 

Hacking with Swift is sponsored by RevenueCat

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

How and when to use child coordinators

We’re going to start off by looking at child coordinators. As I explained in the first article, if you have a larger app you can split off functionality into child coordinators: one responsible for creating an account, one responsible for buying a product, and so on.

These all report back to a parent coordinator, which can then continue the flow once the child coordinator has finished. The end result is that we can split up complicated app functionality into smaller, discrete chunks that are easier to work with and easier to re-use.

But the problem is: when do you use these things, and how is it best done?

Let’s try it out with a real project - we’re going to use the same Coordinators project we had at the end of the first video. We already have a buy view controller and a create account view controller, but we’re going to modify it so that buying is handled using a child coordinator.

First, create a new Swift file called BuyCoordinator.swift, and give it this code:

class BuyCoordinator: Coordinator {
    var childCoordinators = [Coordinator]()
    var navigationController: UINavigationController

    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }

    func start() {
        // we'll add code here
    }
}

For its start method, we need to move some of the existing code from our main coordinator across, because that already creates and shows a BuyViewController.

So, open MainCoordinator.swift and move the contents of buySubscription() over to the start() method above, like this:

func start() {
    let vc = BuyViewController.instantiate()
    vc.coordinator = self
    navigationController.pushViewController(vc, animated: true)
}    

Leave the buySubscription() method empty in MainCoordinator.swift – we’ll be coming back to it in a moment.

Assigning self to the coordinator property of BuyViewController isn’t possible right now because it expects a MainCoordinator. To fix this, open BuyViewController.swift and change the property to be a BuyCoordinator:

weak var coordinator: BuyCoordinator?

Back in MainCoordinator, we need to create an instance of the child coordinator and tell it to take over control. This is done by adding three new lines of code to its buySubscription() method:

let child = BuyCoordinator(navigationController: navigationController)
childCoordinators.append(child)
child.start()

That’s all it takes to create a child coordinator and make it take over control, but a more interesting problem is how we handle removing the child coordinator when we come back. We’ll look at more advanced solutions later on, but first let’s look at a simple solution to get us started.

For simpler apps you could treat your child coordinator array as a stack, pushing and popping things as needed. While that works well enough, I prefer to allow coordinators to be added and removed at any point, giving me more of a tree-like structure than a stack.

To make this work we first need to establish communication between BuyCoordinator and MainCoordinator, so the child can tell the parent when work has finished.

The first step is to add a parentCoordinator property to BuyCoordinator:

weak var parentCoordinator: MainCoordinator?

That needs to be weak to avoid a retain cycle, because MainCoordinator already owns the child.

We need to set that when our BuyCoordinator is being created, so please add this to the buySubscription() method of MainCoordinator:

child.parentCoordinator = self

Next, we need a way for BuyViewController to report back when its work has finished. We don’t have any special buttons on there, and we don’t even have any child view controllers that form much of a sequence, so instead we’re going to use this view controller being dismissed as our signal that the buying process has finished.

The easiest way to do this is by implementing viewDidDisappear() in BuyViewController, like this:

override func viewDidDisappear(_ animated: Bool) {
    super.viewDidDisappear(animated)
    coordinator?.didFinishBuying()
}

We need to add that didFinishBuying() method to BuyCoordinator. How you handle this depends on your application flow: if your main coordinator needs to respond specifically to buying finishing – perhaps to synchronize user data, or cause some UI to refresh – then you might implement a specific method to handle that flow.

In this instance, we’re going to write a general method to handle all child coordinators that don’t need any special behavior.

Add this method to BuyCoordinator now:

func didFinishBuying() {
    parentCoordinator?.childDidFinish(self)
}

We can then add childDidFinish() to our main coordinator:

func childDidFinish(_ child: Coordinator?) {
    for (index, coordinator) in childCoordinators.enumerated() {
        if coordinator === child {
            childCoordinators.remove(at: index)
            break
        }
    }
}

That uses Swift’s triple equals operator to find the child coordinator in our array. That only works with classes, and right now in theory our coordinators could be used by structs.

Fortunately, coordinators should always be classes because they need to be shared in lots of places, so we can mark our coordinator protocol as being class-only to make all this code work. Modify Coordinator.swift to this:

protocol Coordinator: AnyObject {

As you can see the trick with child coordinators is to make sure you carve off one discrete chunk of your app for them to handle. This helps you avoid massive coordinators, but also just makes your code easier to follow.

Navigating backwards

Our current solution for navigating backwards to a previous view controller is fine for simple projects, but what happens when you have multiple view controllers being shown in the child coordinator? Well, viewDidDisappear() will be called prematurely, and your coordinator stack will get confused.

Fortunately, there’s a great solution already written for this by Soroush Khanlou, who developed the coordinator pattern in the first place.

In BuyViewController please comment out the viewDidDisappear() method entirely – we don’t need it any more. And in BuyCoordinator comment out didFinishBuying(), because we don’t need that either.

What we’ll do instead is make our main coordinator detect interactions with the navigation controller directly. First, we need to make it conform to the UINavigationControllerDelegate protocol. That’s only possible if also make it a subclass of NSObject.

Modify the definition of MainCoordinator to this:

class MainCoordinator: NSObject, Coordinator, UINavigationControllerDelegate {

Second, we need to ask our navigation controller to tell us whenever a view controller is shown, by making our main coordinator its delegate.

Add this to the start() method:

navigationController.delegate = self

Now we can detect when a view controller is shown. This means implementing the didShow method of UINavigationControllerDelegate, reading the view controller we’re moving from, making sure that we’re popping controllers rather than pushing them, then finally removing the child coordinator.

Add this method to MainCoordinator now:

func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
    // Read the view controller we’re moving from.
    guard let fromViewController = navigationController.transitionCoordinator?.viewController(forKey: .from) else {
        return
    }

    // Check whether our view controller array already contains that view controller. If it does it means we’re pushing a different view controller on top rather than popping it, so exit.
    if navigationController.viewControllers.contains(fromViewController) {
        return
    }

    // We’re still here – it means we’re popping the view controller, so we can check whether it’s a buy view controller 
    if let buyViewController = fromViewController as? BuyViewController {
        // We're popping a buy view controller; end its coordinator
        childDidFinish(buyViewController.coordinator)
    }
}

As you've seen, the reason the Back button is tricky is because it's not triggered by our coordinator. Fortunately, using the UINavigationControllerDelegate protocol can help us monitor those events cleanly.

Passing data between view controllers

Passing data between view controllers seems like it’s harder when working with coordinators, but in practice it’s actually a great way to make sure we aren’t creating hard links between our view controllers.

Note: To follow along, first revert back to the original Coordinators project so you have a clean slate to work with.

In the main storyboard, drag out a segmented control into our main view controller. This will let users select which product they want to buy, so you might fill that with the names of your products.

We need to create an outlet for this segmented control, so please switch to the assistant editor and create one called product.

Now, over in BuyViewController we want to know which subscription product was selected, so add this a property there:

var selectedProduct = 0

That value should be provided when this view controller is created, so open MainCoordinator.swift and modify buySubscription() so that it receives an integer parameter and assigns it straight to the selectedProduct property we just created:

func buySubscription(to productType: Int) {
    let vc = BuyViewController.instantiate()
    vc.selectedProduct = productType
    vc.coordinator = self
    navigationController.pushViewController(vc, animated: true)
}

The final step is pass the selected segmented index up to the coordinator when calling buySubscription() in ViewController.swift.

Modify buyTapped() to this:

@IBAction func buyTapped(_ sender: Any) {
    coordinator?.buySubscription(to: product.selectedSegmentIndex)
}

As you've seen the key here is to remember that one view controller doesn't know the others exist. It's passing data to the coordinator that can then go anywhere - maybe it triggers a network request, maybe it shows a view controller, or maybe it does something else. The coordinator figures out the destination; it decides what the values is receives should mean.

Coordinator tab bar controllers

It’s common to use tab bar controllers in your app as a way of clearly segmenting your app’s functionality. Fortunately, coordinators work really well with them – it’s a cinch to put them together.

You see, effectively, each tab inside your app can be managed by its own main coordinator. For example, in my Unwrap app for learning Swift I have five tabs across the bottom, and each one has its own coordinator: a Learn Coordinator, a Practice Coordinator, a Challenge coordinator, and so on.

There are a number of ways you can code this, and I’m going to show you how I do it.

First, I create a new subclass of UITabBarController called MainTabBarController. This then has properties for each of the coordinators that are used inside its tabs.

We only have one coordinator, so we need only one property. Add this to MainTabBarController now:

let mainCoordinator = MainCoordinator(navigationController: UINavigationController())

Then in its viewDidLoad() method I call start() on each of the coordinators so they set up their basic view controller, then set the viewControllers property of the tab bar controller to be an array of all the tabs, using the navigation controller of each coordinator I’m working with.

Again, we only have one coordinator here, so we’ll use that:

override func viewDidLoad() {
    main.start()
    viewControllers = [main.navigationController]
}

Don’t forget to give each of your view controllers a tab bar item, otherwise you won’t see much in the tab bar.

For example, one way we could do this is in the start() method of MainCoordinator – we might make the main view controller have a favorites icon, like this:

vc.tabBarItem = UITabBarItem(tabBarSystemItem: .favorites, tag: 0)

Now just go back to your AppDelegate, delete the existing coordinator code, and create a new instance of your MainTabBarController class as the rootViewController of the window:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // Override point for customization after application launch.
    window = UIWindow(frame: UIScreen.main.bounds)
    window?.rootViewController = MainTabBarController()
    window?.makeKeyAndVisible()

    return true
}

So, the smart way to handle tab bar controllers is with one coordinator per tab. It keeps the different parts of your app neatly separated, and it means we apply the same techniques we already know.

Handling segues

Segues get added to your storyboard by creating links between view controllers, and either get triggered automatically by iOS or by us calling prepare(for segue:) directly.

The problem is that segues defeat one of the main benefits of coordinators: they force us into a specific application flow that stops us rearranging view controllers freely.

Yes, I commonly use storyboards to design my user interfaces, because they come with benefits such as the ability to design static cells in table views. But I don’t use them to add segues.

Take a look at this example code from Xcode’s default Master-Detail App template:

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    if segue.identifier == "showDetail" {
        if let indexPath = tableView.indexPathForSelectedRow {
            let object = objects[indexPath.row] as! NSDate
            let controller = (segue.destination as! UINavigationController).topViewController as! DetailViewController
            controller.detailItem = object
            controller.navigationItem.leftBarButtonItem = splitViewController?.displayModeButtonItem
            controller.navigationItem.leftItemsSupplementBackButton = true
        }
    }
}

That just hurts my eyes to be honest – it reads the type of segue using a string name then force casts the destination view controller, then injects values. That’s not the kind of code I want in my apps.

The simple truth is: I don't use segues. In storyboards they force us into a fixed application flow, and in code we have to do a lot of typecasting to make them useful – they just don't work well for me.

Using protocols and closures

One thing that confuses folks when using coordinators is how they differ from delegates. The simple truth is that they don’t – they are effectively just a specialized delegate. In fact, if we took one of our view controllers and renamed coordinator to delegate then that would be it.

The advantage to using coordinator for the name is that its clear this is a specialized delegate that handles navigation around our app. You could have several different delegates for a single view controller, all working together to make it work inside our app – it would be confusing if you just used the name delegate. By naming this thing the coordinator, we’re making its role clear: it’s the thing driving our app forward.

Rather than just renaming our coordinator to be a delegate and leaving it there, we have two alternatives. I want to show them to you both here, but please roll back to your original coordinators project again to avoid confusion.

First, we could use protocols instead. This is usually a better idea for larger apps, because it allows us to use different concrete implementations freely.

Right now we have a MainCoordinator class that has buySubscription() and createAccount() methods, so let’s wrap them up in new protocols.

First, create a new file called Buying.swift and give it this code:

protocol Buying: AnyObject {
    func buySubscription()
}

Now make a second protocol called AccountCreating:

protocol AccountCreating: AnyObject {
    func createAccount()
}

We can make MainCoordinator conform to both of those immediately, because it implements those methods:

class MainCoordinator: Coordinator, Buying, AccountCreating {

Now we can update our ViewController class to refer to those protocols rather than a concrete type:

weak var coordinator: (Buying & AccountCreating)?

We don’t care what implements those two protocols, as long as something does – it might be a coordinator, or it might be something else. Using protocols is really useful when working in large apps where you want much more flexibility, because you can add extra protocols freely, swap in different coordinators for A/B testing, and more.

Another option is to remove the concrete coordinator type and remove protocols and use a closure instead. This works well when you have only one or two actions being triggered by a view controller.

To use this approach, start by defining each of our closures as properties on ViewController, like this:

var buyAction: (() -> Void)?
var createAccountAction: (() -> Void)?

You can of course make those take parameters if you want.

We can just call those inside buyTapped() and createAccountTapped(), like this:

@IBAction func buyTapped(_ sender: Any) {
    buyAction?()
}

@IBAction func createAccountTapped(_ sender: Any) {
    createAccountAction?()
}

That’s our view controller updated, so now we just need to provide values for those closures inside MainCoordinator.

First delete the line that assigned ourself to the coordinator property, and instead set buyAction and createAccountAction:

vc.buyAction = { [weak self] in
    self?.buySubscription()
}

vc.createAccountAction = { [weak self] in
    self?.createAccount()
}

And that’s it! We still get the same result, but now our view controller has no idea that a coordinator is controlling navigation.

You've now seen how larger apps can benefit from using a protocol, because it allows us to swap out coordinators to whatever we want. In particular, protocol composition means we describe the behavior we want rather than the specific type we want, which is much more flexible.

As for using closures, I think it's a great solution when you have only one or two callbacks to your coordinator, particularly because they mean your view controllers are totally isolated – they don't even know that coordinators exist. They don't scale so well, though – if you find yourself adding three or more closure properties, you might want to switch to a protocol instead.

Where next?

This article answered six common questions people ask me when they adopt coordinators in their app, and I hope you picked up some useful tips along the way.

As always, I want to recommend you read Soroush Khanlou’s blog posts about coordinators – he discusses more solutions there for moving backwards and handling model mutation.

And let me know how you use coordinators – tweet me @twostraws, or subscribe to my YouTube channel.

Hacking with Swift is sponsored by RevenueCat

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

BUY OUR BOOKS
Buy Pro Swift Buy Pro SwiftUI Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Beyond Code

Was this page useful? Let us know!

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.