EP 165 · SwiftUI Navigation · Oct 25, 2021 ·Members

Video #165: SwiftUI Navigation: Links, Part 1

smart_display

Loading stream…

Video #165: SwiftUI Navigation: Links, Part 1

Episode: Video #165 Date: Oct 25, 2021 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep165-swiftui-navigation-links-part-1

Episode thumbnail

Description

It’s time to explore the most complex form of navigation in SwiftUI: links! We’ll start with some simpler flavors of NavigationLink to see how they work, how they compare with other navigation APIs, and how they interact with the tools we’ve built in this series.

Video

Cloudflare Stream video ID: 3411f87574b5ff3ab73b4adeebbd7a58 Local file: video_165_swiftui-navigation-links-part-1.mp4 *(download with --video 165)*

References

Transcript

0:05

We were able to quickly write tests for three different features in the row domain and how they interact with each other. This was only possible due to our desire to run the entire application off of a single source of truth. So, there are a ton of benefits to writing SwiftUI applications in this style, two being that you get instant deep linking capabilities and can write deep, comprehensive tests.

0:31

While it is amazing to have test coverage on deep linking and routing in our application, we did have to be on top of our game when it comes to writing the actual assertions. Because we’re dealing with reference types we don’t get the ability to make them equatable and instead need to do remember which fields to assert against, and in fact, our tests as written could be improved a lot.

1:01

So, we have now gone really deep into exploring the concepts of navigation when it comes to tabs, alerts, confirmation dialogs, modal sheets and popovers. We are really starting to see what it means to model navigation as state, and how that state starts to take the shape of a tree-like structure, where each next screen is represented by a piece of optional state becoming non- nil , and those optionals can nest deeper and deeper.

1:50

Further, we are seeing that in order to properly model navigation state in a tree structure we need to create more and more tools for transforming bindings. This often takes the shape of transforming bindings of optionals, or more generally bindings of enum, and we’ve already discovered multiple of these tools.

2:08

But, there is still one extremely important form of navigation that we haven’t yet talked about it, and it is both the most prototypical form of navigation and often thought of as the most complicated: and that’s navigation links. Well, SwiftUI calls them navigation links, but back in the UIKit days you may have known it by “push” and “pop” navigation.

2:27

This form of navigation allows you to push a new view onto the screen, with a right-to-left animation, and that new screen will automatically show a back button in the top-left, which allows you to pop the view to the previous one with a left-to-right animation. Further, the view that is presented with navigation links are typically dynamic and have behavior themselves, so just as with modal sheets and popovers we need a way of spawning a new view model to hand to the next screen.

2:55

So, it seems like navigation links and modal sheets are quite similar. And honestly we think they are basically two names for the same fundamental thing, but for whatever reason their APIs are very different. Further, we feel like many of the complexities that crop up with navigation links is most often due to incorrectly modeled state, rather than any inherent complexity in navigation links itself. In fact, the tools we built in the previous episodes for transforming and destructuring bindings is going to be extremely useful for navigation links, and will make it far easier to use links than it is with just the tools that Apple gives us.

3:31

So, let’s dig in! Fire-and-forget links

3:38

Navigation links are a little different from sheets, popovers, alerts and confirmation dialogs in that they are entire views unto themselves as opposed to view modifiers that you chain onto an existing view. The view is called a NavigationLink , and it’s essentially just a button that is aware of a surrounding navigation context so that it can push new screens onto the stack.

4:05

Using autocomplete we see that there are 9 initializers for the view: NavigationLink.init // init(destination: () -> Destination, label: () -> Label) // init(isActive: Binding<Bool>, destination: () -> Destination, label: () -> Label) // init(tag: V, selection: Binding<V?>, destination: () -> Destination, label: () -> Label) // init(_ titleKey: LocalizedStringKey, destination: () -> Destination) // init(_ titleKey: LocalizedStringKey, isActive: Binding<Bool>, destination: () -> Destination) // init(_ titleKey: LocalizedStringKey, tag: V, selection: Binding<V?>, destination: () -> Destination) // init(_ title: S, destination: () -> Destination) // init(_ title: S, isActive: Binding<Bool>, destination: () -> Destination) // init(_ title: S, tag: V, selection: Binding<V?>, destination: () -> Destination)

4:10

