Flow with SwiftUI 1 and MVVM — Part 1: Navigation [deprecated]

Nick McConnell
The Startup
Published in
7 min readJun 8, 2020

[Note this article relates to SwiftUI 1.0. It has been reworked for SwiftUI 3 and separately for SwiftUI 4.]

I’ve recently been looking at the creation of a multi-screen onboarding flow for my next app and challenging myself to use SwiftUI completely. As with all multi-screen data entry flows, they often represent an interesting problem of how to separate out data, view and navigation logic. I thought SwiftUI’s declarative nature and lean towards ViewModels would be a great opportunity but navigation does have it’s challenges in SwiftUI as we’ll see.

Before we start let’s ask the question: What make’s a great multi-screen data entry flow? Here’s what I came up with. For want of a less grand term, I’ll call it my “screen flow manifesto:

  1. Screens should have no “parent” knowledge nor be responsible for navigating in or out.
  2. Individual ViewModels for every screen.
  3. Overall flow control logic is separate to UI implementation and is testable without UI.
  4. Flexible and allow for branching to different screens in the flow.
  5. As simple as possible but scalable.

SwiftUI’s ObservableObject and @ObservedObject pair seem to work well for ViewModels giving us the 2-way binding that has previously been missing in UIKit. There are a lot of approaches to ViewModels but I like to think of them as a pure data interface to the view which avoids any direct view access.

But ViewModel implementation will be discussed in part 2. In part 1 we will looking at setting up the main navigation flow.

Part 1 — Navigation

So an on-boarding may be simple, perhaps 2 or 3 screens asking the user some simple personal information. A “next” button would move the user forward in the flow.

Simple Screen Flow

However, whats usually more typical is a more complex flow with branching. Maybe the user isn’t ready to share all those details yet or perhaps the more details on needed depending on previous responses. So maybe this is more representative:

Screen Flow with Branching

Obviously any solution would need to be handle any combination of the above and, as per manifesto point 1 to do so outside of the the screens themselves. It should also be noted that we probably want to do some data look up at the end of each screen’s entry so we don’t want the view itself to control navigation (manifesto point 3)

As we are in the world of SwiftUI, I propose using the power of @ViewBuilder. This is the “meat” inside the SwiftUI view’s body . ViewBuilders are a powerful way of generating complex generic types — which is what is behind the declarative nature of SwiftUI (but is beyond the scope of this article).

So what could that look like? Well a good start is SwiftUI’s equivalent of UINavigationController which is NavigationView. Into this we add a ViewBuilder equivalent of a tree structure to represent the navigation nodes and edges:

var body: some View {
NavigationView {
Screen1()
Flow {
Screen2()
Flow {
Screen3()
Flow {
FinalScreen()
}
Flow {
Screen4()
Flow {
FinalScreen()
}
}
}
}
}
}

OK, so this really is pseudo code. Full disclosure — it ain’t that simple 😀.

But let’s try and get close. Using the imbedded types we can create a good declarative definition of the branching flow diagram from above. It satisfies manifesto point 1, point 2, and perhaps point 5. So let’s see if we can implement something like this.

NavigationView pairs with NavigationLink to give us the ability to do “traditional” push navigation. There are a few variations of use, but I honed into the fully-programmatic variation:

NavigationLink(destination: Destination, isActive: Binding<Bool>) { Label }

After some experimentation here (and frustration with lack of documentation), here are a list considerations of using NavigationLink:

  1. Needs to be embedded in a grouping such as a VStack.
  2. The Label is typically Text if we want a simple active link to control navigation. However, in our case do not want the view in direct control of navigation so we use EmptyView.
  3. You can easily go wrong with the binding. If you want external control of navigation (and we do), using @State does not work as @State is often disconnected to its backing store when used externally.ObservableObject looks promising but after experimentation best controlled by use of one ObservableObject per NavigtaionLink. I had initially tried to use one ObservableObject for all links but this failed miserably.
  4. I disliked the order. To me the trigger for navigation reads better if it’s before the destination.

The resulted an improved encapsulation of NavigationLink:

class FlowState: ObservableObject {
@Published var next: Bool = false
}
struct Flow<Content>: View where Content: View {
@ObservedObject var state: FlowState
var content: Content
var body: some View {
NavigationLink(
destination: VStack() { content },
isActive: $state.next
) {
EmptyView()
}
}
init(state: FlowState, @ViewBuilder content: () -> Content) {
self.state = state
self.content = content()
}
}

This encapsulates some of the complexity of NavigationLink usage. We can pass in a FlowState to allow us to externally control navigation. The plumbing work of needing to use VStack and EmptyView is done for us. It also makes use of @ViewBuilder to make a variation of NavigationLink that reads better.

Let’s see it in action in for a simple 2 screen flow.

private let navigateTo2 = FlowState()
private let navigateTo3 = FlowState()
var body: some View {
NavigationView {
VStack() {
Text("Screen 1")
Button(
action: { self.navigateTo2.next = true },
label: { Text("Next") }
)
Flow(state: navigateTo2) {
Text("Screen 2")
Button(
action: { self.navigateTo3.next = true },
label: { Text("Next") }
)
Flow(state: navigateTo3) {
Text("Screen 3")
}
}
}
}
}

Screens 1 and 2 both contain 3 types: AText to display screen name, the Button for the next action and a Flow for the navigation. We store the flow state for each navigation and internal functions perform the actual navigation (didTapNext1 etc).

This works but perhaps is overkill if the next buttons themselves directly do the navigation. Other forms of NavigationLink can fill that role just as well perhaps. However, as part of manifesto part 3 we want our navigation to be controlled externally from views. Typically a “next” button press in an on-boarding flow will require a backend call to validate or save data in each step. To model this out, we could think in terms of a FlowController being in control of the navigation flow which has no knowledge of the views themselves.

Externalizing navigation using a FlowController

It makes sense to have the FlowController own the FlowView and use delegation to event “next requests” back up. We can then wire up the next button taps from the screen views to the delegate functions. So a typical screen view may look something like this:

struct Screen: View {
let title: String
let didTapNext: () -> ()
var body: some View {
VStack() {
Text(title)
Button(
action: { self.didTapNext() },
label: { Text("Next") }
)
}
}
}

And implemented something like this:

Screen(
title: "Screen 1",
didTapNext: { self.modelDelegate.didTapNext(request: .screen2) }
)

Let’s see how that looks in totality with the additionally adding in our 5 screen branched flow. You’ll notice there is a separate NavigateTo case paired with a flow state observable for each every navigation edge. (For readability the Screen instantiation has been shortened — please see the repo for full code).

protocol FlowControllerViewDelegate: class {
func didTapNext(request: NavigateTo)
}
class FlowController {
var view: FlowControllerView?
init() {
self.view = FlowControllerView(delegate: self)
}
}
extension FlowController: FlowControllerViewDelegate {
func didTapNext(request: NavigateTo) {
// In the real world, would do a switch here on NavigateTo,
// followed by potentially some network calls
// before finally...
view?.navigate(to: request)
}
}
enum NavigateTo {
case screen1
case screen2
case screen3
case screen4
case finalFrom3
case finalFrom4
}
struct FlowControllerView: View {
weak var delegate: FlowControllerViewDelegate!
private let navigateTo2 = FlowState()
private let navigateTo3 = FlowState()
private let navigateTo4 = FlowState()
private let navigateToFinalFrom3 = FlowState()
private let navigateToFinalFrom4 = FlowState()
init(delegate: FlowControllerViewDelegate) {
self.delegate = delegate
}
func navigate(to navigateTo: NavigateTo) {
switch navigateTo {
case .screen1: break
case
.screen2: navigateTo2.next = true
case
.screen3: navigateTo3.next = true
case
.screen4: navigateTo4.next = true
case
.finalFrom3: navigateToFinalFrom3 .next = true
case
.finalFrom4: navigateToFinalFrom4.next = true
}
}
var body: some View {
NavigationView {
VStack() {
Screen(title: "Screen 1", ...)
Flow(state: navigateTo2) {
Screen(title: "Screen 2", ...)
Flow(state: navigateTo3) {
BranchedScreen(title: "Screen 3", ...)
Flow(state: navigateTo4) {
Screen(title: "Screen 4", ...)
Flow(state: navigateToFinalFrom4) {
FinalScreen()
}
}
Flow(state: navigateToFinalFrom3) {
FinalScreen()
}
}
}
}
}
}
}
struct Screen: View {
let title: String
let didTapNext: () -> ()
var body: some View {
VStack() {
Text(title)
Button(
action: { self.didTapNext() },
label: { Text("Next") }
)
}
}
}
struct BranchedScreen: View {
let title: String
let didTapNextA: () -> ()
let didTapNextB: () -> ()
var body: some View {
VStack(alignment: .center) {
Text(title)
Button(
action: { self.didTapNextA() },
label: { Text("Next-A") }
)
Button(
action: { self.didTapNextB() },
label: { Text("Next-B") }
)
}
}
}
struct FinalScreen: View {
var body: some View {
VStack(alignment: .center) {
Text("Final")
}
}
}

In part 2 to follow we look at adding in screen-based ViewModels and completing all our manifesto goals.

Full code can be found: https://github.com/nickm01/NavigationFlow

--

--