What is the coordinator pattern?

The coordinator pattern solves the problem where view controllers must be aware of routing. In other words, your view controllers had to know what was going to be presented next. SwiftCurrent can also solve this problem, and in this article, we'll explore how SwiftCurrent's approach differs from coordinators. While we lightheartedly say "vs" in our title, these patterns are not necessarily mutually exclusive. It's entirely reasonable to think about a world where coordinators are coordinating workflows rather than views.

For reference, here's a Hacking with Swift article that showcases the coordinator pattern and details how it works with UIKit. Similarly, here's a QuickBird Studios blog article on the coordinator pattern with SwiftUI. We're going to recreate both scenarios with SwiftCurrent and let you see the difference.

What's the difference between a workflow and a coordinator?

A workflow is a description of a complete, linear flow. It's a DSL (Domain Specific Language) that allows you to describe a series of views that all intercommunicate, and you will have confidence that the views will pass data to each other. A coordinator is an abstraction that allows you to move the logic of routing from view controllers. Ultimately, it describes a group of actions that can turn into navigation instructions.

For UIKit

Let's start by recreating the Hacking with Swift article. Ultimately outside of creating a coordinator, they ended up with one landing page and two flows. The two flows they have are buying a subscription and creating an account. They started with a protocol you can use for loading from storyboards; well, SwiftCurrent has the same thing, so let's begin with that protocol definition. Let's say that the "Main" storyboard holds all the view controllers; we'll create a protocol for that.

import UIKit
import SwiftCurrent_UIKit

extension StoryboardLoadable { // SwiftCurrent
    // Assumes that your storyboardId will be the same as your UIViewController class name
    static var storyboardId: String { String(describing: Self.self) }
}

protocol MainStoryboardLoadable: StoryboardLoadable { }
extension MainStoryboardLoadable {
    static var storyboard: UIStoryboard { UIStoryboard(name: "Main", bundle: Bundle(for: Self.self)) }
}

SwiftCurrent already comes with a StoryboardLoadable protocol that gets you most of the way there. By creating a MainStoryboardLoadable protocol, we have removed all boilerplate from any view controllers and reached a point where all you need to do is inherit from this protocol.

The UIViewControllers

Next, let's get our account creation flow ready. The article's account creation flow was limited to a single screen, but let's imagine three screens for this example. The first screen will collect a username and password, then the next one will present the terms of service, and the final one will collect profile information like a physical address.

import UIKit
import SwiftCurrent

// UIWorkflowItem<Never, User> means we do not take in any arguments, but we pass out a User to the next view in a workflow.
final class EnrollmentViewController: UIWorkflowItem<Never, User>, MainStoryboardLoadable, FlowRepresentable {
	// ... do whatever things are needed to populate the User model....
	var user = User()
	@IBAction private func submit() {
	    // setup user with values from the UI
	    // ...
		proceedInWorkflow(user)
	}
}

Next, let's get terms and conditions working.

import UIKit
import SwiftCurrent

// PassthroughFlowRepresentable means if it receives data it'll move it to the next item in the workflow, no boilerplate needed!
final class TermsAndConditionsViewController: UIViewController, MainStoryboardLoadable, PassthroughFlowRepresentable {
    weak var _workflowPointer: AnyFlowRepresentable?
	@IBAction func rejectTerms() {
		// User does not accept our terms, abandon workflow
		abandonWorkflow()
	}
	
	@IBAction func acceptTerms() {
		proceedInWorkflow()
	}
}

And finally, the personal information collection screen.

import UIKit
import SwiftCurrent_UIKit

class PersonalInformationViewController: UIWorkflowItem<User, User>, MainStoryboardLoadable, FlowRepresentable { // SwiftCurrent
    private let user: User
    
    required init?(coder: NSCoder, with user: User) {
        self.user = user
        super.init(coder: coder)
    }

    required init?(coder: NSCoder) { nil }

    @IBAction private func savePressed(_ sender: Any) {
		proceedInWorkflow(user)
    }
}

But that was mainly view controller code… where's SwiftCurrent?

SwiftCurrent integrates very heavily with both UIKit and SwiftUI to give you as little boilerplate as possible, and what little we have, we are actively trying to slim down even farther. The key players here are UIWorkflowItem, FlowRepresentable, and StoryboardLoadable. UIWorkflowItem gives you a way to describe inputs and outputs easily, while FlowRepresentable is the protocol that describes your view controller as being able to be added to a workflow.

Finally, StoryboardLoadable handles the empty initializers for you and encourages you to add the correct one when your FlowRepresentable has an input type.

Now let's use it. From our landing page, let's have the same action they did.

@IBAction func createAccount(_ sender: Any) {
    launchInto(Workflow(EnrollmentViewController.self)
    	.thenProceed(with: TermsAndConditionsViewController.self)
    	.thenProceed(with: PersonalInformationViewController.self), 
    	launchStyle: .navigationStack) { finishedArgs in // launchStyle says we should be in a navigation controller, the closure is called when the workflow finishes
    	    guard case .args(let user as User) = finishedArgs else {
    	    	return
    	    }
    	    enroll(user) // enroll the user received at the end of the workflow
    	}
}