This just shows that there are many, many ways of creating a navigation link. The 9 initializers fall into 3 categories of 3 initializers each, and within those categories the only difference is how the label of the link is customized, i.e. is it specified by a string, localized string key, view, etc:

4:29

There are 3 initializers that take an isActive boolean binding, which is used to trigger a navigation event. This should be familiar based off what we’ve previously seen with sheets, popovers, alerts and confirmation dialogs.

4:49

There are 3 initializers that take a tag and selection , which is also used to trigger a navigation event, but this has no analogous API for sheets, popovers, or alerts. This is somewhat unique to navigation links, and we’ll be digging into it a bit more later.

5:06

And finally there are 3 initializers that don’t take a binding at all.

5:11

We are going to start by exploring this last category of initializer on NavigationLink because it’s the simplest, but also the least powerful. We like to refer to this category as “fire-and-forget” links because there is no representation of the navigation’s state in the application. SwiftUI just handles everything for us silently behind the scenes. When a user taps the link, SwiftUI triggers the left-to-right transition to the next screen, but nothing in your state needed to change to facilitate this. This is similar to the initializer of TabView that doesn’t take a binding at all, in which the user is free to change the tab all they want, but nothing in our state changes to reflect that.

5:50

Just as we saw with tab views, by using this type of navigation we are losing our ability to programmatically link into a screen or dismiss it, but let’s see this in concrete terms. We are going to make a small change to our application so that editing an inventory item is done via a navigation link rather than a sheet, as is done now. So, let’s get rid of the edit button in the item row view: // Button(action: { viewModel.editButtonTapped() }) { // Image(systemName: "pencil") // } // .padding(.leading)

6:18

And let’s comment out the .sheet modifier since we no longer want to navigation to this screen via a modal sheet: // .sheet( // unwrap: $viewModel.route.case(/ItemRowViewModel.Route.edit) // ) { $item in // … // }

6:33

