EP 212 · SwiftUI Navigation · Nov 14, 2022 ·Members

Video #212: SwiftUI Navigation: Decoupling

smart_display

Loading stream…

Video #212: SwiftUI Navigation: Decoupling

Episode: Video #212 Date: Nov 14, 2022 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep212-swiftui-navigation-decoupling

Episode thumbnail

Description

Why did Apple scrap and reinvent SwiftUI’s navigation APIs in iOS 16? Let’s look at some problems the old APIs had, how one of the new APIs solves one of them, and how we can work around a bug in this new API.

Video

Cloudflare Stream video ID: 658d983a4e5b91bfc0bdd94b00d6113a Local file: video_212_swiftui-navigation-decoupling.mp4 *(download with --video 212)*

References

Transcript

0:05

So, this is absolutely incredible. By making use of the powerful domain modeling tools that Swift gives us, such as enums, and by integrating all of our features together, we have an application that can deep link into any state in an instant, and we can write powerful, nuanced tests for features in isolation or the integration of multiple features.

0:26

So, we just wanted to take the time to show how our SwiftUINavigation library can allow you to write a modern, vanilla SwiftUI application with precise domain modeling.

0:35

Just really, really cool stuff.

0:37

So, what’s the problem then? Why did Apple go and completely revamp the way navigation links work in SwiftUI?

0:44

Well, there were a few problems. Some things were very in-your-face and obvious, such as numerous bugs, especially when it came to deep linking multiple levels. Other things were not as obvious at first blush, but became apparent as applications grow bigger and more complex, such as a tight coupling of the source of navigation with the destination being navigated to.

1:05

Let’s take a look at both of these problems so that we can understand why they are so pernicious, and then that will help us understand why the navigation link APIs were changed the way they were.

1:16

Let’s start with the bugs. There are plenty of navigation bugs, but the one that would get everyone sooner or later is that you cannot deep link in a navigation view more than 2 layers. We haven’t run into that problem in our inventory app because so far the maximum number of levels you can drill down is two: first to the item screen, and then to the color picker.

1:36

It may seem lucky that we didn’t have to drill down 3 levels in the app, but honestly we consciously engineered the app specifically to avoid that problem. So, we can’t see the problem in the app currently, but let’s quickly stub a view into the application that clearly shows something going wrong. Problems: bugs & coupling

1:53

I’m going to paste in an observable object that recursively holds onto an optional value of itself: class NestedModel: ObservableObject { @Published var child: NestedModel? init(child: NestedModel? = nil) { self.child = child } }

2:04

Such a model can be constructed to be any number of levels deep.

2:07

And we can create the corresponding view by having a navigation link whose destination is the view itself: struct NestedView: View { @ObservedObject var model: NestedModel var body: some View { NavigationLink( unwrapping: self.$model.child ) { isActive in self.model.child = isActive ? NestedModel() : nil } destination: { $child in NestedView(model: child) } label: { Text("Go to child feature") } } }

2:24

And let’s put this view into the entry point: NavigationView { NestedView( model: NestedModel() ) } .navigationViewStyle(.stack)

2:37

If we run the app in the simulator we will see that we can drill down any number of levels we want. And if we tap and hold onto the back button we will see the full stack of views that have been pushed onto the navigation view.

2:55

So, what if we wanted to deep link super deeply into the app? We can have that conversation with the compiler again to drill down many levels deep: NavigationView { NestedView( model: NestedModel( child: NestedModel( child: NestedModel( child: NestedModel( child: NestedModel() ) ) ) ) ) } .navigationViewStyle(.stack)

3:17

But when we run this we see some really funky behavior. When the app launches it starts off by doing what seems to be popping something off the stack. It’s hard to see so let’s run it again.

3:35

And if we tap and hold the back button we will see that only 2 things on the navigation stack even though our state says there should be many more.

3:44

Turns out there is just a really serious bug in SwiftUI that makes it impossible to deep link more than 2 levels. To get this to work you have to resort to all types of hack-y things, such as spacing out all the children screens being added to the stack by adding little delays.

3:59

So, that’s the major bug that plagues SwiftUI navigation. It’s not the only one, but it’s probably the most common one. And it’s worth mentioning that we aren’t trying to imply that the only way to fix this bug is to completely reinvent how navigation views work in SwiftUI. Theoretically it should have been possible to fix this bug with the old APIs, and in fact even the new APIs have a version of this bug in them. So something still isn’t quite right.

4:22

Now let’s look at the other problem: view coupling.

4:26

In some bonus episodes after the initial series on navigation we discussed modularization of code bases . This is where we actually put all of the features into their own modules, and at the end of that series we considered some diagrams to show the dependencies between all the various features.

4:42

