Swift UIKit Coordinator Pattern: Multiple level coordination: (part 2)

Ario Liyan
11 min readOct 28, 2023

--

In this post, we want to talk about the coordinator pattern for navigating between view controllers in UIKit applications. This is part two of the coordinator pattern series. In this post, we are going to learn about multiple-level coordination in detail. Note that this post is dependant on the part 1.

Table of contents

  • Using child coordinators
  • Navigation backwards
  • Passing data between view controllers
  • Coordinator with tab bar controller
  • Using protocol and closures in place of concrete coordinators

Using child coordinators

The idea is to divide our responsibility to the child coordinator so one would be responsible for purchase action, one for creating an account and etc. These child coordinators report back to their parent coordinator which continues until get to the top coordinator so the process can continue when a child coordinator has finished its job.

The purpose of child coordinators is to break down complex navigation flows into smaller, more manageable pieces. This can make the code more modular, reusable, and testable.

For example, imagine an app with a complex navigation flow that includes a product catalog, a checkout process, and a user account management section. You could use a parent coordinator to manage the overall flow of the app, and then use child coordinators to manage each of the individual sections.

This would allow you to isolate the code for each section of the app, and make it easier to test and maintain. It would also make it easier to reuse the code for each section in other parts of the app, or even in other apps.

Here are some of the benefits of using child coordinators in the Coordinator pattern:

  • Modularity: Child coordinators allow you to break down complex navigation flows into smaller, more manageable pieces. This can make the code more modular and easier to understand.
  • Reusability: Child coordinators can be reused in different parts of an app, or even in different apps. This can save you time and effort, and make your code more consistent.
  • Testability: Child coordinators can be tested in isolation, which can make it easier to find and fix bugs.

Show me some code

In this example, we want to separate the purchasing flow of the app into its coordinator.

We need to make a change before continuing our journey, we need to add a child coordinator array in our coordinator protocol:

protocol Coordinator: NSObject {
var childCoordinators: [Coordinator] {get set}
var navigationController: UINavigationController {get set}

func start()
}

Now we create a coordinator class for the Purchase flow:

final class PurchaseCoordinator: NSObject, Coordinator {

var childCoordinators: [Coordinator] = [Coordinator]()
var navigationController: UINavigationController

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


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

The only thing that we should pay attention to, here is that now that we are creating different coordinators, in each ViewController we should define our coordinator type carefully so it only accepts its coordinator.

For example, in PurchaseViewController our coordinator is like this:

    weak var coordinator: PurchaseCoordinator?

Back in the main coordinator now we need to make some changes to use this new coordinator.

    func purchase() {
let child = PurchaseCoordinator(navigationController: navigationController)
childCoordinators.append(child)
child.start()
}

the purchase function is going to give the navigation control to the Purchase coordinator from the main coordinator, so here we need to add the purchase coordinator to the main coordinator's child coordinators array.

Our first step is done now we can run our app and see the result:

Note that the code in the app delegate and scene delegate to run the coordinator is as before.

Navigation backwards

Navigating back from a view controller to a previous one is something that we need to take care of so we can easily manage these child coordinators. In our solution to manage backward navigation, we want to use the UINavigationController delegate so whenever a view controller is being shown or dismissed it alerts us, and based on the action we decide to handle the backward action or something else we wish to do.

The first step is to implement a function in the main coordinator so that whenever we want to remove a child coordinator from the array we would be able to do so, this function just needs to take a coordinator and remove it from the array.

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

Note that this can work just because we use only class-type coordinators.

The next step is to be noted whenever a view controller is being dismissed, we achieve this by conforming to the navigation controller delegate, in the start function of the main coordinator we set the navigation controller delegate to self:

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

And now we should implement the didShow function(of delegate functions):

extension MainCoordinator: UINavigationControllerDelegate {

func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
//handle popping the child coordinators from here
guard let sourceViewController = navigationController.transitionCoordinator?.viewController(forKey: .from) else {
return
}

if navigationController.viewControllers.contains(sourceViewController) {
return
}

if let vc = sourceViewController as? PurchaseViewController {
childDidFinish(vc.coordinator)
}
}
}

