SwiftUI Onboarding Architecture
I started working in SwiftUI pretty much as soon as it came out… which was… turbulent to say the least. But as we are five years in, the framework is able to do some really cool things. To the point now that my mentor and I have a private challenge to find a functionality that SwiftUI truly can’t address and UIKit must be fallen back upon. He claims to have found one; I’m not so sure.
But I digress.
With regard to onboarding flows, the options offered by SwiftUI leave something to be desired. In my opinion, onboarding flows present a wildly fun part of our jobs as developers. They are the only place where:
- We have all the control, leading users down a path of mostly our choosing
- A user is likely to see the screens only once, and yet if they don’t have a good experience that one time, they are incredibly likely to uninstall
- We have to deviate from the design of usual UX, showing the screens as a conditional or static flow of steps instead of presenting a screen and allowing the user to navigate through navigation links to their heart’s content.
We are going to hone in today on how we have addressed the onboarding problem so far in SwiftUI, and the advantages and disadvantages of each in my eyes and then I will present a framework that my colleagues and I developed, which breaks the way we generally think of SwiftUI’s declarative design, but which offers, in my experience, the highest degree of flexibility and modularization.
The Standard Design Patterns
The TabView
Or the similar but worse, SwitchView
Both of the above have the same basic pros and cons
Pros:
- Very MVVM and SwiftUI-y, so makes sense to our brains
- Easy to see all views involved in onboarding
- TabView comes with some animations built in
Cons:
- The view has deep knowledge of all the views involved in the flow
- Each step starts with a view, meaning that even if a step were performing some function that may not require user interaction (such as checking authentication and requesting sign in only if auth fails), a view would still ‘flash’ for a user as that step was entered.
- Each step would have to move forward in one of two ways:
- It would know which step came after it — an incredibly unseparated concerns pickle, or
- We pass an OnboardingViewModel as an environment object to every view in the onboarding, which can get a little messy and confusing.
The Ignore the difference Approach
We can, alternatively, pretend that onboarding flows are no different from normal app flows and set it up as we would the body of our application. This would look something like:
You can probably see the serious cons of this approach, but just for argument’s sake
Pros:
- This is very basic and a design pattern that feels familiar. If you are new to development, this could feel very easy to understand and conceptualize
Cons:
- Each view has deep knowledge of the next view, meaning it is not modularized and it will be very difficult to add a new step in the middle
- We will have to work against SwiftUI in disabling the navigation defaults
- Logic can easily end up fragmented all over the place, and
- Like the above two examples, each step starts with a view, meaning that even if a step were performing some function that may not require user interaction (such as checking authentication and requesting sign in only if auth fails), a view would still flash for a user as that step was entered.
I’m sure there are a number of other ways to approach onboarding that I haven’t mentioned, but you can see the major issues we have to confront.
A Different Approach
I work in internal technology consulting for a manufacturing company, which means our applications have to onboard not only users, but devices as well… a process that can be more than a little complex.
My colleagues and I designed an approach that we have found to be incredibly versatile, modular, and easy to understand. It involves a series of components and/or flows organized into a flow and uses protocols to make implementing new steps fairly idiot-proof. It does, however, go against the standard way of thinking in SwiftUI. I took some time to be convinced, but we have been using this design in a production app for several months and are very happy with our decision.
In SwiftUI, there is no way to enter other than through a view, so below is our entry point, however, the base view is incredibly simple and holds absolutely no logic other than that to dismiss itself.
This view will be what the user sees the entire time they are in onboarding, but the content will change based on what the manager.view publishes.
Now, let’s take a look at the logical bits. There are three protocols at work in this design, which represent two types of managers — Flows and Components — and the overlap between them — Phases.
Just a note here, the main difference between Flows and Components is that flows are able to contain both other flows and components, but components cannot contain either. Components represent an isolated process and should be the only type responsible for setting their own views.
As you may have assumed from the naming, OnboardingFlow, the manager of the above view is a Flow type. Let’s take a look at what it does now.
So, what’s happening here?
A flow is initialized and immediately sets its first phase. This phase will perform whatever functionality it represents, like authenticating, and then it will call this flow’s phaseCompleted() function, passing in itself as the phase in question. This flow then determines which phase comes next.
It’s worth saying again, that this flow is not setting a view. Views are only ever set by components. This flow does, however, update the view of its delegate if it exists (which in this case, because it is a top level flow, it does not).
Still with me? Good. Let’s look at a simple component to start to see this come together.
As you can see, there are some similarities and differences. Like the Flow, when the component has its view set, it updates its delegates view as well. It also receives an optional delegate which in this case will be the OnboardingFlow.
Unlike the flow, this Component does set its view. It also performs a specific logical function — authentication. Actually, this is the perfect example because this is where this design pattern really starts to shine. Look at lines 19–23 above and pretend that checkAuthentication() is a real function that checks for authentication state. Do you see the beauty?
What I love about this component is that the flow — or really anything outside of this component — doesn’t have to care about what goes on here. We know that if this component completes with a success, we are authenticated — we didn’t have to care whether we already were, or the user had to sign in or create an account; this component handles it all. And even better, if the user is already signed in, it just tells its delegate to keep moving without ever having to ‘flash’ a view in front of the user.
As we have built out more than 5+ flows and 20+ components, we have really fallen in love with the extensibility of this architecture. There is a lot more we can do with it, including adding sub-flows, components with multiple views, etc. It’s been really fun to explore.
If you decide to implement an onboarding using this architecture or you adapt it to fit your needs, I’d love to know how it works out!
You can find me on LinkedIn. Or get in touch through telawittig.com. And don’t forget to follow me on Medium for more SwiftUI, IoT, and Agile based content.