Here’s how to build the MOST scalable Onboarding flow!

T.Surkis
Lumen R&D
Published in
10 min readApr 8, 2021
Photo by Duncan Meyer on Unsplash

At Lumen, we grow at a fast pace and constantly adjust and improve our onboarding experience, to provide the best personalized experience for our users. As a result, our onboarding flow became a tangle of spaghetti code of logic and UI scattered across multiple screens.

We will show you how we rebuilt our onboarding logic to allow for dynamic changes no matter how the app evolves.

our onboarding flow overview at the time of publishing this article.

Onboarding flows are hard. They are easy to create but hard to maintain when our app evolves and changes with time. The nice flow you worked so hard to create becomes a convoluted mess of screens and logic scattered all around making it hard to introduce new changes. The solution will be to design it in a scalable way to grow with our application, but that’s easier said than done. Here’s how we did it:

The old onboarding flow

Applications are a series of screens, that interact with one another in a certain order. Therefore, pressing the sign-up button will lead you to the sign-up page. The sign-up page in turn will take you to the onboarding process. In this process, each page will lead to the next until you reach the end of the onboarding experience.

This is the most primitive implementation, and there is nothing wrong with it. In fact, many apps don’t require a lot of data, and their onboarding process is simple. But if your app needs a more complex onboarding experience and you feel like you could use a new and flexible way to do this, keep reading.

Once we recognized we needed more data on the user to allow for a more personalized experience, we knew our onboarding experience needed to grow. However, the way we built it didn’t allow for quick and precise actions, and we came across the following issues:

  • Adding a new screen or series of screens forced us to update the previous and following screens. As a result, we had no clear image of the order of pages and each page was dependent on the other.
  • We implemented any complex routes that involved a route based on a decision (for example, go to page A or page B if a user does action C) in the page itself. This made the onboarding flow scattered across multiple pages without any clear indication of how it works and why.
  • To move pages around in the flow, we had to remember to move the complex parts which decided the next routes. As a result, bugs were very frequent due to “forgotten” logic in an already unrelated page.

As a fast-moving startup constantly aiming to improve the user experience, this solution didn’t cut it. We needed a solution that de-coupled the logic from the pages and flows themselves, allowing us to easily and quickly move things around without damaging the flow itself. This will result in a testable, reliable and modular code, allowing us to experiment and iterate without major breaking changes.

The new Onboarding experience

To build a new system and a new mechanism, you first need to understand the different components and how they work together. Down to its core, an onboarding experience is made up of four components:

  • Route: The route contains the name of the screen and the parameters it requires. We use Sailor as our navigational library and each screen name points to a method creating that said screen.
  • Flow: The onboarding flow in Lumen consists of different flows, which allows us to break the experience into a more manageable specification. Each flow contains a collection of pages and represents a unified idea for a said collection of pages. For example, our setup flow is responsible for the initial setup of the user; his device pairing, and approving various permissions we required. As a result, these two screens belong to the setup flow.
  • Manager of Flows: Just like a flow decides on which page to present, so does the manager of flows. For example, after the setup flow comes a flow dedicated to personal details, such as name, birth date, etc. Therefore, after the setup flow is complete, the manager takes us to the personal details flow.
  • Router: This will be the base for all screens and contains the stack of pages from the currently presented flow. Each navigational command will be received here and routed to the correct screen.

If your onboarding experience is relatively simple, you may have a single flow and ignore the manager of flows. However, I would recommend adding one if there is even a slight possibility of a future onboarding change.

To remove the dependency between pages, the manager of flows will decide the next screen. As a result, every page will communicate with the manager of flows when it needs to navigate. This creates onboarding pages that are not dependent on anything but the manager itself, which allows an easier way to arrange things around the onboarding process

Since we are using the BLoC architecture, our only way to communicate with the manager of flows is through an event. This event will be our NavigationEvent and will contain two parameters:

  • Direction: The user can always choose to continue in the flow or return to the previous page. The interpretation of this is an enum containing two values: Left \ Right. In retrospect, a better name would be forward or backward to remove the dependency on the UI itself.
  • Parameters: Some screens may want to pass information to the next screen; however, this remains null if it passes no parameters.

To see how all the parts work together, we first need to look at each one separately.

Route

A route represents the next screen; its name and the parameters it needs to receive.

Flow

A flow is a collection of pages all tied around a single feature. Because it represents a group of pages, a flow needs to be responsible for the logic and relationship between the pages. It should also provide the functionality to query what the next route is upon navigation.

Each flow will have the ability to navigate forward or backward in the flow. The navigation will be entirely logical and would represent an update of the currentRoute parameter.

The purpose of the didFinish param is for the manager of flows to know if this flow is complete. If the flow isn’t complete, then the manager of flows needs to query the current route after applying the next or previous methods. However, if the flow has finished, then the manager of flows will query the next flow in the line. If no more flows exist down the line, then the onboarding process is complete. More on that will be in the manager of flows section down below.