In this function, we first get the view controller that transitioning is started from, and check if this view controller still exists in the stack(view controllers stack) or not, if it is in the stack it means that this view controller pushed another view controller and this is not the scenario we look for so we return but if it’s not the case it means this view controller was dismissed. Now that we know the view controller was dismissed we just need to find its type so we can remove its coordinator from the array(only if the view controller uses a child coordinator), in this step we brute force through all the view controllers that use a child coordinator, for example in our case we just have “PurchaseViewController”, and we check if the view controller is of this type and if it is, it means that we need to remove its coordinator from the array and if it's not it means the view controller was not one of those view controllers that use a child coordinator and we don’t need to do anything then.

Passing data between view controllers

To pass data between view controllers in Swift using the Coordinator pattern, you can use one of the following methods:

1. Pass the data through the coordinator:

This is the simplest and most common way to pass data between view controllers. To do this, you would simply store the data in the coordinator object and then access it from the view controllers when needed.

For example, the following code shows how to pass a user object from a login view controller to a product catalog view controller using a coordinator:

class LoginCoordinator {
private let userObject: User

init(userObject: User) {
self.userObject = userObject
}

func start() {
let productCatalogViewController = ProductCatalogViewController()
productCatalogViewController.userObject = self.userObject

window.rootViewController = productCatalogViewController
}
}

//-------------
class ProductCatalogViewController {
var userObject: User?

override func viewDidLoad() {
super.viewDidLoad()

// Access the user object from the coordinator
guard let userObject = self.userObject else {
return
}

// Use the user object in your view controller
}
}

2. Use a delegate protocol:

Another way to pass data between view controllers is to use a delegate protocol. This allows the view controllers to communicate with each other directly, without having to go through the coordinator.

To do this, you would first create a delegate protocol that defines the methods that the view controllers can use to communicate with each other. Then, you would implement the delegate protocol in the view controllers that need to communicate with each other.

For example, the following code shows how to use a delegate protocol to pass a product object from a product list view controller to a product detail view controller:

protocol ProductListViewControllerDelegate: AnyObject {
func productListViewControllerDidSelectProduct(_ product: Product)
}

class ProductListViewController: UIViewController {
weak var delegate: ProductListViewControllerDelegate?

func didSelectProduct(_ product: Product) {
self.delegate?.productListViewControllerDidSelectProduct(product)
}
}

//-----------
class ProductDetailViewController: UIViewController {
weak var product: Product?

override func viewDidLoad() {
super.viewDidLoad()

// Access the product object from the delegate
guard let product = self.product else {
return
}

// Use the product object in your view controller
}
}

3. Dependency injection

In this method, we can easily inject the needed data into the coordinator’s function and feed the view controller through the coordinator function.

You can find the final code here:

Coordinator with tab bar controller

This one is a bit more tricky than what we have had so far, for this one we need to have a tab bar controller. First, we go to the storyboard and embed our initial view controller within a tab bar controller. We then need to create a tab bar view controller for our newly introduced tab bar.

For having a tab bar and coordinator pattern we should create a main coordinator class for each tab and initialize them in the tab bar view controller and then with in the view did load function of the tab bar we should call the start function on those coordinators class so they would be ready to operate:


import UIKit

class MainTabBarViewController: UITabBarController {

let main = MainCoordinator(navigationController: UINavigationController())
let search = SearchCoordinator(navigationController: UINavigationController())
let setting = SettingCoordinator(navigationController: UINavigationController())
let profile = ProfileCoordinator(navigationController: UINavigationController())

override func viewDidLoad() {
super.viewDidLoad()

main.start()
search.start()
setting.start()
profile.start()

viewControllers = [main.navigationController,
search.navigationController,
setting.navigationController,
profile.navigationController]
}
}

Then we go to the app delegate and scene delegate and introduce our tab bar view controller as the root view controller and we delete all the coordinator function and variables.

import UIKit

@main
class AppDelegate: UIResponder, UIApplicationDelegate {

var window: UIWindow?


func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
setupMainCoordinator()
return true
}

// MARK: UISceneSession Lifecycle

func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
// Called when a new scene session is being created.
// Use this method to select a configuration to create the new scene with.
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
}

func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
// Called when the user discards a scene session.
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
}
}

// MARK: - Coordinator
extension AppDelegate {

private func setupMainCoordinator() {
if #unavailable(iOS 13) {

window = UIWindow(frame: UIScreen.main.bounds)
window?.rootViewController = MainTabBarViewController()
window?.makeKeyAndVisible()
}
}
}