We started with this diagram: /* |-------| | I | | t | | e | | m | |-------| | I | | t | | e | | m | | R |-------| | o | I | |-------| | w | t | | U | |-------| e | | s | | I | m | | e |-------| | n |-------| | r | S | | v | I | |-------| P | e | | e | t | | S | r | t | | n | e |-------| e | o | t | | t | m | I | a | f | i | ... | o | R | t | r | i | n | | r | o | e | c | l | g | | y | w | m | h | e | s | |-------|-------|-------|-------|-------|-------| |-------------------------------------------------| | | | MODEL / HELPER / DEPENDENCY MODULES | | | | |---------------|-----------|---------------| | | | Models | ApiClient | ApiClientLive | | | |---------------|-----------|---------------| | | | |-------------------------------------------------| */

4:54

…which helps us explain the concepts of “horizontal” and “vertical” modularity. The search, profile and settings features were just hypothetical to show how a real application may have more feature modules for the other tabs and other parts of the application.

5:10

Horizontal modularity consists of the bottom slab of modules that form the basis of the application. These are modules that can essentially be included into any feature module. Things like models, helpers and dependencies are great examples of “horizontal” modules.

5:29

Dependencies can be further split into two flavors: the interface, which should be lightweight and build nearly instantly, and the implementation, which tends to be heavier because it usually needs to pull in 3rd party frameworks. Ideally other modules only ever depend on the interface, and the only thing that needs to depend on the implementation is the root entry point of the application.

5:54

Then there’s vertical modularity. This consists of the towers that sit on top of the slab of horizontal modules. These are the feature modules, and they are free to depend on pretty much anything in the horizontal layer, but they should try their hardest to not depend on other vertical modules.

6:11

The reason is that as time goes on, feature modules will collect more and more code, and by having the feature we are working on depend on other features, we will slow down our development cycle since we need to compile more and more code.

6:22

This is the exact situation we are in right now. The first tower in the diagram shows that the InventoryFeature depends on the ItemRowFeature , which in turn depends on the ItemFeature . This means to build any of these features we need to build everything above it. So if there’s a bug we need to fix in the InventoryFeature , then we must build both the row and item features.

6:44

And at first this may not be that big of a deal. But over time those features will get heavier and heavier. Maybe someday the ItemFeature team decides to add analytics to their feature, and they don’t take the time to properly separate interface from implementation, and instead they just depend on a heavyweight analytics SDK right in the feature. That cost is going to be passed down to you working on the inventory feature even if you don’t need to exercise any of the item feature behavior.

7:11

