Flow: an evolution of Coordinator for iOS
Defining Flows to allow composition of smaller ones to create a more complex Flow
We have already seen many articles about Coordinators, each one bringing a different flavour. The core idea, though, remains the same.
A Coordinator is a component which handles the presentation of different view controllers.
This way we can decouple view controllers from knowing about each other (increasing reusability also).
At the same time, a Coordinator encapsulates logically a set of view controllers, creating a new module, which is easier to reason about.
At TransferWise, we already use the concept of flow throughout the company, which is a sequence of steps (screens and user interactions) to achieve a final result.
For example, the Transfer Flow is in charge of gathering all the data in order to send a payment from A to B: this encapsulates a set of different operations.
A flow can be started from various places within the app: for example, the Transfer Flow can be started from the ‘New transfer’ button on the main screen, from a previous transfer by repeating it or from the Balance Account.
Sometimes you’ll need to nest another flow inside the parent flow to achieve your goal.
For example, the Transfer Flow has to eventually start the Profile Flow, to get more information about the user, before proceeding with its original intent.
Requirements for a Flow
With all of these criteria listed above, the rough requirements for a coordinator — what I call a flow — are these:
- a component that encapsulates and performs an activity that provides a final result as output
- flexibility and customisability in presenting Flows from different places in the app
- an easy way to chain and nest flows to create more complex ones
- possibility to interchange between different Flows that have the same result type (ie. think of A/B testing, or if a feature is implemented in different ways depending the country we are in)
For these flows, we also want to have:
- easy way of testing
- have a practical way of handling their lifecycle
An important note to make is that I wanted all this to NOT be tied to any particular design/architecture pattern.
In some examples, I may sometimes refer to some VIPER components (which is what we mainly use at TransferWise), but the Flow as a concept does not depend on any particular design pattern.
This series is split in 3 parts for easier understanding, broken down to:
- first (this): introduction + building blocks
- second (here): examples on how to use a Flow
- third (here): examples on how to write unit tests for a Flow
All the code shown here is already being used in production code. This works great for us, but there are of course possible improvements or things that may work better in a slightly different way for you.
A Flow can be seen as a module which, once started, is expected to terminate producing a result. The caller should make no assumptions on how the Flow is working: this way, we can really make it reusable in different scenarios.
- (1) each Flow has a specific FlowResultType. This could be an enum, for example (with associated values, eventually), but it’s totally up to you
- (2) a FlowHandler<T> is acting like a delegate, and is responsible to provide the result of the Flow and also to let the caller handle the dismissal of the flow itself
- (3) start() and terminate(), to operate the Flow
- the interface is clean of any other parameter, which the Flow protocol doesn’t need to know. They are in fact implementation details.
Through the FlowHandler, a Flow communicates back to the caller when it starts or finishes, providing also the result and a way to dismiss what the Flow presented.
This is just a wrapper, initialised passing two callbacks:
- (1) started: called when the Flow starts
- (2) finished: called when the Flow completes. It has 2 parameters: the flow result T, and the ViewControllerDismisser (to let us handle the dismissal)
ViewControllerPresenter & Dismisser
These components are used to abstract how the view controllers are presented/dismissed.
With these interfaces we get a couple of benefits:
- inside the Flow, we don’t have to care about any presentation logic (UIKit), since that is abstracted. In bigger flows especially, this results in a much easier (and less error prone) way to switch between all the VCs we need to present (imagine if some of the view controllers are presented modally, and some pushed on the navigation view controller instead)
- let the object that started the Flow decide how to dismiss it (animated/not animated), and if dismiss it at all
- make the Flow more reusable, even as a sub-Flow. Since all that a FlowHandler<T> knows about is a ViewControllerDismisser, it’s simple to reuse a Flow when we have different constraints (more on this later)
We also have a factory which provides us these components:
To be able to write synchronously unit test, this factory can be injected in the Flow.
This way, we can provide a mocked version that won’t actually present any view controller on screen, but simply put them in an array. Our unit tests could then simply check that array and assert any required condition.
This only exposes the type of the sub-Flow active at any given moment.
Note: we could eventually make this protocol expose the instance itself, rather than the Flow result type, if we want to be able to have more precise unit tests.
FlowController (for sub-flows)
Finally, for when we need to handle sub-Flows, we have an additional component which can help us write less and more robust code:
It implements the FlowInspectable protocol, and adds a function. Its signature contains all the parameters required by the FlowHandler, which this FlowController is creating and assigning to the Flow passed as parameter.
Its responsibility is to keep a strong reference to the Flow being presented, and release it when it completes.
This could have been done manually by the object launching a new flow, but I found it a bit harder to master/remember for new people approaching the Flow idea, and more error prone too.
What we did cover in this first article:
- the definition of our problem
- the actual features we want to implement
- the basic building blocks and their purpose to create flows
You can find the complete project containing all these codes here.
In the second part we’ll see a concrete example of how to create a flow from scratch.