@IBAction func buyTapped(_ sender: Any) {
	// You can imagine something very similar here
	// No launch style means it'll use smart defaults. If you are already in a navigation view, it'll use that. Otherwise, it'll present modally.
    launchInto(Workflow(SubscriptionSelectionViewController.self)
    	.thenProceed(with: ReviewPurchaseViewController.self) { subscription in
    	    guard case .args(let subscription as Subscription) = finishedArgs else {
    	    	return
    	    }
    	    subscriptionService.subscribe(subscription) // Handle the purchasing the subscription
    	}
}

What about navigating backward?

The advanced coordinators tutorial mentioned in the Hacking with Swift article talks about how coordinators handle moving backward. Because coordinators should be responsible for all navigation, they need to be notified when navigations occur outside its interface. One way to manage that is to create a navigation delegate to inform the coordinator of these navigation changes.

SwiftCurrent is callback-based, though, which means that you do not need to notify it if the user presses back on a navigation controller. This callback-based approach is an essential distinction because SwiftCurrent does not keep track of a view stack. It does not know what the top view controller or what most recently got presented is. It merely delegates to UIKit or SwiftUI as necessary. It's both an abstraction and a description of a workflow.

What are these proceedInWorkflow and abandonWorkflow functions?

The proceedInWorkflow function is a type-safe function that allows the view to indicate it has performed an action that should move forward in a workflow. Calling proceedInWorkflow is quite similar to telling the coordinator a specific action was performed and having it know what to do next with a slight philosophical difference. By calling proceedInWorkflow, the view is generically describing that it performed some action, but it's also aware that it's in a workflow.

Similarly, abandonWorkflow is generically describing something that has happened, and we should bail out of whatever flow the view controller is in.

For SwiftUI

The article by QuickBird Studios details how to create the ListView for their app. It involves creating a CoordinatorView, ListView and ListViewModel. They call out why they go through all this effort:

Let's dive into how SwiftCurrent handles the same separation as coordinators in SwiftUI.

It's not necessary to recreate all of the views in this article, so I'll pick one of them. We'll look at the RecipeList and recreate that with SwiftCurrent.

import SwiftUI
import SwiftCurrent

struct RecipeList: View, FlowRepresentable {
    weak var _workflowPointer: AnyFlowRepresentable?
    typealias WorkflowOutput = Recipe

    @Binding var recipes: [Recipe]

    init(with recipes: Binding<[Recipe]>) {
        self._recipes = recipes
    }

    // could also have been:
    // init(with viewModel: ObservedObject<RecipeViewModel>) {
    //     _viewModel = viewModel
    // }

    // OR could have been
    // @EnvironmentObject var viewModel: RecipeViewModel

    var body: some View {
        List(recipes) { recipe in
            Button { proceedInWorkflow(recipe) } label: {
                HStack {
                    AsyncImage(url: recipe.imageURL)
                        .frame(width: 40, height: 40)
                        .cornerRadius(10)
                    Text(recipe.title)
                        .font(.headline)
                    Spacer()
                }
            }
        }
    }
}

RecipeList here is isolated from RecipeView. It simply declares that it takes in a value and will output a value, and when the view's task is complete, it will call proceedInWorkflow to complete its work. Whereas in the coordinator pattern, where the view must notify the coordinator of an action taken.

You'll also notice that there are several available approaches to getting recipes that update. None of them go against convention when using SwiftUI, and each approach could easily be used in this situation. Ultimately that's less of a SwiftCurrent concern and more of a preference from developers using SwiftCurrent. If the data doesn't change, you can even pass in an array of recipes without binding or state.

Next, let's look at how our tab view changes.

var body: some View {
    TabView(selection: $selectedTab) {
        WorkflowLauncher(isLaunched: .constant(true),
                         startingArgs: $meatViewModel.recipes) {
            thenProceed(with: RecipeList.self) {
                thenProceed(with: RecipeView.self)
            }.presentationType(.navigationLink)
        }
        .embedInNavigationView()
        .tabItem { Label("Meat", systemImage: "hare.fill") }
        .tag(HomeTab.meat)

        WorkflowLauncher(isLaunched: .constant(true),
                         startingArgs: $veggieViewModel.recipes) {
            thenProceed(with: RecipeList.self) {
                thenProceed(with: RecipeView.self)
            }
            .presentationType(.navigationLink)
            .applyModifiers { recipeList in
                VStack {
                    Text("If this were a modal, you can title it this way")
                    recipeList
                }
                .navigationTitle("No title needed in view")
            }
        }
        .embedInNavigationView()
        .tabItem { Label("Veggie", systemImage: "leaf.fill") }
        .tag(HomeTab.veggie)

        WorkflowLauncher(isLaunched: .constant(true)) {
            thenProceed(with: SettingsView.self) {
                thenProceed(with: SafariView.self)
                    .presentationType(.modal(.fullScreenCover))
                    .applyModifiers {
                        $0.edgesIgnoringSafeArea(.all)
                    }
            }
        }
        .tabItem { Label("Settings", systemImage: "gear") }
        .tag(HomeTab.settings)
    }
}

We've built out the tab view to reflect the source code in their repository versus what's in the article. With that said, this is still more code in this view than what you have with their example of the coordinator pattern, but overall, there is a lot less code to write in the project to get things functioning. Even better, if at any point we decide we want to add items to this workflow, extract the workflow views for re-use, or re-order items within the defined workflow, it's effortless to do!

Wrapping up

Both, SwiftCurrent and the Coordinator pattern have their place. They help with view isolation and abstract navigation away from the view or view controller, and they each achieve this goal in different ways. And as stated at the start, these ideas are not mutually exclusive. You should use the option which works best for you, and that option might be "both."

Ultimately, SwiftCurrent is a new way of approaching development both in UIKit and SwiftUI. The definition of workflows gives an awful lot of decoupled flexibility and allows you to expressively create and compose simple and complex flows across your app. Check out SwiftCurrent and give us a star! We'd also really appreciate all of your feedback. We're interested in making the library even better, so feel free to start a discussion or open an issue.

Technologies