The Flow Pattern in UIKit: Testable Navigation Outside View Controllers
The flow pattern has a number of different names and similarities to other navigation patterns, like the Coordinator pattern, the Router pattern, and more.
It's 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.
Flows often live close to the UIKit composition root. If you want to see how the app can assemble flows and screens together, read The Composition Root in UIKit Apps.
#The Simplest Flow Pattern
Here's a simple example of the pattern, 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:
final class LoginFlow {
let loginFormScreenFactory: LoginFormScreenFactory
let successScreenFactory: SuccessScreenFactory
var navigationController: UINavigationController?
init(
loginFormScreenFactory: LoginFormScreenFactory,
successScreenFactory: SuccessScreenFactory
) {
self.loginFormScreenFactory = loginFormScreenFactory
self.successScreenFactory = successScreenFactory
}
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
func start(on navigationController: 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.pushedViewControllers.count, 1)
XCTAssertEqual(navigationController.pushedViewControllers.first?.title, "Simple Flow")
}
}
private class MockSomeScreenFactory: SomeScreenFactory {
func makeScreen() -> UIViewController { UIViewController() }
}
private class MockNavigationController: UINavigationController {
var pushedViewControllers: [UIViewController] = []
override func pushViewController(_ viewController: UIViewController, animated: Bool) {
pushedViewControllers.append(viewController)
}
}
#What you should remember
View controllers should not have to know where a user goes next. A flow keeps that decision outside the screen, which makes the same screen reusable in different journeys and lets you test navigation without running a full UI test.
Read next
Feature Modules in iOS: Vertical Slices vs Layers
Learn how to organize iOS feature modules using vertical slices, layers, and boundaries that make change easier.