Creating Flows

Dama
InsideN26
Published in
4 min readJan 24, 2018

--

More often than not, when a user is signing up for a feature, they will need to provide some information. If the design of the app is lean, the information is gathered across multiple screens and finally submitted in the last step.

Take for example a simplified flow of sending a MoneyBeam using the N26 iOS App:

MoneyBeam flow

It boils down to 5 steps:

  1. Selecting a contact
  2. Entering the amount
  3. Entering a message (optional)
  4. Reviewing the MoneyBeam
  5. Entering the PIN

The MoneyBeam information is gathered across 5 screens, but it’s submitted only in the last screen after entering your PIN.

If you’re using an architectural pattern similar to VIPER, the Wireframes are the ones performing the navigation and passing the data around. An example of how we use wireframes to black box a screen can be found in the first part of our talk at iOS.Conf.

There are a couple of ways the information can be passed between them:

1. Passing each value as a typed parameter

Although this approach ensures compiler safety, it is not scalable nor maintainable. The later the screen is in the flow, the more parameters it has to accept as input and store so it can pass them along to the next one. For example, the message Wireframe would look like:

Each Wireframe needs to store all of the values received in order to pass them to the next one, which leaves plenty of room for error.

2. Passing the values in a mutable class or dictionary

The values get filled as the containers get passed around the Wireframes. This approach is easily maintainable, but it does not offer compiler safety. For example:

Whenever the order of the screens is changed, or a new one is added, it requires no changes to the previous ones. However, it provides no guarantee that the information will be filled in when it is time to use it, which leads to dodgy code:

In this scenario, we can either safely unwrap the optional value and fall back to a default value, or forcefully unwrap the value. This is possible since it should be present in every possible scenario given the possible code paths. The compiler, however, cannot verify it if this approach is used.

Flows and steps

In order to have a scalable, maintainable, and compiler-safe solution we came up with steps. The process of gathering information by the user is treated as a flow and each flow consists of multiple steps.

A step is defined as an instance that takes in a single INPUT parameter and asynchronously produces an OUTPUT. Since the parameters used by steps aren’t always used in succession (step 4 might need the output of step 2), closures are used to report completions of steps. For the simplified MoneyBeam flow from above:

Since each of the steps defines its own INPUT, the compiler will guarantee the type safety. All of the parameters are implicitly retained by the closures themselves, so there is no need to explicitly store them in between steps. Changing the order of steps, adding, and removing them becomes trivial — it’s just changing the order of calls to the steps.

The formal definition of a step in Swift is:

Each step corresponds to a screen, and in our screen architecture, the Wireframe emerges as the ideal component to implement the step.

The UINavigationController is passed in the initializer and not in the perform method, since it doesn't matter semantically to the definition of the message step.

We add a convenience method to make it easier to read on the call site, and to convert it to a generic:

StepT is a thunk used to convert the Step protocol with an associated type into a generic structure. This allows the compiler to perform type checks.

Complicated flows

The technique uses closures. If the flow gets big or complicated it becomes hard to read. An easy way to combat this problem is to break down the flow into smaller parts — defined objects that implement the Step protocol by calling a couple of other steps.

Forks

In rare cases, the flow needs to fork and behave differently depending on a combination of parameters. The obvious thing to do would be to pass all of the parameters to the components of the last step and observe the output. However, this creates a tight coupling between the step and its relative position in the flow. To get around this we introduced another concept - decision. This is an instance that synchronously makes a decision and produces an output.

Decisions are used to remove logic from the flow code and to make that logic easily testable with unit tests.

The definition of a decision in Swift is:

protocol Decision {

associatedtype OUTPUT

func make() -> OUTPUT

}

Note that in order to ensure all logic is removed from the flow, the Decisions need to return an Enum as an output. The flow has no other option than to do a switch statement and cover all of the scenarios.

You can find a sample project demonstrating the usage of the Step protocol here.

If you found this read interesting, give the approach a try. If you’re interested in a career opportunity at N26, check out careers page.

--

--