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

Easy methods to implement navigation successfully in your codebases

It is a revisit of a last few articles on making a decoupled navigation circulation (part 1 and part 2). Instances have modified and SwiftUI, NavigationView, and my very own perspective at the moment are completely different (and less complicated!) so thought it was worthwhile re-evaluating.

I’ve lately been re-looking my multi-screen onboarding circulation with SwiftUI. As with all multi-screen information entry flows, they usually signify an fascinating downside of decouple information, view, and navigation logic.

So what make’s an incredible multi-screen information entry circulation? Right here’s what I got here up with. For need of a much less grand time period, I’ll name it my “display circulation manifesto”. I take advantage of the “display” right here quite than view as a result of we’re explicitly referring to whole-screen navigation.

  1. Screens should not have any “guardian” data nor be liable for navigating in or out.
  2. Particular person view fashions for each display.
  3. Total circulation management logic is separate from UI implementation and is testable with out UI.
  4. Versatile and permit for branching to completely different screens within the circulation.
  5. So simple as attainable however composable and scalable.

So an on-boarding could also be easy, maybe 2 or 3 screens asking the person some easy private data. A “subsequent” button would transfer the person ahead within the circulation.

Easy Display Move

Nonetheless, what’s often extra typical is a extra advanced circulation with branching. Possibly the person isn’t able to share all these particulars but or maybe the extra particulars on wanted relying on earlier responses. So perhaps that is extra consultant:

Display Move with Branching

Clearly, any answer would wish to deal with any mixture of the above and, as per manifesto level 1 to take action outdoors of the screens themselves. It must also be famous that we in all probability need to do some information lookup on the finish of every display’s entry so we don’t need the view itself to regulate navigation (manifesto level 3)

As we’re on this planet of SwiftUI, I suggest utilizing the facility of @ViewBuilder. That is the “meat” contained in the SwiftUI view’s physique. ViewBuilders are a strong manner of producing advanced generic varieties — which is what’s behind the declarative nature of SwiftUI (however is past the scope of this text).

So what might that seem like? Properly, a very good begin is SwiftUI’s equal of UINavigationController which is NavigationView. Into this we add a ViewBuilder equal of a tree construction to signify the navigation nodes and edges:

var physique: some View {
NavigationView {
Screen1()
Move
Screen2()
Move
Screen3()
Move
FinalScreen()

Move
Screen4()
Move
FinalScreen()




}
}

OK, so this actually is pseudo-code. Full disclosure — it ain’t that straightforward 😀.

That is nonetheless “declarative”, within the sense that it’s predefined quite than fully programmatic however dynamic the place the paths are pushed by programming logic. You continue to require every navigation “edge” to be outlined up-front. In case your flows are fully dynamic with no explicit set paths, then maybe this isn’t the strategy for you.

With that mentioned, let’s try to get a detailed implementation of this strategy. Utilizing the embedded varieties we are able to create a very good declarative definition of the branching circulation diagram from above. It satisfies manifesto level 1 and maybe level 5. So let’s see if we are able to implement one thing like this.

NavigationView pairs with NavigationLink to provide us the flexibility to do “conventional” push navigation. There are just a few variations of use, however I honed into the fully-programmatic variation:

NavigationLink(vacation spot: Vacation spot, isActive: Binding<Bool>)  Label 

After some experimentation right here (and frustration with the shortage of documentation), here’s a listing of concerns for utilizing NavigationLink:

  1. Must be embedded in a grouping comparable to a VStack.
  2. The Label is often Textual content if we wish a easy energetic hyperlink to regulate navigation. Nonetheless, in our case are not looking for the view in exterior programmatic management of navigation so we use EmptyView.
  3. You’ll be able to simply go improper with the binding. If you’d like exterior management of navigation (and we do), utilizing the newer@StateObject (quite than @ObservedObject — see beneath) with flags for every navigation works properly.
  4. I disliked the order. To me, the set off for navigation reads higher if it’s earlier than the vacation spot.

The resulted an improved encapsulation of NavigationLink:

struct Move<Content material>: View the place Content material: View 
@Binding var subsequent: Bool
var content material: Content material var physique: some View
NavigationLink(
vacation spot: VStack() content material ,
isActive: $subsequent
)
EmptyView()

init(subsequent: Binding<Bool>, @ViewBuilder content material: () -> Content material)
self._next = subsequent
self.content material = content material()

This encapsulates a few of the complexity of NavigationLink utilization. We will cross in a certain flag to permit us to externally management navigation. The plumbing work of needing to make use of VStack and EmptyView is completed for us. It additionally makes use of @ViewBuilder to make a variation of NavigationLink that reads higher.

Let’s see it in motion for a easy 3 display circulation. We’ve launched an observable object to encapsulate the navigation flags (extra to comply with).

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

var physique: some View {
NavigationView
VStack()
Textual content("Display 1")
Button(
motion: self.navigateTo2 = true ,
label: Textual content("Subsequent")
)
Move(subsequent: $vm.navigateTo2)
Textual content("Display 2")
Button(
motion: self.navigateTo3 = true ,
label: Textual content("Subsequent")
)
Move(subsequent: $vm.navigateTo3)
Textual content("Display 3")




}

Screens 1 and a pair of each comprise 3 varieties: ATextual content to show the display identify, the Button for the following motion, and a Move for the navigation. We retailer the circulation state for every navigation and inner features carry out the precise navigation (didTapNext1 and so on).

This works however maybe is overkill if the following buttons themselves immediately do the navigation. Different types of NavigationLink can fill that position simply as properly maybe. Nonetheless, as a part of manifesto half 3, we wish our navigation to be managed externally from views.

