fbpx
How to Use a Coordinator Pattern to Separate Concerns in iOS
Share on linkedin
Share on twitter
Share on facebook
Share on email
Share on print

 

Let’s talk about view controllers. Right off the hop, stop making a mess of your view controllers and start putting your code in logical places – view controllers are for views, and views only. It’s very tempting to throw all sorts of logic into a view controller, but when we separate concerns, we write code that’s easier to understand and easier to reuse.

 

There are handfuls of responsibilities we can dump into a view controller: data fetching, data transformation, user input, model-view binding, model mutation, animations, and navigation flow, just to name a few. But, we shouldn’t house all of these responsibilities in one place; otherwise, we’re working with a confusing and unwieldy view controller.

 

This article will focus specifically on navigation and provide an actionable example of using a coordinator pattern to extract navigation flow from view controllers. This article will also touch on how protocols facilitate more concise communication between objects.

 

For this exercise, I teamed up with Clearbridge Mobile iOS Developer, Michael Voline, to learn about separating concerns to create clean, isolated, and reusable view controllers. Credit goes to Michael for writing the code to support this article. If you’d like to skip the step-by-step example, and move right to the full version of the configuration code, there is a GitHub link at the end of the article.

 

Note: There are several ways you can decouple view controllers; however, using a coordinator pattern to handle the navigation logic that doesn’t belong in a view controller is one of many ways to create abstractions and separate concerns.

Don’t Let View Controllers Know About Other View Controllers

A problem that sometimes comes up when creating view controllers is writing the code in such a way that one view controller must be aware of another. What’s worse is that when view controllers are aware of the position of other view controllers in an app’s flow, they have the authority to configure and present other view controllers. What ends up happening is view controllers get tied together in a chain and the navigation flow is spread across multiple objects.

 

What if we need to insert more screens into the flow? We would have to write more configuration code inside the view controllers, further perpetuating the problem.

Coordinators in Action

So, let’s jump in with a solution — if we control the app’s flow with a coordinator pattern, view controllers communicate only with the coordinator and not with each other.

 

The following examples will demonstrate a coordinator pattern that handles the navigation between two very basic view controllers: a login view and sign-up view. Our login view will present the user the option to sign-up and our sign-up view will present the user the option to go back to the login view. For flexibility, we’ll use protocols to set the communication rules for the coordinator to conform to.

 

First, we’ll set up a Coordinator to control the app’s flow in the app delegate like this:

 

@UIApplicationMain
final class AppDelegate: UIResponder, UIApplicationDelegate {

    private let coordinator = AppCoordinator()
    
    func application(_ application: UIApplication,...) -> Bool {
        coordinator.start()
        return true
    }
}

 

Next, let’s write the view controllers for our login view and sign-up view. Here’s the LoginViewController:

 

protocol LoginViewControllerCoordinatorDelegate: AnyObject {
    func didPressSignupButton()
}

final class LoginViewController: UIViewController {
    
    weak var delegate: LoginViewControllerCoordinatorDelegate?
    
    @objc func didPressExitButton() {
        delegate?.didPressSignupButton()
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        let button = UIButton()
               button.addTarget(self, action: #selector(didPressExitButton), for: .touchUpInside)
              view.addSubview(button)
    }
}

 

Notice how we’ve set up a LoginViewControllerCoordinatorDelegate protocol. This will help us limit exposure to the AppCoordinator’s methods, which you will see soon.

 

And, the SignupViewController:

 

import UIKit

protocol SignupViewControllerCoordinatorDelegate: AnyObject {
    func didPressLoginButton()
}

final class SignupViewController: UIViewController {
    
    weak var delegate: SignupViewControllerCoordinatorDelegate?
    
    @objc func didPressButton() {
      delegate?.didPressLoginButton()
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        let button = UIButton()
        button.addTarget(self, action: #selector(didPressButton), for: .touchUpInside)
        view.addSubview(button)
    }
}

 

From here, I’d like to draw your attention to the protocols in each view controller.

 

protocol LoginViewControllerCoordinatorDelegate: AnyObject {
    func didPressSignupButton()
}

 

What we’ve done here is designed a protocol that limits what the LoginViewController has access to in the Coordinator. There are multiple methods and properties inside the Coordinator that the LoginViewController doesn’t need to know; it only needs to know the didPressSignupButton function.

 

Similarly, we’ve designed a protocol for the SignupViewController, that again limits the information the view controller can extract from the Coordinator. The SignupViewController only needs to know didPressLoginButton function and nothing else.

 

protocol SignupViewControllerCoordinatorDelegate: AnyObject {
    func didPressLoginButton()
}

 

By conforming the Coordinator to each of these protocols, we are able to define exact communication rules about what information needs to be conveyed between the Coordinator and our view controllers.

 

Our app coordinator will look something like this:

 

final class AppCoordinator {
    
    private let window: UIWindow
    private let navigation: UINavigationController

    // Please see sample project at the end for full implementation.
    // init() {...}

    func start() {
        let login = LoginViewController()
        login.delegate = self
        navigation.pushViewController(login, animated: true)
    }
}

// MARK: - LoginViewControllerCoordinatorDelegate
extension AppCoordinator: LoginViewControllerCoordinatorDelegate {
    func didPressLoginButton() {
        navigation.popViewController(animated: true)
    }
}

// MARK: - SignupViewControllerCoordinatorDelegate
extension AppCoordinator: SignupViewControllerCoordinatorDelegate {
    func didPressSignupButton() {
        let signup = SignupViewController()
        signup.delegate = self 
        navigation.pushViewController(signup, animated: true)
    }
}

 

The AppCoordinator class contains a navigation property that is used to push and pop view controllers from the navigation stack. In this example, we do not use storyboards and hence we configure window manually (we’ve included a GitHub link for the full version of the configuration code at the end of this article). When we call start() method in AppDelegate, the LoginViewController is pushed onto the navigation stack and the app starts its main flow.

 

For convenience, we used extensions to conform the AppCoordinator to our two protocols. As you can see, both LoginViewController and SignupViewController propagate touch events to AppCoordinator and let it decide what happens next in the app flow.

 

We didn’t create a hard-coded path between our login and sign-up views, rather we’ve extracted navigation logic out of the view controllers and placed it into a separate object responsible for transitioning between screens. Following this pattern helps make it easier to see what’s happening in the flow of the app. Using a coordinator also makes testing easier and allows for reusability.

 

Case and point, each view controller should only know enough to perform its specific action, and nothing else. Save yourself valuable time and effort – keep your view controllers isolated and don’t overcrowd them with code out of convenience

 

You can find the full configuration code on GitHub here.