How to pass data between views using Coordinator pattern in Swift

A question that comes back often when using Coordinator pattern in iOS development is how to pass data between views. Today I’ll share different approaches for a same solution, regardless if you are using MVVM, MVC or other architectural design pattern.

As a reminder, Coordinator pattern is an architectural design pattern dedicated to handle navigation and user flow, and remove complexity from UI or business logic layer.

This blog post is based on the same code sample, reusing BaseCoordinator from my previous article.

Let’s see now how to pass data between two different layers when using Coordinator pattern.

MVC

Using (Apple) MVC with Coordinator pattern, we want to pass data between two views.

However, the idea with Coordinator pattern is to keep a clear separation between layers. So ideally we want to avoid exposing a UIViewController to another, like following.

class ViewControllerA: UIViewController {
    
    @IBAction func navigateToViewControllerB(sender: Any) {
        let destinationController = ViewControllerB()
        destinationController.data = self.data
        self.navigationController.pushViewController(destinationController, animation: true)
    }
}

Using Coordinator Pattern, we can make sure both UIViewControllers are separated.

// responsible of flow to ViewControllerA
class CoordinatorA: BaseCoordinator {
    let navigationController: UINavigationController
    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }
    
    func start() {
        let viewController = ViewControllerA()
        
        viewController.performAction = { [weak self] essentialData in
            self?.navigateToFlowB(with: data)    
        }
        
        self.navigationController.pushViewController(viewController, animating: true)
    }
    
    func navigateToFlowB(with data: AnyObject) {
        let coordinatorB = CoordinatorB(navigationController: self.navigationController, data: data)
        self.store(coordinatorB)
    }
}

// responsible of flow to ViewControllerB
class CoordinatorB: BaseCoordinator {
    let navigationController: UINavigationController
    let data: AnyObject
    init(navigationController: UINavigationController,
        data: AnyObject) {
        self.navigationController = navigationController
        self.data = data
    }
    
    func start() {
        let viewController = ViewControllerB()
        viewController.data = data
        
        self.navigationController.pushViewController(viewController, animating: true)
    }
}

In the second code version, and using Coordinator pattern, each UIViewController is unaware of the source or destination of the flow. Even better, they don’t expose their models or dependencies to each other.

MVVM

If you are using MVVM design pattern, the goal is quite similar: we still want to avoid exposing the View or ViewModel logic to the navigation and leave this responsibility to Coordinator.

So we want to avoid direct usage like following

class ViewControllerA: UIViewController {
    @IBAction func navigateToViewControllerB(sender: Any) {
        let viewModel = ViewModelB(data: self.viewModel.data)
        let destinationController = ViewControllerB(viewModel: viewModel)
        self.navigationController.pushViewController(destinationController, animation: true)
    }
}

// or
class ViewControllerA: UIViewController {
    @IBAction func navigateToViewControllerB(sender: Any) {
        let destinationController = self.viewModel.makeViewControllerB()
        self.navigationController.pushViewController(destinationController, animation: true)
    }
}

extension ViewModelA {
    func makeViewControllerB() -> UIViewController {
        let viewModel = ViewModelB(data: self.data)
        return ViewControllerB(viewModel: viewModel)
    }
}

In both cases, we’re exposed to the same problem: the view holds the logic of the navigation, and potentially the viewModel is exposed to its destination. It’s not really flexible and can be a pain to support over time.

One thing is sure, the ViewModel should be limited to only its matching View.

Like before, we want to separate each responsibility, keeping MVVM pair away of the navigation.

// responsible of flow to ViewControllerA
class CoordinatorA: BaseCoordinator {
    let navigationController: UINavigationController
    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }
    
    func start() {
        
        let viewModel = ViewModelA(...)
        let viewController = ViewControllerA(viewModel: viewModel)
        
        viewModel.didSubmitAction = { [weak self] essentialData in
            self?.navigateToFlowB(with: data)    
        }
        
        self.navigationController.pushViewController(viewController, animating: true)
    }
    
    func navigateToFlowB(with data: AnyObject) {
        let coordinatorB = CoordinatorB(navigationController: self.navigationController, data: data)
        self.store(coordinatorB)
    }
}

// responsible of flow to ViewControllerB
class CoordinatorB: BaseCoordinator {
    let navigationController: UINavigationController
    let data: AnyObject
    init(navigationController: UINavigationController,
        data: AnyObject) {
        self.navigationController = navigationController
        self.data = data
    }
    
    func start() {
        let viewModel = ViewModelB(data: data)
        let viewController = ViewControllerB(viewModel: viewModel)
        
        self.navigationController.pushViewController(viewController, animating: true)
    }
}

Except the trigger didSubmitAction, the rest is really similar, up to you if you want to leave it in the ViewModel or ViewController, depends of your MVVM flavor.

In any case, ViewControllers are not exposed to each other, so do their ViewModels. If we need to update the user flow tomorrow, it will be at minimum impact.

Delegation

So far I’ve used a closure to perform callback and trigger a new journey but we can do it very similarly using delegation pattern.

protocol NavigationPerformActionDelegate {
    func didPerformAction(with: data)
}
// responsible of flow to ViewControllerA
class CoordinatorA: BaseCoordinator, NavigationPerformActionDelegate {
    let navigationController: UINavigationController
    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }
    
    func start() {
        let viewController = ViewControllerA()
        viewController.performActionDelegate = self
        self.navigationController.pushViewController(viewController, animating: true)
    }
    
    // MARK - NavigationPerformActionDelegate
    func didPerformAction(with: data) {
        let coordinatorB = CoordinatorB(navigationController: self.navigationController, data: data)
        self.store(coordinatorB)
    }
}

In my opinion, what’s important when using Coordinator pattern (or any other pattern and architecture), it’s to understand what’s its goal, its strengths and weaknesses and how to make the most of it.

In this case, Coordinator pattern aims to keep a clear separation of concern between navigation layer on interface one. If you feel your code doesn’t follow this separation, you might want to revisit it to have a deep understanding of each layer and ownership.

Happy coding!

© 2023 Benoit Pasquier. All Rights Reserved
Author's picture

Benoit Pasquier

Software Engineer πŸ‡«πŸ‡·, writing about career development, mobile engineering and self-improvement

ShopBack πŸ’°

Singapore πŸ‡ΈπŸ‡¬