It ought to be famous that navigation ought to be activated based mostly on the at the moment energetic display — setting navigateTo3 to true when the person is on display 3 will trigger some “odd” outcomes (it does navigate however immediately, with out animation).

Backward navigation to a earlier display (together with root) will be achieved by setting the flag that initially moved the person away from this vacation spot display to false. Within the case above for programmatically going again to display 1 from display 2, by setting navigateTo2 = false. Observe new in iOS 15, now you can use @Atmosphere(.dismiss). Nonetheless, this merely goes again one display and doesn’t enable for a bigger backward soar (or pop to root). Utilizing the activation flags permits for extra full management.

On to manifesto level 2 – separate view fashions for every display. Utilization and implementation of view fashions might differ right here and I’ve heard considerations concerning the overuse of the MVVM design sample in SwiftUI. It actually isn’t a time period utilized in something official from Apple. What I need is a non-UI illustration of the view so I can cleanly encapsulate non-UI logic, unit check it with out the view, and naturally, simply bind to the view (each methods). It additionally ought to be particular to the View so the View will be moved round and isn’t depending on something exterior (i.e. composable — manifesto level 5). It’s the interface of the view to the remainder of the appliance. I name this a view mannequin.

Inside SwiftUIObservableObject (which is definitely a part of Mix) makes for a very good view mannequin that permits 2-way view binding. Utilizing @ObservedObject within the View itself, nonetheless, will be problematic and trigger pointless view mannequin recreation. The newer strategy with @StateObject creates a steady view mannequin which is lazily loaded solely when wanted. It is a giant and essential enchancment on the outdated strategy the place a workaround was used.

Observe additionally that on this model of a view mannequin, UI occasions are additionally handed into the view mannequin from the view, and any view-specific logic (e.g. community calls) could also be triggered from there (often calling all the way down to an API layer for instance).

To mannequin this out, we’ve got a circulation view mannequin (FlowVM) to handle the screen-to-screen navigation. It doesn’t know the views and is designed to be testable. It itself might require API calls to find out the trail to comply with. That is much like a “co-ordinator” however to me is taken into account a mannequin of the navigation and subsequently I’ve used the time period “view mannequin”.

Every display then additionally has particular person view fashions as properly. These display view fashions deal with the UI occasions and display logic. Finally (upon completion of all display logic after a “subsequent” faucet for instance), we cross the management again from the display view fashions to the circulation view mannequin to in the end resolve on the place to navigate.

For completion eventing again from the display view fashions again “up” to the circulation view mannequin, we are able to use a wide range of strategies. Delegates and call-backs are all legitimate implementations however I like to make use of Mix’s PassthroughSubject passing again a reference to the display view mannequin itself.

So the display view mannequin and consider would really like one thing like this

class Screen1VM: ObservableObject 
@Printed var identify = "" // some certain information
let didComplete = PassthroughSubject<Screen1VM, By no means>()
fileprivate func didTapNext()
didComplete.ship(self)

struct Screen1: View
@StateObject var vm: Screen1VM

var physique: some View
VStack(alignment: .middle)
TextField("Identify", textual content: $vm.identify)
Button(motion:
self.vm.didTapNext()
, label: Textual content("Subsequent") )


And wired within the circulation view mannequin to hearken to the completion occasions as follows utilizing a sink and storing that in a subscription. You’ll discover the manufacturing unit operate to create the display view mannequin is dealt with right here which additionally added the occasion listening. This manufacturing unit operate known as by the circulation view in display view initialization.

The sink calls a way on to deal with any logic (which this case is simply merely setting the navigate flag) and shops the subscription in aSet hooked up to the vm (which can be utilized for all subscriptions).

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

Let’s see how that appears in totality with the moreover including in our 5 display branched circulation. You’ll discover there’s a separate navigation flag for every navigation edge. Additionally, you will be aware that display 3 view mannequin would require 2 didTap() features and a pair ofPassthroughSubjects to deal with the branched logic.

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


Move(subsequent: $vm.navigateToFinalFrom3)
FinalScreen(vm: vm.makeScreen5VM())



}
}
}
}

Try the repo for full code. This additionally contains examples of backward navigation (together with again to root or display 2 and so on).

A giant a part of our design is to enhance testability and permit for unit exams of the navigation circulation impartial of the UI (manifesto level 3). Now with view fashions, that is simply performed. Right here’s an instance:

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

We’re in a position to set off a “subsequent” button faucet after which examine the navigation logic has been triggered — all with out precise UI.

Observe although that is clearly a easy implementation. If the view fashions had API calls, we’d have to consider some injection to mock these out. As well as, that is clearly not a UI check.

We can also need to add some UI exams (maybe utilizing snapshot testing) — however that is past the scope of this text.

That wrap — hope this has made sense! Positively been a journey attempting to make sense of use navigation and glad to share. And navigation has improved lately, I do hope that Apple continues to enhance it. Listed here are some options:

  1. There are too some ways to make errors with navigation when it’s past the straightforward case — and too little documentation. For instance — utilizing @ObservedObject, utilizing the improper activation flag on the improper time, and so on. I see this within the variety of posts and customized libraries on the market.
  2. Enable navigation in a manner that not each navigation edge must be specified and dealt with from the proper display, however quite only a manner of declaring the navigation to a display from any display.
  3. Create a less complicated API for backward navigation (pop to root and so on).
  4. Slightly than UIKit being extra versatile (e.g. customized push navigation animations and so on), make SwiftUI navigation no less than as versatile… and, maybe, properly, even higher.

The total code will be discovered: https://github.com/nickm01/NavigationFlow

More Posts