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)
}
}