The reason these features are coupled together is due to how SwiftUI’s APIs are designed. If we look at the full signature of NavigationLink ’s initializer: NavigationLink( isActive: <#Binding<Bool>#>, destination: <#() -> Destination#>, label: <#() -> Label#> )

7:20

…we will see that we must specify the destination view at the moment of creating the navigation link. So, the act trying to navigate from view A to view B means that we have to compile view B and view A at the same time. It’s the only way to get access to view B’s symbols when constructing the navigation link.

7:36

So, that’s the problem of coupling feature modules. What we’d like is for our vertical modules to be flatter and wider like this: /* |-------| | U | |-------| | s | | I | | e |-------| |-------| n | | r | S | | I | v |-------| P | e | | t | e | S | r | t | |-------| e | n | e | o | t | | I | m | t | a | f | i | ... | t | R | o | r | i | n | | e | o | r | c | l | g | | m | w | y | h | e | s | |-------|-------|-------|-------|-------|------*/ /*--------------------------------------------------------| | | | MODEL/HELPER/DEPENDENCY MODULES | | | | |---------------|-----------|---------------| | | | Models | ApiClient | ApiClientLive | | | |---------------|-----------|---------------| | | | |---------------------------------------------------------| */

7:53

In this theoretical situation, each of the feature modules can be built in full isolation without building any other feature module. This means it doesn’t matter what is going on in the ItemFeature module because we will never have to build it if we are only working on the InventoryFeature .

8:08

Now, this all sounds great in theory, and in practice it can be really nice too, but it’s not without its drawbacks. Extreme decoupling of all features should not be the only goal you have.

8:18

The biggest downside to consider is that with fully decoupled features you don’t get a lightweight way to test out the integration points between two features. For example, in this diagram the row feature knows nothing about the item feature, which means we can’t test the flows of drilling down to edit the item or the duplication popover. Those navigation touch points must have been moved out of the row feature into some parent domain because that’s the only way the row feature could have been fully decoupled from the item feature.

8:45

And that is definitely a bummer, because previously we even created a dedicated “preview app” specifically for playing around with a row’s functionality that could be run in a simulator or on a device. In fact we can run it now, and we will see it puts a single row on the screen, and we can play around with the duplicate and edit flows.

9:07

And a lot of the functionality is actually working. We can apply edits, and even the little save progress view shows. In our previous series of episodes we even showed how to enable URL deep linking just for this preview app, which means we could test how deep linking works just for this feature without building the entire application.

9:29

The only functionality that doesn’t work is actually duplicating and deleting the item, because that logic is handled by the parent inventory feature, which actually manages the list. But we don’t even have to build any of the inventory feature to get this little preview app working. In fact, if we were in the middle of a massive refactor of the application and the InventoryFeature wasn’t even building we could still run this preview app. To see this, let’s purposely put the inventory feature into a non-building state, say we’re in the middle of a merge conflict: public final class InventoryModel: ObservableObject { <<<<<< … } Expected ‘(’ in argument list of function declaration

10:07

Even with that our preview app still builds and runs.

10:16

But if the row feature and item feature were fully decoupled, then this preview app would be mostly inert. Tapping on the row or the duplicate buttons wouldn’t do anything at all, because all of that behavior would have been delegated to the parent since that is the only way to decouple.

10:35

So, there are definitely downsides to decoupling just as there are upsides. You just have to figure out where you want to draw the lines. Maybe you can figure out which large, core features you want to have decoupled in your app, but then those features are composed of multiple features in which you do allow a little bit of coupling. It’s just something that you and your team have to think about.

10:54

So, source and destination coupling is a big problem with the current navigation APIs. And it affects all navigation APIs, not just navigation links. Sheets, covers and popovers have the same problem of coupling the view that wants to perform the navigation with the view that you are navigating to.

11:09

There’s one last problem with the navigation APIs that lies at the intersection of the two other problems we just discussed. It leads to a serious bug in a common UI pattern, and it happens due to the coupling of source and destination views in navigation.

11:23

It seems very natural to put navigation links directly in a list. In fact it’s what we are doing in the inventory view since it’s a List of a ForEach with an ItemRowView : List { ForEach( self.model.inventory, content: ItemRowView.init(model:) ) }

11:39

…and ItemRowView has a navigation link right at the root of the view.

11:42

However, List and ForEach work together to try to lazily load the views for the elements by waiting until the row actually comes on screen. So if there are a lot of elements in the list such that the last element is off the screen, its body property may never even be called. This means its navigation link or alert modifier or sheet modifier is not going to be realized, which means the bindings aren’t created, which means SwiftUI has no idea if that row wants to perform some navigation.

12:09

We can see this problem in concrete terms. Let’s alter the entry point of the application so that the model starts up with a whole bunch of items in its inventory.

12:28

Then we will construct an item to represent the last row: let lastItem = Item( name: "Headphones", color: .red, status: .outOfStock(isOnBackOrder: false) )

12:45

…and we’ll put that item last in the inventory collection but we will also set the model’s destination to be the .edit for that specific item: inventory: [ … ItemRowModel( destination: .edit(ItemModel(item: lastItem)), item: lastItem ), ]

12:56

So, what do we expect to happen when we run this and navigate to the inventory tab? I would expect that we immediately drill down to the edit screen even though the item is not going to be on the screen.

13:18

Well, instead, if we run the app and navigate to the inventory tab nothing happens. We are not further drilled down. It’s not until we scroll a little bit that all of the sudden a drill down animation happens. Presumably scrolling has caused the ForEach to start loading up later rows, which invoked the body property of the last row and then SwiftUI finally saw that one of its bindings is saying to drill down.

13:37

So, this is a pretty bad bug, and even if Apple could somehow fix this bug, it’s still kind of strange to put a navigation link in every single row because what if multiple rows say they want to be drilled down to the edit view at the same time. That is completely non-sensical, and so ideally shouldn’t be allowed in our data modeling.

13:54

Ideally we should be able to move navigation to the same level as the list or to a parent so that there is only a single description of navigation for all of the rows, rather than each row having a description of navigation. Decoupling old navigation

14:05

So, those are the problems of navigation, and Apple deemed them significant enough that they completely redesigned the APIs for navigation views and links. Now its worth noting that the coupling problem also exists for all the other forms of navigation, such as sheets, covers and popovers, and Apple has not provided new APIs to fix coupling in those styles. It is still on us to either fix the coupling or live with the coupling.

14:26

Now that we understand the problems, let’s take a look at the new tools Apple has given us to see how they fix the problems. And strangely, to best understand the first tool that we are going to look at we actually have to see how we would have gone about doing it with what Apple gave us in iOS 15.

14:46

We are going to decouple the drill down navigation that a row can do to an item by moving that logic to the parent inventory feature. The row feature will still have to depend on the item feature in order to handle the duplicate popover, but at least we will have this one integration point decoupled.

15:06

To get us started let’s concentrate on the Destination enums which represent all the places a particular feature can navigate to. Right now the row feature has 3 destinations, a delete alert, a duplicate popover, and an edit drill down: public enum Destination: Equatable { case deleteConfirmationAlert case duplicate(ItemModel) case edit(ItemModel) }

15:21

We are now saying we don’t want the edit drill down in the row, so let’s comment it out: public enum Destination: Equatable { case deleteConfirmationAlert case duplicate(ItemModel) // case edit(ItemModel) }

15:28

Then, in the inventory feature we have a Destination with a single case for navigating to the add sheet: public enum Destination: Equatable { … }

15:37

We now want the edit destination to be represented at this level, so sounds like we should add an edit case: public enum Destination: Equatable { … case edit(ItemModel) }

15:47

This is going to cause a number of compiler errors. Let’s first concentrate on the row feature by changing our current target to build only the ItemRowFeature module.

16:03

We’ve got errors in the model’s setEditNavigation and commitEdit methods since the destination no longer as an .edit case. And we have an error in the view because we can no longer construct a navigation link that points to the .edit case.

16:18

We can no longer even use a navigation link in this view, after all they completely couple the source and destination views of navigation, which we are trying to disentangle. What we need instead is a simple button, and we need another callback closure to tell the parent that the row has been tapped, just like we have with commitDeletion and commitDuplication to tell the parent when it’s time to delete or duplicate an item.

16:44

So, let’s add an onTap closure to the model that a parent domain can hook into and do the actual handling of navigation: public final class ItemRowModel: Hashable, Identifiable, ObservableObject { … public var onTap: () -> Void = unimplemented("ItemRowModel.onTap") … }

16:54

Then we can get rid of the setEditNavigation and commitEdit endpoints because that logic will need to move into the parent, and we can replace it with a simple method to notify the parent that the row was tapped: func rowTapped() { self.onTap() }

17:22

Next we can replace the NavigationLink at the root of the row’s view with a simple button: public var body: some View { Button { self.model.rowTapped() } label: { … } }

17:39

Notably there is no mention whatsoever of the ItemView . This means if it wasn’t for the popover for duplicating an item we wouldn’t have to depend on the item feature at all, and we would have completely decoupled those features.

18:14

And you may wonder whether we really even need the rowTapped method. After all, can’t we just invoke the onTap closure directly from the view?

18:26

Well, by maintaining this additional bit of interface for the view to communicate with we are giving ourselves the opportunity to layer on additional functionality. For example, maybe we want to track some analytics, or perform an API request and then notify the parent that the row was tapped. We wouldn’t want to do that work in the view, so we are doing a little bit of upfront work to give us that flexibility in the future.

18:54

There is one more compiler error in the row feature, which is the routing file. This is what handles interpreting a URL request to figure out where in the row we want to navigate to. Since the edit functionality is moving to the parent we can just remove all mentions of “edit” from this file.

19:07

And before moving on let’s go ahead and fix the tests, because now the edit test is a lot simpler. All we have to do is invoke the rowTapped endpoint: func testEdit() { let model = ItemRowModel(item: .headphones) model.rowTapped() } Unimplemented: ItemRowModel.onTap

19:51

And the test failure shows that an unimplemented endpoint was invoked, and forces us to get some test coverage on it. If we want to confirm that the onTap closure is called when the row is tapped, so that we can have confidence that in production the row will definitely communicate to the parent, then we have to use an expectation: func testEdit() { let model = ItemRowModel(item: .headphones) let expectation = self.expectation(description: "onTap is called") model.onTap = { expectation.fulfill() } model.rowTapped() self.wait(for: [expectation], timeout: 0) }

20:21

And this still passes, and so now I have some confidence that the row really will communicate to the parent model when it is tapped.

20:39

The row feature is now compiling entirely on its own, so let’s see if there’s anything to fix in the inventory feature.

21:08

Technically this target is already building, which means adding a new case to the destination hasn’t caused any errors. However, if we run the application and try to deep link into the last row we will get a runtime warning letting us know something is definitely not right: Unimplemented: ItemRowModel.onTap

22:01

This warning is letting us know that we have not configured the row model’s onTap closure. Currently we configure the row model in the bind method, which is where we set the commitDeletion and commitDuplication closures.

22:12

So, it seems we need to override the onTap closure there: private func bind() { … itemRowModel.onTap = { } … }

22:20

In this closure we want to point the destination to the edit case, and to do that we need to construct an ItemModel , and the ItemModel needs an item: itemRowModel.onTap = { self.destination = .edit(ItemModel(item: <#Item#>)) }

22:32

The item being tapped is precisely the one we are binding to: itemRowModel.onTap = { [weak self, weak itemRowModel] in guard let self, let itemRowModel else { return } self.destination = .edit(ItemModel(item: itemRowModel.item)) }

23:02

A few things to note here. First, we want to capture the itemRowModel instead of the item inside the model because when onTap is called we want to always grab the freshest item. Not just what was captured at the moment of binding. And second, we are capturing all of this weakly instead of as an unowned because from our experience ForEach rows can hang around for longer than expected, causing code to execute on objects that have been deallocated. So, we’ll play it safe for now.

23:48

With that done, this now properly binds the row model to the inventory model, and if we run the application and tap on a row we no longer get a warning, but also still nothing happens.

24:03

We still don’t have anything listening for the edit case of the destination so that we can perform a drill-down animation. Now NavigationLink s are the only way to do drill-downs, at least pre-iOS 16, so we gotta put one of those somewhere. But where?

24:25

Well, there was a pretty common trick in iOS 15 and earlier to hide a navigation link in the view so that it could listen to a binding for changes. Probably the most common way to do this is to stick a navigation link in the background of a view and hide it. We can even make use of the nice API that ships with our SwiftUINavigation library. List { ForEach( self.model.inventory, content: ItemRowView.init(model:) ) } .background { NavigationLink( unwrapping: self.$model.destination, case: /InventoryModel.Destination.edit ) { isActive in <#???#> } destination: { $itemModel in ItemView(model: itemModel) } label: { EmptyView() } }

26:00

However, there are a couple of things that we have to figure out here. First of all, what do we do with the action closure, which is the first trailing closure. Since this is a hidden navigation link the user can’t actually tap it, and so we don’t expect it to ever be called with isActive equal to true . But, it can be called with a false value, such as when the user taps the back button or performs the swipe from edge gesture to pop the view of the stack.

26:35

So, we do need to do some work in here, and we can just call a method on our model that says we want to deactivate the edit navigation: ) { _ in self.model.deactivateEdit() }

26:43

And that endpoint just needs to clear out the state: func deactivateEdit() { self.destination = nil }

26:51

The other thing to figure out is the tool bar for the ItemView . Recall that we tweaked the toolbar a bit when showing the edit item screen because we wanted the back button to say “Cancel” and we wanted the right button to be a “Save” button. We can just copy-and-paste the toolbar that we previously had over to this view, and comment out any references to the model until we figure out what to do about that: } destination: { $itemModel in ItemView(model: itemModel) .navigationBarTitle("Edit") .navigationBarBackButtonHidden(true) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { // self.model.cancelButtonTapped() } } ToolbarItem(placement: .primaryAction) { HStack { // if self.model.isSaving { // ProgressView() // } Button("Save") { // Task { await self.model.commitEdit() } } } // .disabled(self.model.isSaving) } } }

27:32

For the cancel button we can create a new endpoint on the model for cancelling: func cancelEditButtonTapped() { self.destination = nil } And call that endpoint from the view: Button("Cancel") { self.model.cancelEditButtonTapped() } Note that we are decided to keep two separate methods for cancelling the edit flow versus the add flow. This is so that the model can distinguish between these two events so that it can perform custom logic, like for example if it needed to track some analytics.

27:42

For the “Save” button we need to bring more code from the row feature over to the inventory feature. First we need some extra state to capture when the save effect is inflight: public final class InventoryModel: ObservableObject { @Published var isSaving = false … }

27:55

Then we need to copy over the commitEdit endpoint from the ItemRowModel to the InventoryModel , with a few small tweaks in order to apply the edit to the correct model in the inventory collection: @MainActor func commitEdit() async { guard case let .some(.edit(itemModel)) = self.destination else { return } // TODO: precondition? self.isSaving = true defer { self.isSaving = false } do { // NB: Emulate an API request try await Task.sleep(for: .seconds(1)) } catch {} self.inventory[id: itemModel.id]?.item = itemModel.item self.destination = nil }

28:55

As an aside, this is a great demonstration of why it’s important to use an identified collection instead of a plain array with positional indices. While the save effect is inflight it is possible that the inventory items are shifted around, and it would be possible for us to accidentally update the wrong item if we relied solely on positional indices. But because we are using an identified collection we can just subscript in with an id , and it efficiently finds that model.

29:56

With that done everything is compiling, and amazingly this actually works. If we run the app in the simulator we are now instantly drilled down, even though the last row isn’t visible. We can make edits, hit “Cancel” and see that no edits are applied. But we can also go back to the edit screen, make edits, hit “Save”, and we see the same progress view while saving and then it pops back with the edits applied. So it works exactly as it did before.

30:53

This works because the navigation link is no longer locked up in the row view, which isn’t even evaluated until we scroll to the bottom of the list. It’s directly in the list view. Or, well, behind the list view.

31:22

So it seems to work, but it also seems a little hack-y. A hidden background navigation link just to decouple source from destination view? Not only is it hack-y, but it’s technically not correct right now.

31:37

While we are using an EmptyView for the label of the navigation link, the view is still in the view hierarchy. We can’t see it, but it’s there.

31:47

And even though we physically can’t see it, that doesn’t mean it’s not visible to other processes. For example, to iOS’s accessibility machinery this navigation link is just a regular navigation link, and so it should participate in accessibility focus. We can even see this by opening up the accessibility inspector, cycle through the focusable elements, and we will see it stops on a little tiny “Button” in the center of the screen.

32:45

So maybe we need to hide the empty view inside the label: } label: { EmptyView() .hidden() }

33:02

Sadly that is not correct.

33:15

What you have to do is hide the whole navigation link: NavigationLink( … ) { … } .hidden()

33:23

Now the accessibility inspector skips past the background link, but is this the most correct way to do this? There’s also a accessibility(hidden:) view modifier, so maybe we should slap that on while we are here: .hidden() .accessibility(hidden: true) Decoupling new navigation

33:36

Is now this the most correct way to do this? How do we know for sure that there isn’t some edge case out there that will cause this background navigation link to surface in unexpected ways? Personally, I’m not very confident in this code.

33:50

And that finally brings us to the first new tool that iOS 16 has brought us, and it is specifically designed to drive drill-down navigations without literally sticking a NavigationLink in your view hierarchy.

34:06

There is a new view modifier called navigationDestination and it takes a binding of a boolean: func navigationDestination<V: View>( isPresented: Binding<Bool>, destination: () -> V ) -> some View

34:14

There’s actually another overload of navigationDestination that takes a type as a parameter, but we will be looking at that API a bit later.

34:20

SwiftUI will listen to changes of this binding, and when it detects a change to true it will invoke the destination closure and perform a drill-down animation to that view. And when it sees a false value it will pop back to the inventory view. And further, when the user taps on the back button or performs the swipe gesture from the edge of the screen it will write false to the binding.

34:40

This is very close to what we want. It allows us to drive navigation off of state without using a navigation link. It isn’t ideal to use a boolean binding, but let’s see how far we can get with this simple API.

34:53

Putting in some scaffolding we see that we need to fill in the get and set for the binding, as well as the destination closure: .navigationDestination( isPresented: Binding( get: { <#???#> }, set: { isActive in <#???#> } ) ) { <#???#> }

35:35

The get should return true if the model’s destination state is non- nil and matches the edit case. We can do this with an involved guard case statement: get: { guard case .some(.edit) = self.model.destination else { return false } return true },

36:00

And the set can just invoke the deactivateEdit endpoint on the model: set: { isActive in if !isActive { self.model.deactivateEdit() } }

36:13

Next we have the destination closure. This is called only when the destination state is non- nil and matches the edit case, because that’s what the isPresented binding specifies, so we can just try unwrapping the itemModel from the destination: ) { if case let .some(.edit(itemModel)) = self.model.destination { } }

36:36

And that itemModel is exactly what we need to pass along to the ItemView : ) { if case let .some(.edit(itemModel)) = self.model.destination { ItemView(model: itemModel) … } }

36:52

And that’s all it takes. No need to construct hidden navigation links to stuff into the background of views and hope it never accidentally shows its face.

37:00

There’s just one more change to make to get this working. The .navigationDestination view modifier only works if it operates in a larger NavigationStack context, which is a brand new view in iOS 16 that supplants the NavigationView as the means for performing drill-down animations in SwiftUI.

37:18

All we have to do is swap out the NavigationView for a NavigationStack at the root of the InventoryView : public struct InventoryView: View { @ObservedObject var model: InventoryModel public init(model: InventoryModel) { self.model = model } public var body: some View { NavigationStack { … } } }

37:25

And if we run the application we will see it works exactly as it did before, just with less hacks.

37:38

So, this works, but also the call site of navigationDestination is looking super gnarly. We have to construct a binding from scratch, which is not something you usually have to do in SwiftUI, and we have to repeat the enum destructuring work in the destination closure. Wouldn’t it be nicer if there was an API that was specifically tuned for matching a particular case in an optional enum like we have for navigation links, sheets, and popovers?

38:01

Well, luckily for us it is quite straightforward to implement. We can even copy-and-paste the signature from the sheet API to give us a starting point: extension View { public func sheet<Value, Content: View>( unwrapping value: Binding<Value?>, onDismiss: (() -> Void)? = nil, @ViewBuilder content: @escaping (Binding<Case>) -> Content ) -> some View { self.sheet(isPresented: value.isPresent(), onDismiss: onDismiss) { Binding(unwrapping: value).map(content) } } public func sheet<Enum, Case, Content: View>( unwrapping enum: Binding<Enum?>, case casePath: CasePath<Enum, Case>, onDismiss: (() -> Void)? = nil, @ViewBuilder content: @escaping (Binding<Case>) -> Content ) -> some View { self.sheet(unwrapping: enum.case(casePath), onDismiss: onDismiss, content: content) } }

39:02

And we can get rid of the onDismiss argument because navigationDestination doesn’t have that concept like sheets do, and we will rename to navigationDestination : extension View { public func navigationDestination<Value, Content: View>( unwrapping value: Binding<Value?>, @ViewBuilder content: @escaping (Binding<Case>) -> Content ) -> some View { self.navigationDestination(isPresented: value.isPresent()) { Binding(unwrapping: value).map(content) } } public func navigationDestination<Enum, Case, Content: View>( unwrapping enum: Binding<Enum?>, case casePath: CasePath<Enum, Case>, onDismiss: (() -> Void)? = nil, @ViewBuilder content: @escaping (Binding<Case>) -> Content ) -> some View { self.navigationDestination(unwrapping: enum.case(casePath), content: content) } }

39:38

And that’s all it takes. If you want to know how we could have arrived at the same API by starting from first principles then we highly recommend you watch our past navigation episodes .

39:49

With that done we can now massively simplify the call site of navigationDestination by making it clear that we want to navigate when the destination becomes non- nil and matches the edit case of the Destination enum: .navigationDestination( unwrapping: self.$model.destination, case: /InventoryModel.Destination.edit ) { $itemModel in ItemView(model: itemModel) … }

40:45

And everything works exactly as it did before.

40:55

Before moving on, let’s fix our tests. We’ve made a substantial change to the inventory model and currently the tests are not compiling.

41:07

We can actually simplify the test quite a bit now. We can now simulate the user tapping on the first row by invoking the rowTapped method on the row model: model.inventory[0].rowTapped()

41:18

And when that happens we expect the inventory model’s destination to flip to the edit case: let editItemModel = try XCTUnwrap( (/InventoryModel.Destination.edit).extract(from: XCTUnwrap(model.destination)) )

41:36

And then we can commit the edit by invoking the commitEdit method on the model: await model.commitEdit()

41:43

With just those few changes everything is compiling and tests pass. And if we run this in the simulator it seems to behave exactly as it did before.

41:52

Now one strange thing is we have the edit screen being powered by this new navigationDestination view modifier, but the color picker drill down is still being powered by the old, and now deprecated, NavigationLink . It still works, which means it seems that the deprecated NavigationLink s somehow work even in NavigationStack s, but let’s get rid of this deprecated symbol by converting it over to our new navigationDestination helper.

42:24

To do this we will swap out the navigation link for a button that calls a method when tapped: Button { self.model.colorPickerButtonTapped() } label: { … }

42:52

Instead of calling the old setColorPickerNavigation(isActive:) method, which was named like that specifically because we were using a NavigationLink , we will now go with a simpler colorPickerButtonTapped . And all it needs to do is point the destination to there colorPicker case: func colorPickerButtonTapped() { self.destination = .colorPicker }

43:07

And then we can use our fancy new navigationDestination view modifier to describe that we want to navigate to the color picker when the destination state turns non- nil and matches the colorPicker case: .navigationDestination( unwrapping: self.$model.destination, case: /ItemModel.Destination.colorPicker ) { _ in ColorPickerView(color: self.$model.item.color) }

44:02

Now things are building, and the application works how we expect. We can drill down to the edit view, then the color picker, make a change, and then hit save to see that the changes are reflected in the inventory list. A bug and a workaround

44:18

So, everything seems to work exactly has it did before, but we are now using modern, non-deprecated SwiftUI APIs.

44:24

Well, unfortunately there’s a problem, and it has to do with deep linking.

44:36

Let’s try starting the application in a state where we are drilled down into the edit screen for the first item: let keyboard = Item(name: "Keyboard", color: .blue, status: .inStock(quantity: 100)) @main struct InventoryApp: App { let model = AppModel( inventoryModel: InventoryModel( destination: .edit(ItemModel(item: lastItem)), inventory: [ … ItemRowModel( item: lastItem ), ] ), selectedTab: .inventory ) … }

45:03

If we launch the app we will see that we are still on the inventory screen. Not the edit screen. And worse, tapping on any row in the list does nothing. This is an even more broken state of navigation than anything we saw before.

45:25

And sadly it just seems like yet another bug in SwiftUI navigation, but this time in the shiny new APIs. We really hoped this would be fixed before the iOS 16 release, but it wasn’t, and it’s a real bummer.

45:37

Well, luckily for us we partially fix it. It fixes one of the most pernicious bugs in SwiftUI, but there are still a few other, smaller issues that will remain unfixed.

45:51

It seems that SwiftUI has a problem with a view being created with state that tells it to perform an immediate drill down. So, maybe we can start the view in a state where that is not true, and then immediately flip the binding to tell SwiftUI to drill down.

46:14

In order to accomplish this we need to manage some internal state, and so we are going to need a dedicate ViewModifier conformance for this. We can’t just return a view from our navigationDestination method because there’s no way to introduce new state inside there.

46:32

I don’t think we’ve ever once made a view modifier from scratch on Point-Free, but it’s straightforward. We’ll start with a new type that conforms to the ViewModifier protocol and a stub of its one requirement: private struct _NavigationDestination: ViewModifier { func body(content: Content) -> some View { } } We are going to underscore the type because it can be completely private and people using the API never need to construct it directly.

46:56

The purpose of the body method is to transform an existing content view into a new view. You can wrap the content in new view containers and chain modifiers to the content.

47:13

We basically want to do the work we are currently doing in our navigationDestination inside this method, and applied to the content view: func body(content: Content) -> some View { content .navigationDestination(isPresented: enum.case(casePath).isPresent()) { Binding(unwrapping: enum.case(casePath)).map(destination) } }

47:30

In order for this to work we need the view modifier type to hold onto a bunch of this data. For example, the enum binding, the casePath , and the destination function, which also means it needs generics: private struct _NavigationDestination<Value, Destination: View>: ViewModifier { let value: Binding<Value?> @ViewBuilder let destination: (Binding<Value>) -> Destination … }

48:11

That gets things compiling, and this is the type we want to use in our navigationDestination method rather than doing the work directly inside that method: public func navigationDestination<Value, Content: View>( unwrapping value: Binding<Value?>, @ViewBuilder destination: @escaping (Binding<Value>) -> Content ) -> some View { self.modifier( _NavigationDestination(value: value, destination: destination) ) }

48:41

Things are compiling, but we aren’t done yet. All this has done so far is move code from a view method to a view modifier. But now that we are in a proper ViewModifier conforming type, we can introduce some local state that helps us accomplish what we described a moment ago.

48:59

In particular, we can introduce some local @State that represents the true value of whether or not the navigation is presented and use that for the binding: struct _NavigationDestination<Value, Destination: View>: ViewModifier { … @State var isPresented = false func body(content: Content) -> some View { content .navigationDestination(isPresented: self.$isPresented) { Binding(unwrapping: value).map(destination) } } }

49:11

So, by default it will be false , which is what we think SwiftUI needs on first launch.

49:17

But then, a moment later when the view actually appears, we can overwrite it with the true value: @Binding var value: Value? … func body(content: Content) -> some View { content … .onAppear { self.isPresented = self.value != nil } }

49:38

That very small amount of time will give SwiftUI just enough time to get itself in order so that it is ready to see that the navigation should be presented.

50:07

But we now seemingly have two sources of truth for navigation. We’ve got the enum binding that is used in our model and we have this local isPresented state. When one changes we need to play it back to the other.

50:27

We can do this by listening to changes in each binding with the onChange view modifier, and when a change is detected play it back to the other binding: func body(content: Content) -> some View { content … .onAppear { self.isPresented = self.value != nil } .onChange(of: self.isPresented) { isPresented in if !isPresented { self.value = nil } } .onChange(of: self.value != nil) { isPresented in self.isPresented = isPresented } }

51:06

We’ve actually come across this kind of binding synchronization once before on Point-Free while discussing how to use @FocusState in SwiftUI. It should probably be extracted out into a library so that others can make use of it.

51:24

Let’s give this a spin.

51:26

If we run the application we will see it launches immediately to the edit screen. Already that’s an improvement to what we saw a moment ago. Further, we can make changes to the item, tap “Save”, and we will see all of those changes were applied to the inventory list.

51:47

Let’s try deep linking into a deeper part of the application. Like going into the edit screen and then the color picker: destination: .edit( ItemModel( destination: .colorPicker, item: keyboard ) ),

52:01

Running the app immediately puts us on the color picker. Selecting a color pops up back to the item view with that color changed. And finally hitting save pops us back to the inventory list with those changes applied.

52:12

This is pretty incredible. We are now using the fancy new navigationDestination view modifier instead of the deprecated NavigationLink view in order to drive navigation from state. We get to model our domains concisely using enums of all the different destinations that can be navigated to, and that includes any sheets, popovers, drill downs, etc. all in one single enum. And even better, those enums nest, so in order to navigate the user to a specific state we just have to constructed a nested piece of state, hand it off to SwiftUI, and let it do its thing.

52:57

And if that wasn’t good enough, with a few tricks we are even able to fix the bugs that plagued the old navigation APIs. We can launch the application in a deeply nested screen, and SwiftUI does the right thing. It doesn’t freeze up or incorrectly pop screens off the stack. Next time: stack bindings

55:47

So by using this new API we are solving a huge bug that plagued navigation links in iOS 15 and earlier. So this is a huge win, but we must caveat that there are still a few bugs and quirks even with the fix we have applied. They are not nearly as pernicious as what we saw with navigation links, and there are work arounds to the bugs, but still we want to make it clear that this is not a universal solution.

56:16

So, this is all looking fantastic, and in our opinion, this one of the best ways of dealing with navigation in SwiftUI. You get to model your navigation state as a tree of nested enums, so it is very concise and exact. You can deep link any number of levels, and URL deep linking is also quite easy. We haven’t fixed the router that we built previously, but it’s possible and we leave that as an exercise for the viewer.

56:43

So, things are looking pretty good, and we could maybe stop right now to rest on our laurels, but there’s actually still one more tool to discuss, and in fact it’s the one that I think everyone was really excited about when announced at WWDC. And that’s the binding initializer for NavigationStack , which allows you to drive navigation from an array of data.

57:01

This form of navigation is incredibly powerful and allows you to even more fully decouple screens, as well as support navigation flows that are difficult, if not impossible, to do with tree-based state modeled on nested enums. However, with this power comes tradeoffs and new complexities, and so you must still consider whether you truly need all of that power.

57:20

Let’s take a look at this last new tool of SwiftUI navigation in iOS 16. 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 Downloads Sample code 0212-navigation-stacks-pt2 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 .