?
Digital Application Development
9 minute read

SwiftCurrent vs The Coordinator Pattern

A popular method for moving responsibility of routing and navigation from views is the coordinator pattern. In this article, we'll explore how SwiftCurrent stacks up against the coordinator pattern.

In This Article

copy link

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. Note that 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 goes into detail about how it works with UIKit.  Similarly, here's a different blog article on the coordinator pattern with SwiftUI. We're going to recreate both scenarios with SwiftCurrent and let you see the difference. 

copy link

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.

copy link

For UIKit

Let's start by recreating what was accomplished in the UIKit article referenced above. 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 all the view controllers are located on the "Main" storyboard; 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 for this example, let's imagine three screens. 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.

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. 

copy link

For SwiftUI

A quick note before we dive into SwiftUI: SwiftCurrent has a BETA version of SwiftUI support at the time of writing. In our case, beta means that the public API is subject to change, but the code is well tested and reliable. We do not yet have support for navigation stacks meaning this isn't going to be an apples-to-apples comparison. However, the team is hard at work, and we expect to be out of beta before too long. With that out of the way, let's dive into how SwiftCurrent handles the same separation as coordinators in SwiftUI.

They've got many SwiftUI Views, and it's not necessary to recreate them all 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 SCRecipeList: 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()
                }
            }
        }
    }
}

As you can see, there are several approaches to getting recipes that update. None of them go against convention when using SwiftUI, and all 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: WorkflowItem(SCRecipeList.self))
            .thenProceed(with: WorkflowItem(SCRecipeView.self))
            .tabItem { Label("Meat", systemImage: "hare.fill") }
            .tag(HomeTab.meat)

        WorkflowLauncher(isLaunched: .constant(true), startingArgs: $veggieViewModel.recipes)
            .thenProceed(with: WorkflowItem(SCRecipeList.self))
            .thenProceed(with: WorkflowItem(SCRecipeView.self))
            .tabItem { Label("Veggie", systemImage: "leaf.fill") }
            .tag(HomeTab.veggie)

        NavigationView { // no need for a WorkflowLauncher here, there's only a single view
            SettingsView(with: $openedURL) // can still be a FlowRepresentable for later
        }
        .navigationViewStyle(StackNavigationViewStyle())
        .tabItem { Label("Settings", systemImage: "gear") }
        .tag(HomeTab.settings)
    }
    .sheet(item: $openedURL) {
        SafariView(url: $0).edgesIgnoringSafeArea(.all)
    }
}

It may be a little more code on this view, but overall, there is a lot less code for you 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!

copy link

Wrapping up

Both coordinators and SwiftCurrent have their place. They solve somewhat different problems but have overlapping ideas. Ultimately you should always pick the option that works best for you. 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 today and give us a star! We'd also really appreciate your feedback. We're interested in making the library even better, so feel free to start a discussion or open an issue.