And let’s wrap the entire row’s view in a NavigationLink . We can use the initializer that just takes two arguments: the destination , which is the view that will be navigated to when the row is tapped, and label , which is the view that is rendered to represent the navigation link. This means we can just pile all of our row view code into the label trailing closure: struct ItemRowView: View { @ObservedObject var viewModel: ItemRowViewModel var body: some View { NavigationLink(destination: { <#???#> }) { HStack { … } } } }

7:14

But, what do we put in for the destination? We want to drill down to the item view when this navigation link is tapped, but that view requires a binding of an item: NavigationLink( destination: { ItemView(item: <#Binding<Item>#>) } ) { … }

7:24

How do we get this binding?

7:26

Recall that previously, when the item view was displayed via a sheet, we modeled this as an enum with the 3 possible navigation routes that represented the 3 different places we could navigate to next: enum Route: Equatable { case deleteAlert case duplicate(Item) case edit(Item) }

7:35

We were either showing an alert to delete the item, or a popover for duplicating the item, or a sheet for editing the item.

7:38

The view model held onto an optional route value, representing the idea that either we were not currently navigating anywhere if it was nil or we were navigating to a particular place if it was non- nil . We then took the binding to that optional route that SwiftUI gives us for free, and transformed it with the .case binding method in order to transform it into a binding that traverses into the .edit case of the route: // .sheet( // unwrap: $viewModel.route.case(/ItemRowViewModel.Route.edit) // ) { $item in

8:07

This binding is non- nil only when the route is non- nil , and the route’s value is in the .edit case. Once that happens the .sheet view builder closure is triggered with an $item binding of an honest Item , which is exactly what we needed to hand to the ItemView to get it to display on the screen.

8:16

So, that’s all very cool and powerful, but it sadly does not help us at all when dealing with “fire-and-forget” navigation links. We aren’t even notified of the moment that the navigation link is tapped. It all happens behind the scenes.

8:31

Now, just to get something on the screen we can take a shortcut. We already have access to an honest Item value in the ItemRowViewModel , so we could hand a binding of that field to the ItemView : NavigationLink( destination: { ItemView(item: $viewModel.item) } ) {

8:46

So, with that change the project is back to compiling order, and each row of our inventory list has been magically given a little chevron drill down indicator. This is pure magic with SwiftUI, it somehow can detect when a navigation link is embedded inside a List and then can customize its behavior.

9:10

But even cooler, if we run the preview we will see that we can tap on any row of the list to trigger an immediate drill down animation to the item view. Further, any change made inside this item view is immediately reflected in the list that we drill down from.

9:24

This is pretty incredible. We only changed a few lines of code and have completely altered our application’s navigation structure. We went from a sheet-based system for editing items to a drill-down system, and edits in the drill down screen are immediately reflected in the parent, thanks to the fact that we are passing a binding from the parent to the child. It’s hard to imagine how we would have accomplished a similar refactor in UIKit.

9:48

There is a very subtle aspect of this code that we want to call out, which is something that has tripped up many people while exploring NavigationLink s. The destination argument takes a closure where we return the view to be navigated to when the link is tapped. This may lead you to believe that the view is lazily created once the navigation is activated.

10:11

But this is not the case. The destination closure we pass to this initializer is not escaping, as we can see by looking at the initializer’s signature in SwiftUI’s headers: public init(destination: () -> Destination, label: () -> Label)

10:21

This means the initializer is invoking the destination closure immediately, and so there is no laziness whatsoever. In fact, pre-iOS 15 the API for navigation links took a concrete view for the destination, not a closure. Those APIs have been deprecated.

10:41

The only reason we can think of that Apple chose to design this API as a closure rather than just passing a value is to make it look similar to other SwiftUI APIs, which are typically very closure-heavy and @ViewBuilder -heavy.

10:54

Since the destination is not lazy, you may be a little worried that every time the ItemRowView ’s body is re-computed it is also re-computing the ItemView . Right now ItemView is relatively simple, but someday it could grow to be quite complex. It could contain huge lists, have lots of sub screens, maybe even contain heavy views such as map views, grids and more. This certainly wasn’t the case with the .sheet modifier, whose content closure was only invoked once there was some data to present in the modal, and so was totally lazy.

11:31

However, the “heaviness” of the ItemView doesn’t matter at all. The ItemView ’s body isn’t being executed. The struct is being re-created, potentially many, many times, but the body of that view is executed only when we need to actually drill down to the screen. Creating a struct value is an extremely lightweight operation, and so we should not fret over it.

11:53

We’ve seen many people create “lazy view” helpers for wrapping the destination view so that it isn’t executed until the navigation link is activated, but we don’t think that should ever be necessary. Creating the destination should be so lightweight that you shouldn’t need to worry even if it’s being created dozens or hundreds of times. If creating the destination struct is not lightweight it must mean you are performing work or effects inside the initializer of your view, and this is something you absolutely need to avoid.

12:28

With all that said, it does seem like we have accomplished what we set out to do. But sadly, there are a few things wrong with this, and it’s all due to the fact that we are using the “fire-and-forget” style of navigation link. It makes it easy to get something on the screen quickly, but it’s also the least robust version of navigation links.

12:47

First of all, as we mentioned before, this navigation link has no deep linking abilities whatsoever. The only way to trigger a drill down transition is for the user to literally tap on the navigation link button. We can’t just construct a piece of data and have SwiftUI automatically restore the state of the application. This was the same problem with saw with the TabView before we introduced the binding: we had no way to change the tab programmatically, only when the user actually interacted with it.

13:19

If that wasn’t enough, there’s another big problem. The application no longer works as it did before. Previously, when editing items was driven off a modal sheet, the user could perform edits to the item and then decide to cancel all of the changes. It wasn’t until the user tapped the “Save” button that the changes were actually committed and the modal sheet was dismissed.

13:40

That’s not what is happening in our current application. After drilling down to the edit screen, every change made in the UI is instantly applied to the source of truth in the view model, which is why the change is immediately visible in the parent list view. The binding we are handed from the parent isn’t a piece of scratch work that we can be mutated and then discarded, but rather it’s a binding to the actual real data representing the row of the list. Boolean binding links

14:00

So, these are two pretty big problems, but luckily both can be solved by using one of the more powerful initializers on NavigationLink .

14:19

We’re going to start with the one that takes an isActive boolean binding: NavigationLink( isActive: <#Binding<Bool>#>, destination: { ItemView(item: $viewModel.item) }, ) { … }

14:28

This initializer works exactly as the .sheet , .popover , .alert and .confirmationDialog view modifiers that we have previously seen. Once the binding value flips to true a navigation event is triggered and we drill down to the view represented by the destination . Then, later, when the binding flips to false the destination will automatically be popped off the navigation stack.

14:47

Now previously when we explored the .sheet , .alert and other APIs we first modeled things using local @State because it made it easy to get our feet wet, but we quickly ran into problems with doing that, in particular it prevents us from deep linking, so we always resorted to properly modeling the state in our view model.

15:03

We’re going to jump straight to that, and luckily the data is already modeled for us in the Route enum. It’s not a simple boolean binding, but we can deriving a boolean binding from its shape. Let’s start by expanding the binding we need to hand to isActive into its get and set components: NavigationLink( isActive: .init( get: <#() -> Bool#>, set: <#(Bool) -> Void#> ), destination: { ItemView(item: $viewModel.item) }, ) { … }

15:21

The get endpoint needs to return a boolean, which should be true when the route field on the view model is non- nil and is holding a value in the .edit case of the Route enum: get: { guard case .edit = viewModel.route else { return false } return true },

15:42

For the set we are handed a boolean that determines whether we are trying to activate the navigation link, which means triggering a transition to the destination or if we are trying to deactivate the link, which means transition back to the parent view.

15:55

When activating the link we simply want to set the view model’s route appropriately, and when deactivating we just want to clear out the route. We could of course do all of that logic directly in the view, but time and time again we have found it is better to push that logic to the view model. So, we could introduce a new view model endpoint for handling the navigation change, and we can even pass along the isActive boolean so that it can handle all the logic in one place: // func editButtonTapped() { // route = .edit(item) // } func setEditNavigation(isActive: Bool) { route = isActive ? .edit(item) : nil } … set: { isActive in viewModel.setEditNavigation(isActive: isActive) }

16:46

Even better, we can just pass the .setEditNavigation(isActive:) method reference directly to the set endpoint, utilizing the point-free style of coding: set: viewModel.setEditNavigation(isActive:)

16:54

Next we gotta fix the destination argument for the NavigationLink . We want create an ItemView , but its initializer requires a binding of an Item . Further, the item we want to bind to specifically lives in the .edit case of the Route enum. In previous episodes we cooked up a binding transformation that could traverse into a case of an enum to grab a binding to just that case. The method was called .case , and when used in conjunction with a failable Binding initializer we also defined one could easily turn a binding of a Route into a binding of an Item in the .edit case: destination: { if let $item = Binding( unwrap: $viewModel.route.case(/ItemRowViewModel.Route.edit) ) { ItemView(item: $item) } }

18:00

And we have now constructed a NavigationLink that is driven off of state rather than being fire-and-forget! It’s a little intense, but at least it’s compiling.

18:08

If we run the preview we will see that we can still tap a row to drill down, but we will now see that when we make changes to the item in the UI and go back it no longer affects the item in the list of the parent view. This is because the ItemView that we drill down to is being handed a temporary scratch piece of state, from the Route enum, which has no connection whatsoever on the item state held in the view model, which is what is used to render the row.

18:36

But, this is basically what we want. We don’t want changes made in the ItemView to be instantly reflected in the list view, but rather we want the user to further confirm saving the changes, or allow them to discard the changes. We can do this by overriding the navigation bar buttons to present “Save” and “Cancel” buttons like we had when this screen was presented in a modal sheet.

18:55

To do this we can hide the back button on the ItemView , and then provide custom buttons by using the new .toolbar API for specifying a .navigation button and a .navigationBarTrailing button: destination: { if let $item = Binding( unwrap: $viewModel.route.case(/ItemRowViewModel.Route.edit) ) { ItemView(item: $item) .navigationBarTitle("Edit") .navigationBarBackButtonHidden(true) .toolbar { ToolbarItem(placement: .navigation) { Button("Cancel") { viewModel.cancelButtonTapped() } } ToolbarItem(placement: .navigationBarTrailing) { Button("Save") { viewModel.edit(item: $item.wrappedValue) } } } } }

19:23

Notice that in the “Save” button we can simply call out to the .edit(item:) method we created previously. It takes care of updating the view model’s item and nil -ing out the route so that the item view pops off the navigation stack. That method would also be the appropriate place to perform any side effects necessary to save the item to disk or send data to an external API server.

19:39

With these changes we will now see that our preview runs just as we expect. If we drill down to an item, make some edits, and hit “Cancel”, we will see the screen pop off the stack and no changes were committed on the list screen. However, if we drill back down, make some changes, and hit “Save”, we are again popped off the stack but this time our changes are reflected in the list, so this means we really are mutating the view model’s state.

20:04

So that’s looking good, but the main reason we wanted to bring navigation into the state of our application is that we could instantly unlock deep linking. If we wanted to start this screen off with the edit screen already presented, it’s as simple as initializing one of the row view models with a route pointed to the .edit case: struct InventoryView_Previews: PreviewProvider { static var previews: some View { let keyboard = Item( name: "Keyboard", color: .blue, status: .inStock(quantity: 100) ) var editedKeyBoard = keyboard editedKeyboard.name = "Bluetooth Keyboard" editedKeyboard.status = .inStock(quantity: 1000) return NavigationView { InventoryView( viewModel: .init( inventory: [ .init(item: keyboard, route: .edit(editedKeyboard)), .init( item: Item( name: "Charger", olor: .yellow, status: .inStock(quantity: 20) ) ), .init( item: Item( name: "Phone", olor: .green, status: .outOfStock(isOnBackOrder: true) ) ), .init( item: Item( name: "Headphones", olor: .green, status: .outOfStock(isOnBackOrder: false) ) ), ] ) ) } } }

20:28

Now the app loads up with the edit screen already drilled down to, and the item is marked as sold out and not on back order. And if we tap the “Save” or “Cancel” buttons we are popped back to the inventory list. An enum-friendly link helper

20:50

So, functionality-wise this is looking absolutely amazing, the code is still a bit messy. Just as we did with the .sheet and .popover APIs, there’s gotta be a way to make a NavigationLink convenience initializer that hides away some of the messy details.

21:05

Let’s start by theorizing what the call site for this initializer might look like. Perhaps we can directly mimic what we did for .sheet and .popover by simply taking a binding of an optional value, and then making the destination be a closure that accepts a binding of an honest value: NavigationLink( unwrap: $viewModel.route.case(/ItemRowViewModel.Route.edit), destination: { $item in ItemView(item: $item) .navigationBarTitle("Edit") .navigationBarBackButtonHidden(true) .toolbar { ToolbarItem(placement: .navigation) { Button("Cancel") { viewModel.setEditNavigation(isActive: false) } } ToolbarItem(placement: .navigationBarTrailing) { Button("Save") { viewModel.edit(item: item) } } } } ) { … }

22:00

Much simpler! This very clearly expresses the idea that we want a navigation link that is driven by the .edit case in the Route enum, and when it flips to something non- nil we will trigger a drill down to the ItemView .

22:14

So let’s get a signature for such an initializer in place: extension NavigationLink { init<Value>( unwrap optionalValue: Binding<Value?>, @ViewBuilder destination: @escaping (Binding<Value>) -> Destination, @ViewBuilder label: @escaping () -> Label ) { } }

23:10

If it’s possible to implement this, then constructing navigation links will look basically the same as invoking .sheet and .popover view modifiers, which would be really awesome. It would show that these seemingly different forms of navigation are basically all the same. Fundamentally they represent the transition to a new screen when a piece of optional state flips from a nil value to a non- nil value.

23:34

So, let’s try implementing it. First things first, we need to invoke an existing initializer on NavigationLink , and we’ve so far had success with the isActive category of initializer, so let’s start there. We can even go ahead and plug in the label argument: extension NavigationLink { init<Value>( unwrap optionalValue: Binding<Value?>, @ViewBuilder destination: @escaping (Binding<Value>) -> Destination, @ViewBuilder label: @escaping () -> Label ) { self.init( isActive: <#Binding<Bool>#>, destination: <#() -> View#>, label: label ) } }

23:58

For the isActive binding, we need to somehow transform a binding of an optional value to a binding of a boolean, which sounds a lot like the isPresent binding transformation we defined, so maybe we can use it here: self.init( isActive: optionalValue.isPresent(), destination: <#() -> View#>, label: label )

24:35

So we now only have one single argument left to fill in, and that’s the destination. It’s a Void to View closure, but inside that closure we want to repeat the the work we performed in the ad-hoc version of this code, where we tried unwrapping the value in the binding, and if that succeeded we pass it along to the destination closure: destination: { if let value = Binding(unwrap: optionalValue) { destination(value) } }, Value of optional type ‘Destination?’ must be unwrapped to a value of type ‘Destination’

25:02

However, we run into a compiler error that seems to complain that we are returning an optional Destination where it is expected to return a honest Destination . This is because we are trying to unwrap the item binding, which can possibly fail, and only if it succeeds do we invoke the destination closure with the binding of an honest item .

25:22

In order to fix this we need to express to the compiler that the destination type we are actually returning from the destination argument is an optional. If Swift supported generic extensions we could simply introduce a new generic and constrain the Destination generic to be the optional of the new type parameter: extension <WrappedDestination> NavigationLink where Destination == WrappedDestination? { … }

25:45

However, this doesn’t work yet, though it may someday. In the meantime we can introduce a new generic to the initializer, and then constrain it: extension NavigationLink { init<Value, WrappedDestination>( unwrap optionalValue: Binding<Value?>, onNavigate: @escaping (Bool) -> Void, @ViewBuilder destination: @escaping (Binding<Value>) -> WrappedDestination, @ViewBuilder label: @escaping () -> Label ) where Destination == WrappedDestination? { self.init( isActive: optionalValue.isPresent(), destination: { if let value = Binding(unwrap: optionalValue) { destination(value) } }, label: label ) } }

26:17

And now everything compiles, even the theoretical syntax we wrote a moment ago that tried invoking this initializer. When we run the application and tap on a row…it highlights, but nothing happens. We’re no longer drilling down to edit a particular row.

26:45

This is because when we tap on a row, SwiftUI will write true to the binding, but if we check isPresent , we ignore this value: extension Binding { func isPresent<Wrapped>() -> Binding<Bool> where Value == Wrapped? { .init( get: { self.wrappedValue != nil }, set: { isPresented in if !isPresented { self.wrappedValue = nil } // else { ??? } } ) } }

27:03

This wasn’t a problem for sheets and popovers because the only way to display one of them is by us writing to state directly in the view model. Everything was in our hands. However, with navigation links, there’s an external event that occurs that is outside of our control, that of the user tapping on the navigation link, and from that event we must create the state that drives the navigation.

27:25

The binding we pass along must react to the true s that SwiftUI may write, and this is where we can tell the view model to construct some state. So rather than rely on isPresent , let’s instead open up an ad hoc binding: isActive: .init( get: <#() -> Bool#>, set: <#(Bool) -> Void#> ),

27:39

This will give us an opportunity to inject some additional logic that is responsible for constructing the value being presented. In the get endpoint we just need to check if the optionalValue binding passed in is nil or not: get: { optionalValue.wrappedValue != nil },

27:57

For the set , we can check if the boolean handed to the binding is false then we can nil out the value in the binding: set: { isActive in if !isActive { optionalValue.wrappedValue = nil } else { <#???#> } }

28:12

And if true is handed to us, what do we do?? The value of true is passed to this binding when the user taps on the navigation link button, which means we need to transition to the destination screen, and that means we have to somehow populate the state that drives the navigation. But how can we do that in full generality? Right now in this convenience initializer we don’t have any domain specific knowledge of how to construct the state to trigger the navigation. We just have a fully generic Value that we know nothing about, so it’s not exactly clear how we should implement the else branch of this conditional.

28:38

Well, it turns out that we are just missing some information. So, for this reason, we are going to pass in an additional argument to our NavigationLink initializer in order for us to hook into the moment the navigation link is activated from the outside. We will call it onActivate , and it will be a simple () -> Void closure: extension NavigationLink { init<Value>( unwrap optionalValue: Binding<Value?>, onActivate: @escaping () -> Void, @ViewBuilder destination: @escaping (Binding<Value>) -> WrappedDestination, @ViewBuilder label: @escaping () -> Label ) where Destination == WrappedDestination? { self.init( isActive: .init( get: { optionalValue.wrappedValue != nil }, set: { isActive in if !isActive { optionalValue.wrappedValue = nil } else { onActivate() } } ), … ) } }

29:06

This allows us to tap into the moment the link is activated, which allows us to invoke the setEditNavigation(isActive:) method on our view model: NavigationLink( unwrap: $viewModel.route.case(/ItemRowViewModel.Route.edit), onActivate: { viewModel.setEditNavigation(isActive: true) }, destination: { $item in … } ) { … }

29:22

And if we build and run things, everything works again, exactly as it did before!

29:31

However, we are still missing out on some important events. Not only can navigation be activated outside our control, but it can also be deactivated outside our control. For example, the user can tap the “Back” button in the top-left of the navigation bar, or they can perform swipe gesture to pop back to the previous screen. We want to be notified of those events too, so perhaps we should upgrade the onActivate: () -> Void closure to be a closure that takes a boolean. We’ll call it onNavigate , since it is evaluated when a user navigates into or out of a link, and because it is similar to some of SwiftUI’s other APIs such as onDismiss from the .sheet and .popover modifiers. extension NavigationLink { init<Value>( unwrap optionalValue: Binding<Value?>, onNavigate: @escaping (Bool) -> Void, @ViewBuilder destination: @escaping (Binding<Value>) -> Destination, @ViewBuilder label: @escaping () -> Label ) { self.init( isActive: .init( get: { optionalValue.wrappedValue != nil }, set: { isActive in if !isActive { optionalValue.wrappedValue = nil } onNavigate(isActive) } ), … ) } }

30:38

Now that we’re notifying the outside of all user events, maybe it should be responsible for cleaning up state, and we could just pass onNavigate directly to set : set: onNavigate

30:51

But unfortunately this still isn’t quite correct. We are making the view model completely responsible for nil ing out its state when the setEditNavigation(isActive:) method is invoked with false . And that may sound correct, but it’s unfortunately too lenient.

31:08

When you tap the “Back” button in the navigation bar or perform a swipe gesture to pop the current view, SwiftUI writes false to its binding and then performs the animation immediately and unconditionally. This leaves us open to having invalid state where the edit screen has been popped off the stack yet our route field still holds a non- nil value. This shouldn’t be possible, and really SwiftUI probably shouldn’t perform its animation in such a case, but it seems to operate optimistically under the assumption that we are going to clean up our state.

31:44

So, we need to update our NavigationLink initializer so that we nil out the binding when isActive flips to false , and then we can invoke onNavigate to let the caller do any further customization they need: set: { isActive in if !isActive { optionalValue.wrappedValue = nil } onNavigate(isActive) }

31:55

And now when creating the NavigationLink we can just pass the view model’s method directly to the onNavigate argument: NavigationLink( unwrap: $viewModel.route.case(/ItemRowViewModel.Route.edit), onNavigate: viewModel.setEditNavigation(isActive:), destination: { $item in … } ) { … }

32:13

Alright, things are looking good, and if we compare the final binding to isPresent , we’ll notice that it’s identical except for the addition of that single onNavigate line. It’s a shame that we had to duplicate all of isPresent just to insert this little bit of trailing logic whenever the value is set.

32:30

Maybe instead we can leverage isPresent by introducing a new binding transformation! This transformation can simply take an existing binding and insert some logic whenever the binding is written to.

32:40

We will call it didSet , and it’s simple enough to implement. It will be handed a callback, which it will invoke right after the binding has been written to: extension Binding { func didSet(_ callback: @escaping (Value) -> Void) -> Self { .init( get: { self.wrappedValue }, set: { self.wrappedValue = $0 callback($0) } ) } }

33:12

And now we can update the isActive binding so that we notify the onNavigate callback when the binding is written to by chaining a didSet onto isPresent : // isActive: .init( // get: { self.optionalValue.wrappedValue != nil } // set: { isActive in // if !isActive { // self.optionalValue.wrappedValue = nil // } // onNavigate(isActive) // } // ), isActive: selection.isPresent().didSet(onNavigate),

33:38

Let’s quickly add a new piece of functionality to show just how easy it is to work with navigation in this way. Suppose that when the “Save” button is tapped it needs to execute some asynchronous work to save the item, and only once that is finished do we pop back to the previous screen. This turns out to be surprisingly easy to accomplish.

33:59

We can start by having the edit(item:) spin off a task before setting its item, and we’ll throw in a Task.sleep just to simulate work being done: func edit(item: Item) { Task { @MainActor in try await Task.sleep(nanoseconds: NSEC_PER_SEC) item = item route = nil } }

34:32

Further, we’ll introduce some state to the view model to track whether or not we are currently saving, which can be used in the view to show a loading indicator: @Published var isSaving = false func edit(item: Item) { isSaving = true Task { @MainActor in await Task.sleep(NSEC_PER_SEC) isSaving = false item = item route = nil } }

34:51

Then we can update the toolbar so that we show a loading indicator when isSaving is true , and otherwise show the “Save” button, and its action will need to spin up a task in order to invoke the edit(item:) method: ToolbarItem(placement: .primaryAction) { HStack { if viewModel.isSaving { ProgressView() } Button("Save") { viewModel.edit(item: item) } .disabled(viewModel.isSaving) } }

35:15

And if we run the application we will see it works exactly as we expect. When we tap the “Save” button a loading indicator shows for one second, and then the screen pops back to the inventory list.

35:31

So, this is all looking pretty incredible. With very little work we have developed a new tool that allows us to leverage NavigationLink s in a pretty simple manner. In fact, it didn’t take us much time at all to get a navigation link in place with full deep-linking support. This is surprising because navigation links are usually thought to be quite complicated, yet here it seems to have been even easier than what we encountered with modal sheets.

36:03

However, this is only because in the previous episodes we built up all the tools necessary to make navigation links simple. We now have the machinery necessary to properly model our domain in the most concise and correct way possible, and then to leverage SwiftUI’s navigation APIs without giving up any of that conciseness. And thanks to our continued efforts to model all of the application’s state in a single source of truth rather than splitting off chunks of state into isolated @State or @StateObject fields we are getting instant deep-linking capabilities for free.

36:41

This is also now the 8th time we’ve cooked up a binding transformation in this series of episodes on SwiftUI navigation, whether it be via a custom view or a method on the Binding type. We can see them all by scanning up and down the “SwiftUIHelpers.swift” file: IfCaseLet , Binding.init(unwrap:) , Binding.case , two Binding.isPresent methods, .sheet(unwrap:) , .popover(unwrap:) and now NavigationLink.init(unwrap:) and Binding.didSet .

37:12

So we are seeing over and over that the key to being able to model navigation state as a concise tree structure is that we have access to the tools that allow us to transform bindings. All of these transformations basically have the same shape. We previously saw that ForEach ’s initializer that worked on bindings had the following shape once you removed all the syntactic noise: ForEach.init: (Binding<C>, (Binding<C.Element>) -> some View) -> some View

37:47

But also .sheet(unwrap:) had basically the same shape, but it worked on optionals instead of collections (though optionals can be thought of as collections of at most one element): .sheet(unwrap:): (Binding<Value?>, (Binding<Value>) -> some View) -> some View

38:02

And now we’re seeing yet again a view that has a similar shape: NavLink.init: (Binding<Value?>, (Binding<Value>) -> some View) -> NavLink

38:16

This really is pointing at the fact that all forms of navigation we have seen so far can be boiled down to transforming bindings of optionals into bindings of honest values, and that’s honestly pretty amazing. Next time: tag and selection-based links

38:27

So, we’ve now cooked up a new initializer on NavigationLink that brings it more in line with how sheets and popovers work. You just hand it a binding of an optional, and when that binding flips to something non- nil the binding is transformed into a binding of an honest value, and that binding is handed to your destination so that it can do whatever it wants with it. It’s pretty cool that all of these seemingly disparate kinds of navigation are really just all driven off the same concept, which is optional state, or more generally enums.

38:57

However, there’s another initializer on NavigationLink that is quite different from any of the other ones. We’ve already seen the fire-and-forget initializer, the boolean binding initializer, and then we just cooked up an optional binding initializer, but this other one takes two pieces of information: something called a “tag” and something called a “selection.” References SwiftUI Navigation Brandon Williams & Stephen Celis • Nov 16, 2021 After 9 episodes exploring SwiftUI navigation from the ground up, we open sourced a library with all new tools for making SwiftUI navigation simpler, more ergonomic and more precise. https://github.com/pointfreeco/swiftui-navigation WWDC 2021: Demystifying SwiftUI Matt Ricketson, Luca Bernardi & Raj Ramamurthy • Jun 9, 2021 An in-depth explaining on view identity, lifetime, and more, and crucial to understanding how @State works. https://developer.apple.com/videos/play/wwdc2021/10022/ Collection: Derived Behavior Brandon Williams & Stephen Celis • May 17, 2021 Note The ability to break down applications into small domains that are understandable in isolation is a universal problem, and yet there is no default story for doing so in SwiftUI. We explore the problem space and solutions, in both vanilla SwiftUI and the Composable Architecture. https://www.pointfree.co/collections/case-studies/derived-behavior Downloads Sample code 0165-navigation-pt6 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .