Flow Navigation With SwiftUI (Revisited) | by Nick McConnell | Apr, 2022

How to implement navigation effectively in your codebases

This is a revisit of a previous couple of articles on creating a decoupled navigation flow (part 1 and part 2). Times have changed and SwiftUI, NavigationView, and my own perspective are now different (and simpler!) so thought it was worthwhile re-evaluating.

I’ve recently been re-looking my multi-screen onboarding flow with SwiftUI. As with all multi-screen data entry flows, they often represent an interesting problem of how to decouple data, view, and navigation logic.

So 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“. I use the “screen” here rather than view because we are explicitly referring to whole-screen navigation.

  1. Screens should have no “parent” knowledge nor be responsible for navigating in or out.
  2. Individual view models for every screen.
  3. Overall flow control logic is separate from 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 composable and scalable.

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, what’s 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 handle any combination of the above and, as per manifesto point 1 to do so outside of 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 😀.

This is still “declarative”, in the sense that it’s predefined rather than completely programmatic but dynamic where the paths are driven by programming logic. You still require each navigation “edge” to be defined up-front. If your flows are completely dynamic with no particular set paths, then perhaps this isn’t the approach for you.

With that said, let’s try and get a close implementation of this approach. Using the embedded types we can create a good declarative definition of the branching flow diagram from above. It satisfies manifesto point 1 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 the lack of documentation), here is a list of considerations for using NavigationLink:

  1. Needs to be embedded in a grouping such as a VStack.
  2. The Label is typical Text if we want a simple active link to control navigation. However, in our case do not want the view in external programmatic 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 the newer@StateObject (rather than @ObservedObject — see below) with flags for each navigation works nicely.
  4. I disliked the order. To me, the trigger for navigation reads better if it’s before the destination.

The resulting an improved encapsulation of NavigationLink:

struct Flow<Content>: View where Content: View {
@Binding var next: Bool
var content: Content var body: some View {
NavigationLink(
destination: VStack() { content },
isActive: $next
) {
EmptyView()
}
}
init(next: Binding<Bool>, @ViewBuilder content: () -> Content) {
self._next = next
self.content = content()
}
}

This encapsulates some of the complexity of NavigationLink usage. We can pass in a bound flag 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 for a simple 3 screen flow. We’ve introduced an observable object to encapsulate the navigation flags (more to follow).

class FlowVM: ObservableObject {
@Published var navigateTo2: Bool = false
@Published var navigateTo3: Bool = false
}
struct FlowView: View {
@ObservedObject var vm: FlowVM

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

Screens 1 and 2 both contain 3 types: AText to display the 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.

It should be noted that navigation should be activated based on the currently active screen — setting navigateTo3 to true when the user is on screen 3 will cause some “odd” results (it does navigate but instantly, without animation).

Backward navigation to a previous screen (including root) can be achieved by setting the flag that originally moved the user away from this destination screen to false. In the case above for programmatically going back to screen 1 from screen 2, by setting navigateTo2 = false. Note new in iOS 15, you can now use @Environment(.dismiss). However, this simply goes back one screen and does not allow for a larger backward jump (or pop to root). Using the activation flags allows for more complete control.

On to manifesto point 2 – separate view models for each screen. Usage and implementation of view models may differ here and I’ve heard concerns about the overuse of the MVVM design pattern in SwiftUI. It certainly isn’t a term used in anything official from Apple. What I want is a non-UI representation of the view so I can cleanly encapsulate non-UI logic, unit test it without the view, and of course, easily bind to the view (both ways). It also should be specific to the View so the View can be moved around and is not dependent on anything external (ie composable — manifesto point 5). It is the interface of the view to the rest of the application. I call this a view model.

Within SwiftUIObservableObject (which is actually part of Combine) makes for a good view model that enables 2-way view binding. Using @ObservedObject in the view itself, however, can be problematic and cause unnecessary view model recreation. The newer approach with @StateObject creates a stable view model which is lazily loaded only when needed. This is a large and important improvement on the old approach where a workaround was used.

Note also that in this version of a view model, UI events are also passed into the view model from the view, and any view-specific logic (eg network calls) may be triggered from there (usually calling down to an API layer for example ).

To model this out, we have a flow view model (FlowVM) to manage the screen-to-screen navigation. It does not know the views and is designed to be testable. It itself may require API calls to determine the path to follow. This is similar to a “co-ordinator” but to me is considered a model of the navigation and therefore I’ve used the term “view model”.

Each screen then also has individual view models as well. These screen view models handle the UI events and screen logic. Ultimately (upon completion of all screen logic after a “next” tap for example), we pass the control back from the screen view models to the flow view model to ultimately decide on where to navigate.

For completion eventing back from the screen view models back “up” to the flow view model, we can use a variety of techniques. Delegates and call-backs are all valid implementations but I like to use Combine’s PassthroughSubject passing back a reference to the screen view model itself.

So the screen view model and view would like something like this

class Screen1VM: ObservableObject {
@Published var name = "" // some bound info
let didComplete = PassthroughSubject<Screen1VM, Never>()
fileprivate func didTapNext() {
didComplete.send(self)
}
}
struct Screen1: View {
@StateObject var vm: Screen1VM

var body: some View {
VStack(alignment: .center) {
TextField("Name", text: $vm.name)
Button(action: {
self.vm.didTapNext()
}, label: { Text("Next") })
}
}
}

And wired in the flow view model to listen to the completion events as follows using a sink and storing that in a subscription. You’ll notice the factory function to create the screen view model is handled here which also added the event listening. This factory function is called by the flow view in screen view initialization.

The sink calls a method directly to handle any logic (which this case is just simply setting the navigate flag) and stores the subscription in aSet attached to the vm (which can be used for all subscriptions).

class FlowVM: ObservableObject {    @Published var navigateTo2: Bool = false    var subscription = Set<AnyCancellable>()    func makeScreen1VM() -> Screen1VM {
let vm = Screen1VM()
vm.didComplete
.sink(receiveValue: didComplete1)
.store(in: &subscription)
return vm
}
func didComplete1(vm: Screen1VM) {
navigateTo2 = true
}
}

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 navigation flag for each navigation edge. You will also note that screen 3 view model would require 2 didTap() functions and 2PassthroughSubjects to handle the branched logic.

class FlowVM: ObservableObject {
@Published var navigateTo2: Bool = false
@Published var navigateTo2: Bool = false
@Published var navigateTo3: Bool = false
@Published var navigateTo4: Bool = false
@Published var navigateToFinalFrom3: Bool = false
@Published var navigateToFinalFrom3: Bool = false
var subscription = Set<AnyCancellable>() // repeated for all screens
func makeScreen1VM() -> Screen1VM {
let vm = Screen1VM()
vm.didComplete
.sink(receiveValue: didComplete1)
.store(in: &subscription)
return vm
}
// repeated for all screens
func didComplete1(vm: Screen1VM) {
//do other logic here
navigateTo2 = true
}
...}struct FlowView: View {
@StateObject var vm: FlowVM
var body: some View {
NavigationView {
VStack() {
Screen1(vm: vm.makeScreen1VM())
Flow(next: $vm.navigateTo2) {
Screen2(vm: vm.makeScreen2VM())
Flow(next: $vm.navigateTo3) {
Screen3(vm: vm.makeScreen3VM())
Flow(next: $vm.navigateTo4) {
Screen4(vm: vm.makeScreen2VM())
Flow(next: $vm.navigateToFinalFrom4) {
FinalScreen(vm: vm.makeScreen5VM())
}
}
Flow(next: $vm.navigateToFinalFrom3) {
FinalScreen(vm: vm.makeScreen5VM())
}
}
}
}
}
}
}

Check out the repo for full code. This also includes examples of backward navigation (including back to root or screen 2 etc).

A big part of our design is to improve testability and allow for unit tests of the navigation flow independent of the UI (manifesto point 3). Now with view models, this is easily done. Here’s an example:

class NavigationFlowTests: XCTestCase {    func test_navigation() throws {
let sut = FlowVM()
XCTAssertFalse(sut.navigateTo2)
let screen1VM = sut.makeScreen1PhoneVM()
screen1VM.didTapNext()
XCTAssertFalse(sut.navigateTo2)
}
}

We are able to trigger a “next” button tap and then check the navigation logic has been triggered — all without actual UI.

Note though this is obviously a simple implementation. If the view models had API calls, we would have to think about some injection to mock those out. In addition, this is obviously not a UI test.

We may also want to add some UI tests (perhaps using snapshot testing) — but this is beyond the scope of this article.

That wrap — hope this has made sense! Definitely been a journey trying to make sense of how to use navigation and happy to share. And navigation has improved recently, I do hope that Apple continues to improve it. Here are some suggestions:

  1. There are too many ways to make mistakes with navigation when it’s beyond the simple case — and too little documentation. For example — using @ObservedObject, using the wrong activation flag at the wrong time, etc. I see this in the number of posts and custom libraries out there.
  2. Allow navigation in a way that not every navigation edge has to be specified and handled from the correct screen, but rather just a way of declaring the navigation to a screen from any screen.
  3. Create a simpler API for backward navigation (pop to root etc).
  4. Rather than UIKit being more flexible (eg custom push navigation animations etc), make SwiftUI navigation at least as flexible… and, perhaps, well, even better.

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

Leave a Comment