Since most of the onboarding process is linear, meaning it has a set number of pages that do not change along the way, we can construct a custom class that would do the heavy lifting for all of our flows.

The linear flow holds a list of objects (screenRoutes) representing the screens. The order of this flow is also the chronological order in which the screens are displayed, hence why a data structure of the list is used. To move from one screen to the next or back, we update the currentPosition index.

The query as to whether we finished the flow or not depends on the index value compared to the number of screens. If the index has passed that number, it means we have completed the flow.

To create a new flow, all we have to do is to create a new class extending the LinearRouteFlowGroup class and declare in its constructor, the screens that belong to that flow.

As a side note, the class will be a bit more complex, checking if the data exists before deciding if a screen enters the list or not. This depends on the logic of your application and works similarly to the way we add different screens depending on the operating system.

Manager of Flows

This class will be the manager of flows and the highest logical component in your onboarding process. Due to the complexity of the class, I will break it down into a few parts. Before we begin, there are two important things to note here:

The manager of flows will be a class implementing a BLoC and following the BLoC architecture. Therefore, let’s start with the state of the BLoC, which represents the current route and flow direction. The router will read the state to decide what page or flow to present.

The initial state will have to come after the data is ready for us to query it. The manager will instantiate each flow in a dedicated list, representing the order of flows. Each flow will be created as presented earlier and, depending on the data, some flows might be already complete. This can occur if a user has started the onboarding process and closed the application or uninstalled it and returned to it later.

Each flow would hold a getter named isEmpty to represent whether the list of flows inside it is empty. If all the data is available for that particular flow, then it should have an empty list.

Iterating over the list would lead us to the flow that was not completed yet, thus receiving its current position in the collection of flows (_flows). That position, _currentPosition, represents the currently active flow.

During the actual onboarding process, a user will move between pages, be it forward or backward. Each movement will be an event processed by the manager BLoC. It will activate the current flow and then query it on whether it is complete or not.

If the flow did not finish, we will move to the next page. Since the flow has already advanced, as we have seen in the previous section, all we have to do is create a state out of its currentRoute value.

If the flow is already complete, we will advance to the next flow by incrementing the _currentPosition value. Afterward, we will have three options.

  • This was the last flow. As a result, the entire flow list is complete, and the onboarding process is over. The state that would return to the router will signal it to move to the steps following a successful onboarding process.
  • The current flow that _currentPosition points to is empty. From the previous sections, an empty flow is a complete one. As a result, we will need to advance to the next flow. Therefore, we would call the _onFlowFinished method again to initiate the process of moving a flow all over again.
  • The new flow is not an empty one and contains a screen that was not yet filled by the user. Therefore, the user continues to the current page of the flow, calling the _onFlowContinue method we have seen earlier.

Router

Each application in Flutter has a navigation class. This navigation is a stack of pages being put on top of one another, depending on the logic. To separate the onboarding process from the main navigation of the application, we created the onboarding as its own navigator, independent of the main application navigation, but nested inside of it.

This means, that each navigation occurs inside the onboarding navigator. Upon a successful onboarding, the navigator is discarded and move to the regular application navigation.

It provides full control of the entire onboarding process without potentially breaking anything in the application navigation itself. I would recommend separating big feature-rich screens the same way if possible.

The OnboardingFlow is the widget that gets loaded from the application navigation, be it the next page after sign up.

The OnboardingRouterWidget is the one that receives the different navigational states from the BLoC we’ve seen earlier. It decides upon receiving a state — whether to navigate towards a specific screen or pop the current one.

The screens and flows themselves live inside the Onboarding Navigator which is just the Navigator widget nested inside the OnboardingRouterWidget.

The root screen is a simple empty container that is presented for a short period of time until the onboarding BLoC decides on the first flow that will be displayed.

The onBlocStateChange method will vary between different applications, but at its core, it will have four options:

  • Navigate to a new flow: The old flow, since it was complete, has its screens popped from the navigation stack. In their place is inserted the first screen of the flow.
  • Navigate to a new page: Navigating to a new page will be done by adding it to the current navigational stack. As a result, a user could seamlessly scroll forward and backward between all the pages of that given flow.
  • Navigate back: Navigating back is executed by a simple pop to the stack. The user will face each page again if he or she chooses to navigate forward.
  • Navigate out of onboarding: When all the flows are complete, the user has finished the onboarding process. Therefore, we will navigate outside of the onboarding flow by calling the application navigator, discarding the OnboardingFlow widget.

Summary

Although seemingly more difficult at first, the new onboarding process allows us to move fast and change things easily. Since each widget is de-coupled from its previous or following widget, all we have to do to change the order is to update the flow classes.

The logic allows us abstraction and control with the given benefit of tests, all while not comprising of the quality that we provide for our users.

I would recommend that anyone, given a complex onboarding process, or one that might grow one day, invest a lot of thought into how you build it today. This will allow your company to move fast and be nimble without compromising the experience of the user.

In what ways are you implementing your onboarding flows?

--

--

T.Surkis
Lumen R&D

A Flutter developer by day, a technology enthusiast by night.