Architecture With Nico

Software Architecture and Design for iOS.

Published on 04 June 2023

The Flow Pattern

The flow pattern has a number of different names and similarities to other navigation patterns, like the Coordinator pattern, the Router pattern, and more.

Its a navigation pattern that is used to orchestrate the flow of an application or a feature. It is used to navigate between different screens or views in an application. It is also used to navigate between different flows in an application.

Its ultimate goal, is to decouple the navigation logic from the view controllers, and to make the navigation logic reusable, unit testable, and easy to maintain.

The Simplest Flow Pattern

Here's a simple example of the patter, to show how it works. It has a single method to start our flow, which receives a parameter (a prefill email in this example) and a UIViewController.

struct LoginFlow {
    let loginFormScreenFactory: LoginFormScreenFactory

    func start(on presentingController: UIViewController, with prefilledEmail: String?) {
        let loginFormScreen = loginFormScreenFactory.makeScreen(prefilledWith: prefilledEmail)
        presentingController.present(loginFormScreen, animated: true)
    }
}

// To use it, we need to create a factory that will create the screen we want to present
let loginScreenFactory = LoginFormScreenFactory()
let loginFlow = LoginFlow(loginFormScreenFactory: loginScreenFactory)

// And then we can start the flow
loginFlow.start(on: self, with: emailField.text) // self is the presenting controller in this case

Why not just navigating from the view controller?

The problem with this approach is that it couples the navigation logic to the view controller. This is a problem if we want to reuse the view controller in a different flow, or if we want to change the navigation logic. We would have to change the view controller, which is not ideal.

Adding complexity to the flow

Lets say we want to navigate to a Success screen after the user logs in. We can add a method to the Login Flow to do that:

struct LoginFlow {
    let loginFormScreenFactory: LoginFormScreenFactory
    let successScreenFactory: SuccessScreenFactory

    var navigationController: UINavigationController?

    func start(on presentingController: UIViewController, with prefilledEmail: String?) {
        let loginFormScreen = loginFormScreenFactory.makeScreen(
            prefilledWith: prefilledEmail,
            onLoginSuccess: navigateToSuccessScreen
        )
        let navigationController = UINavigationController(rootViewController: loginFormScreen)
        presentingController.present(navigationController, animated: true)
        self.navigationController = navigationController
    }

    func navigateToSuccessScreen() {
        let successScreen = successScreenFactory.makeScreen()
        navigationController?.pushViewController(successScreen, animated: true)
    }
}

As you can see, we added a new method and a new property to the flow. The new method is used to navigate to the Success screen, and the new property is used to keep a reference to the navigation controller, so we can use it to navigate to the Success screen and eventually maybe dismiss the flow, or decide to show a different screen after the user logged in. All of this without having to change the view controller.

Only expose the entry point of your features

The Flow pattern allows you to only expose the entry point of your features, and hide the implementation details. This makes it easier to maintain your code, because you can change the details without breaking the contract.

If you want to expose the login flow, you just expose the start method, and not the individual view controllers.

More reusable View Controllers

The Flow pattern makes it easier to reuse View Controllers, because you can use the same View Controller in different flows, and you can also use the same View Controller in different places in the same flow.

This also liberates the view controllers from the responsibility of knowing where they are being presented from, and how they are being presented.

Example

Here's an example of the Products List screen, used in 2 different contexts to do 2 different things. One, will open the Product Details screen when the user taps on a product, and the other will be used to select a product that the user wants to add to a shopping cart.

struct ProductListScreenFactory {
    func makeScreen(onProductSelected: @escaping (Product) -> Void) -> UIViewController {
        let viewController = ProductsListViewController()
        viewController.onProductSelected = onProductSelected
        return viewController
    }
}

struct BuyProductFlow {
    func start(on presentingController: UIViewController) {
        let productsListScreen = ProductListScreenFactory()
            .makeScreen(onProductSelected: navigateToProductDetailsScreen)
        // present trips list screen
    }

    func navigateToProductDetailsScreen(_ selectedProduct: Product) {
        let productDetailsScreen = ProductDetailsScreenFactory()
            .makeScreen(for: selectedProduct)
        // present product details screen
    }
}

struct AddToCartFlow {
    func start(on presentingController: UIViewController) {
        let productsListScreen = ProductListScreenFactory()
            .makeScreen(onProductSelected: addToCart)
        // present trips list screen
    }

    func addToCart(_ selectedProduct: Product) {
        // add product to cart
    }
}

Unit testing

The Flow pattern makes it easier to unit test the navigation logic, because you can test the flow in isolation, without having to test the view controllers or without resorting to UI tests!

Example

struct SimpleFlow {
    let someScreenFactory: SomeScreenFactory
    var navigationController: UINavigationController?

    func start(on: UINavigationController) {
        let viewController = someScreenFactory.makeScreen()
        viewController.title = "Simple Flow"
        navigationController.pushViewController(viewController, animated: false)
    }
}

class SimpleFlowTests: XCTestCase {
    func test_start_shouldPushViewController() {
        // Given
        let sut = SimpleFlow(someScreenFactory: MockSomeScreenFactory())

        // When
        sut.start(on: navigationController)

        // Then
        XCTAssertEqual(navigationController.viewControllers.count, 1)
        XCTAssertEqual(navigationController.viewControllers.first?.title, "Simple Flow")
    }
}

private class MockSomeScreenFactory: SomeScreenFactory {
    func makeScreen() -> UIViewController { UIViewController() }
}

private class MockNavigationController: UINavigationController {
    var viewControllers: [UIViewController] = []
    override func pushViewController(_ viewController: UIViewController, animated: Bool) {
        viewControllers.append(viewController)
    }
}