30. Nov 2021iOS

How does our iOS team use the Coordinator Pattern in Swift?

The coordinator design pattern is nothing new. There are many reasons why it is used. Today I will show you how we use it.

Andrej JaššoiOS Developer

Why:

The design coordinator template helps to divide responsibilities in the application flow. The logic of navigation passes from the view controller to the coordinator, and thus we gain greater control over the navigation flow.

What makes our coordinators different?

Our coordinator is tailored to work with a redux-like one-way flow. I will show you a simple example of a coordinator as we do it and at the end of the article and I will provide you a link to the library which includes GoodCoordinator you can plug into the project via SPM.

Let's start with something simple. First we will make an enum file called Coordinator.swift which will contain an enum with actions that describe type of navigation. It should contain basic actions like push, present, dismiss, pop and the default value none.

import Combine
import UIKit
 
enum StepAction {
 
    case push(UIViewController)
    case present(UIViewController, UIModalPresentationStyle)
    case dismiss
    case pop
    case none
 
}

Then we create a generic Coordinator class. This class will serve as a model for the other coordinators which will inherit from it. It will contain a navigationController that will be inserted in the constructor and it will be optional.

Then we need step and cancellables. They are part of the combine library available from iOS 13.

Step will have a property wrapper published which means that we can subscribe to it and listen for changes similar to if we used didSet closure.

class Coordinator<Step> {

    weak var navigationController: UINavigationController?
    var cancellables: Set<AnyCancellable> = Set()
    @Published var step: Step?

}

We will add the navigate function to the coordinator class, which will serve as an interface for the child coordinatory, but here it will be empty and will do nothing.

Then we define the private function navigate which will perform the standard navigation actions that we defined above the navigation controller and we saved them in the class.

Finally, we will define the public function start, which will serve as an interface for child coordinators and at the same time will listen to the change of the step variable every time it is started, as I mentioned above. Since step is optional we use compactMap to take into account only non-zero values and in sink closure we call our private navigate function.

@discardableResult
    func navigate(to stepper: Step) -> StepAction {
        return .none
    }

    private func navigate(flowAction: StepAction) {
        switch flowAction {
        case .dismiss:
            if let presentedViewController = navigationController?.presentedViewController {
                presentedViewController.dismiss(animated: true, completion: nil)
            } else {
                navigationController?.topViewController?.dismiss(animated: true, completion: nil)
            }

        case .push(let controller):
            navigationController?.pushViewController(controller, animated: true)

        case .pop:
            navigationController?.popViewController(animated: true)

        case .present(let controller, let style):
            controller.modalPresentationStyle = style

            if let presentedViewController = navigationController?.presentedViewController {
                presentedViewController.present(controller, animated: true, completion: nil)
            } else {
                navigationController?.topViewController?.present(
                    controller,
                    animated: true,
                    completion: nil
                )
            }

        case .none:
            break
        }
    }

    @discardableResult
    public func start() -> UIViewController? {
        $step
            .compactMap { $0 }
            .sink { [weak self] in
                guard let self = self else { return }

                self.navigate(flowAction: self.navigate(to: $0))
            }
        .store(in: &cancellables)

        return navigationController
    }

Now let's define some specific navigation actions. Create a file AppStep.swift and write 2 simple cases. Later, in this enum, it is possible to layer the structure on other coordinators, with each case representing one coordinator and the associated value of its AppSteps, but now let's define the simplest case.

enum AppStep {

    case showFirstViewController
    case showSecondViewController

}

Now we have successfully set up parentKoordinator but now let's use it. We will create the final class AppCoordinator, which will inherit from the Coordinator of the AppStep type, which we defined below.

In this coordinator, we define the appwindow. In most practical cases, the logic of windows is solved here because AppCoordinator usually decides whether we are on the Login window or we are already logged in and inside the app. In our case, we will use only one window into which will be automatically selected from the beginning.

We will set the window in inite. And in the start function we will create a navigation controller for it. And we'll show the window.

import Foundation
import UIKit
import Combine


// MARK: - Navigation & Initializers

final class AppCoordinator: Coordinator<AppStep> {

    // MARK: - Properties

    private let appWindow: UIWindow

    // MARK: - Init

    override init() {
        appWindow = UIWindow()
        appWindow.frame = UIScreen.main.bounds
    }

    // MARK: - Overrides

    @discardableResult
    override func start() -> UIViewController? {
        super.start()

        let navigationController = UINavigationController()
        self.navigationController = navigationController
        appWindow.rootViewController = navigationController
        appWindow.makeKeyAndVisible()

        return navigationController
    }

}

Now we are done with the preparation and let's do something in the navigation. We will override the navigate function and create decision logic via a switch above AppStep.

In each appstep we can run the viewController by returning the appstep with the viewcontroller as a parameter. You could also go back .none here and add another navigationController from another coordinator to the navigation controller.

override func navigate(to step: AppStep) -> StepAction {
        switch step {
        case .showFirstViewController(let animated):
            let firstViewController = DocumentBrowserViewController(forOpeningFilesWithContentTypes: ["two", "one"])
            return .push(firstViewController)

        case .showSecondViewController(let animated):
            let secondViewController = DocumentBrowserViewController(forOpeningFilesWithContentTypes: ["hone", "two"])
            return .push(secondViewController)

        default:
            return .none
        }

    }

Okay, and now let's try to integrate it into the app. In the Appdelegate class, we will create the appCoordinator variable and create it when turning on the app. The coordinator is then called to start and now it is enough to set the step to AppStep which is needed.

override func navigate(to step: AppStep) -> StepAction {
        switch step {
        case .showFirstViewController(let animated):
            let firstViewController = DocumentBrowserViewController(forOpeningFilesWithContentTypes: ["two", "one"])
            return .push(firstViewController)

        case .showSecondViewController(let animated):
            let secondViewController = DocumentBrowserViewController(forOpeningFilesWithContentTypes: ["hone", "two"])
            return .push(secondViewController)

        default:
            return .none
        }

    }

All done. We just used a coordinator pattern using combine. The coordinator is very easy to scale and is therefore suitable almost everywhere.

Almost done:

But let's see what it looks like when we are connecting two coordinators. Create a coordinator AboutAppCoordinator.swift

//
//  AboutAppCoordinator.swift
//  CoordinatorArticle
//
//  Created by Andrej Jasso on 27/09/2021.
//

import UIKit

// MARK: - Steps

final class AboutAppCoordinator: Coordinator<AppStep> {


    // MARK: - Overrides

    override func navigate(to step: AppStep) -> StepAction {
        switch step {
        default:
            return .none
        }
    }

    @discardableResult
    override func start() -> UIViewController? {
        super.start()

        let controller = UIViewController()
        let navigationController = UINavigationController()
        navigationController.viewControllers = [controller]
        controller.view.backgroundColor = .green

        return navigationController
    }
    

}

We now present this coordinator via the AppCoordinator step by replacing the content of the second step logic

case .showSecondViewController(let animated):
	let secondViewController = AboutAppCoordinator().start()
  guard let secondViewController = secondViewController else {
		return .none
	}
	return .present(secondViewController, .automatic)

All done. We have successfully gone through the process of coordinators. With this guide, you should be able to create a functional project navigation through the coordinators.

You can find the finished sample at my GitHub

You can find the GoodIOSExtensions library with GoodCoordinator at GoodRequest GitHub.

Andrej JaššoiOS Developer