import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

var window: UIWindow?

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }
setupMainCoordinator(for: windowScene)
}

func sceneDidDisconnect(_ scene: UIScene) {
// Called as the scene is being released by the system.
// This occurs shortly after the scene enters the background, or when its session is discarded.
// Release any resources associated with this scene that can be re-created the next time the scene connects.
// The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead).
}

func sceneDidBecomeActive(_ scene: UIScene) {
// Called when the scene has moved from an inactive state to an active state.
// Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
}

func sceneWillResignActive(_ scene: UIScene) {
// Called when the scene will move from an active state to an inactive state.
// This may occur due to temporary interruptions (ex. an incoming phone call).
}

func sceneWillEnterForeground(_ scene: UIScene) {
// Called as the scene transitions from the background to the foreground.
// Use this method to undo the changes made on entering the background.
}

func sceneDidEnterBackground(_ scene: UIScene) {
// Called as the scene transitions from the foreground to the background.
// Use this method to save data, release shared resources, and store enough scene-specific state information
// to restore the scene back to its current state.
}
}

// MARK: - Coordinator
@available(iOS 13.0, *)
extension SceneDelegate {

private func setupMainCoordinator(for scene: UIWindowScene) {
let window = UIWindow(windowScene: scene)

window.rootViewController = MainTabBarViewController()
self.window = window
window.makeKeyAndVisible()
}
}

Now within each coordinator, we act as if they are the main coordinator:

import UIKit

final class ProfileCoordinator: NSObject, Coordinator {

var navigationController: UINavigationController
var childCoordinators = [Coordinator]()

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

func start() {
navigationController.delegate = self
let vc = ProfileViewController.instantiate(.Main)
vc.tabBarItem = UITabBarItem(tabBarSystemItem: .contacts, tag: 3)
vc.coordinator = self
navigationController.pushViewController(vc, animated: true)
}

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

// MARK: - UINavigationControllerDelegate
extension ProfileCoordinator: UINavigationControllerDelegate {

func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
//handle popping the child coordinators from here
guard let sourceViewController = navigationController.transitionCoordinator?.viewController(forKey: .from) else {
return
}

if navigationController.viewControllers.contains(sourceViewController) {
return
}

// if let vc = sourceViewController as? PurchaseViewController {
// childDidFinish(vc.coordinator)
// }
}
}

You can find the related code here:

Using protocol and closures in place of concrete coordinators

Protocols can be used in the Coordinator pattern in Swift to decouple the coordinators from the view controllers they manage. This makes the code more flexible, reusable, and testable.

To use protocols in the Coordinator pattern, you would first create a protocol for each coordinator. The protocol should define the methods that the coordinator can use to communicate with the view controllers it manages.

Then, you would implement the protocol in each coordinator. The coordinator would use the protocol to communicate with the view controllers it manages, and the view controllers would use the protocol to communicate with the coordinator.

For example, the following code shows a simple example of how to implement the purchase process and create an account in the coordinator with protocols :

protocol Buying: AnyObject {
func buying(_ item: Item)
}

protocol CreatingAccount: AnyObject {
func creatingAccount(_ user: User)
}
class BuyingCoordinator: Coordinator, Buying, CreatingAccount {

private var shoppingCart: ShoppingCart

init(delegate: BuyingCoordinatorDelegate, shoppingCart: ShoppingCart) {
self.delegate = delegate
self.shoppingCart = shoppingCart
}

func start() {
// Present the checkout view controller
}

func buying(_ item: Item) {
// purchase navigation
}

func creatingAccount(_ user: User) {
// creating account
}

}
class CheckoutViewController: UIViewController {
weak var coordinator: (Buying & CreatingAccount)?

func didFinishBuying() {
self.coordinator?.finishBuying()
}
}

Using protocols in the Coordinator pattern has a number of benefits:

  • Decoupling: Protocols decouple the coordinators from the view controllers they manage. This makes the code more flexible and reusable.
  • Testability: Protocols make it easier to test the coordinators in isolation.
  • Modularity: Protocols make it easier to break down complex navigation flows into smaller, more manageable pieces.

Overall, using protocols in the Coordinator pattern is a good way to make your code more flexible, reusable, testable, and modular.

--

--

Ario Liyan

As an iOS developer with a passion for programming concepts. I love sharing my latest discoveries with others and sparking conversations about technology.