Video #166: SwiftUI Navigation: Links, Part 2
Episode: Video #166 Date: Nov 1, 2021 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep166-swiftui-navigation-links-part-2

Description
Let’s explore “tag” and “selection”-based navigation links in SwiftUI. What are they for and how do they compare with the link and link helpers we’ve used so far? We will then take a step back to compare links with all of the other forms of navigation out there and propose a “Grand Unified Theory of Navigation.”
Video
Cloudflare Stream video ID: 886f2c34d51a96c81e2ebe854ed115c4 Local file: video_166_swiftui-navigation-links-part-2.mp4 *(download with --video 166)*
References
- Discussions
- SwiftUI Navigation
- WWDC 2021: Demystifying SwiftUI
- 0166-navigation-pt7
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
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.
— 0:35
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.” Tag and selection-based links
— 0:56
If we look at its signature: init<V>( tag: V, selection: Binding<V?>, @ViewBuilder destination: () -> Destination, @ViewBuilder label: () -> Label ) where V: Hashable
— 1:04
We’ll see that the tag is just some Hashable value, the selection is a binding to an optional Hashable value of the same type as the tag, and then the destination and label are the same as the other initializers, just simple closures that take no arguments and return some view.
— 1:22
To use this form of navigation link you associate some kind of tag value with the link, which could be an enum that enumerates each type of link on the screen or could be some kind of object identifier, and then you provide a binding. When the binding changes to a non- nil value matching the tag specified, the navigation is activated. And once the binding changes to nil or something not matching the tag the navigation is deactivated.
— 1:45
Interestingly this API does have some similarities with the TabView we saw in the first episode of this series, where you provide the view a selection binding and then .tag each view to identify it.
— 2:01
This initializer for NavigationLink is supposed to be well-suited for situations where you have multiple things you may want to navigate to on a single screen. If you were forced to use the boolean binding version of NavigationLink then you can accidentally introduce an explosion of invalid states to your domain. For example, if you had 5 navigation links on the screen modeled by five booleans in state, then there would be 2^5=32 different states modeled of which only 6 are valid: either all are false or exactly one is true.
— 2:29
So, it’s nice that SwiftUI offers us another option for constructing more complex navigation links, but it’s also not super helpful for the problems we have faced while building our app. It does not allow to peel off a bit of behavior from our view model and hand it down to the destination. In fact, the destination closure does not take any arguments, which means it has no way to be dynamic with respect to the event that causes the navigation to activate. You will be stuck with holding non-optional state in your view model to pass along, which as we’ve seen over and over again in this series of episodes leads to non-optimally modeled domains.
— 3:04
And although this initializer is supposed to be geared towards supporting multiple navigation links in a single view, we’ve already created a tool that allows us to do exactly that, and doesn’t have the downside we just mentioned. The NavigationLink.init(unwrap:) initializer we created is completely flexible in allowing us to construct multiple navigation links from a single route enum. In fact, if we wanted to construct links for the .duplicate or .deleteAlert it’s as simple as matching a different case on the view model’s route binding: NavigationLink( unwrap: $viewModel.route.case(/ItemRowViewModel.Route.duplicate), onNavigate: { _ in }, destination: { $item in }, label: { Text("Duplicate") } ) NavigationLink( unwrap: $viewModel.route.case( /ItemRowViewModel.Route.deleteAlert ), onNavigate: { _ in }, destination: { $item in }, label: { Text("Delete") } )
— 3:56
And the Route didn’t even need to be Hashable . 😆
— 4:05
Another popular use for the tag:selection: version of NavigationLink is embedding links in each row of a list. What you can do is make the tag an identifier for the row, and then selection is a binding the parent list holds onto that represents which row has an active navigation. This can be handy, but also we have exactly this situation in our list and yet we didn’t need this tool. This is entirely because we modeled a completely separate domain for each row of the list, which has its own view model and Route enum.
— 4:34
Now, there is one non-optimal thing about how we have structured the routes to be fully localized in each row of the list. It means that multiple rows can have an active route. For example, in the entry point of the application we could make it so that both the first and second rows have their delete alerts showing: .init(item: keyboard, route: .deleteAlert), .init( item: Item( name: "Charger", color: .yellow, status: .inStock(quantity: 20) ), route: .deleteAlert ),
— 4:55
When we launch the application we get an alert, and as soon as we dismiss that alert another shows up.
— 5:07
The tag:selection: initializer of NavigationLink aims to solve this by having the parent domain, in this case the inventory list, hold onto a single binding and pass that to each child. Then each child can observe the binding to trigger navigation, but it prevents multiple navigation links from being activated at the same time.
— 5:26
Let’s see what happens if we move some of the route domain modeling for the row into the inventory list domain, which should allow us to guarantee that only a single route is active amongst all of the rows. Currently the inventory list domain has only a single route, which is the add item modal, and so it’s modeled as just a simple optional: @Published var itemToAdd: Item?
— 5:45
Let’s beef this up to be a proper Route enum that has a case for adding as well as a case for all of the routes in a particular row: // @Published var itemToAdd: Item? @Published var route: Route? enum Route { case add(Item) case row(id: ItemRowViewModel.ID, route: ItemRowViewModel.Route) }
— 6:38
We’ll have to update the initializer for the InventoryViewModel : init( inventory: IdentifiedArrayOf<ItemRowViewModel> = [], route: Route? = nil ) { self.inventory = [] self.route = route for itemRowViewModel in inventory { bind(itemRowViewModel: itemRowViewModel) } }
— 6:49
And then there are a few spots in the view model where we were dealing with an optional item to drive the modal and now we want to deal with the Route enum. For example, the add(item:) and cancelButtonTapped methods need to nil out the route: func add(item: Item) { withAnimation { bind(itemRowViewModel: .init(item: item)) route = nil } } func cancelButtonTapped() { route = nil }
— 7:02
In the addButtonTapped method we have some stubbed out future code that emulates us doing a bunch of advanced AI and ML work in order to predict the item you want to add. That used to be as easy as mutating the itemToAdd directly, but now we have got to go through the Route enum. Luckily the case paths library gives us a tool to make that easy: func addButtonTapped() { route = .add( .init(name: "", color: nil, status: .inStock(quantity: 1)) ) Task { @MainActor in try await Task.sleep(nanoseconds: 500 * NSEC_PER_MSEC) try (/Route.add).modify(&route) { $0.name = "Bluetooth Keyboard" } } }
— 7:50
Next we have an compiler error in the view when trying to derive a binding for the item to add so that we can hand it to the .sheet API. We just need to transform the route binding to further match on the .add case of the Route enum: .sheet( unwrap: $viewModel.route.case(/InventoryViewModel.Route.add) ) { $itemToAdd in
— 8:06
And finally we just have an error in our preview because we are specifying itemToAdd instead of route : route: nil
— 8:17
Now everything is building, but of course this isn’t quite right yet. In fact, when we run things, we are still routed to 2 rows at the same time, where we see 2 delete alerts, one after the other.
— 8:29
The add item functionality works just fine, though, and we can still deep link into it from the entry point of the application using the new route argument of the initializer: ContentView( viewModel: .init( inventoryViewModel: .init( inventory: [ .init(item: keyboard), .init( item: Item( name: "Charger", color: .yellow, status: .inStock(quantity: 20) ) ), .init( item: Item( name: "Phone", color: .green, status: .outOfStock(isOnBackOrder: true) ) ), .init( item: Item( name: "Headphones", color: .green, status: .outOfStock(isOnBackOrder: false) ) ), ], route: .add( Item(name: "", color: nil, status: .inStock(quantity: 1)) ) ), selectedTab: .inventory ) )
— 8:43
However, if we try a route associated to a row from the inventory’s route, like say the delete alert, it does not work: route: .row(id: keyboard.id, route: .deleteAlert)
— 8:56
Even worse, the route is still exposed in the initializer for each row, so we could specify routes both at the inventory list level and the row level: inventoryViewModel: .init( inventory: [ .init(item: keyboard, route: .deleteAlert), .init(item: Item(...), route: .edit(editedKeyboard), … ], route: .row(id: keyboard.id, route: .deleteAlert) )
— 9:12
Now, we do want to keep the route in the row domain because that’s what allows that domain to be standalone and isolated. But we don’t want to it be fully independent from the route values in every other row. We somehow need to synchronize any change to a row’s route with the route in the inventory list, and vice versa.
— 9:29
This is actually a very common problem that one needs to solve when building applications in vanilla SwiftUI, and it was the topic of an entire series of episodes on Point-Free called “ Derived Behavior .” In those episodes we showed that one often needs to peel off a piece of behavior from a parent view model in order to hand down to a child view model, and in order for the parent and child domains to share behavior you must further synchronize pieces of state between them. There are a few gotchas with doing that, and so if you want to go really deep into that topic we suggest you watch those episodes.
— 9:59
But let’s give it a shot from scratch now. To begin with, let’s remove the route argument from the ItemRowViewModel ’s initializer because it should no longer be possible to route using it. One should be using the route in the InventoryViewModel instead: init(item: Item) { self.item = item }
— 10:11
Next, we need to update the bind method on InventoryViewModel to do some extra work. It is responsible for configuring an ItemRowViewModel so that it can be added to the inventory collection, and to do that we currently set up the onDelete and onDuplicate hooks to implement that logic.
— 10:27
We now further need to add some synchronization logic so that changes to the inventory’s route is played to each row and changes to each row’s route is played back to the inventory’s route. The latter one is the easiest, so let’s start with it.
— 10:39
First of all, we can get a hold of all changes to the row’s route by simply accessing the itemRowViewModel.$route property, which gives us back a Published<ItemRowViewModel.Route?>.Publisher .
— 10:52
Since this field is a publisher we can .map on it in order to transform its output and wrap it up in an inventory Route : itemRowViewModel.$route .map { route in route.map { Route.row(id: itemRowViewModel.id, route: $0) } }
— 11:25
Unfortunately this introduces a return cycle since itemRowViewModel owns the map ’s closure and the map ’s closure now owns the itemRowViewModel . Since we only need access to the id we can just capture that one single value: itemRowViewModel.$route .map { [id = itemRowViewModel.id] route in route.map { Route.row(id: id, route: $0) } }
— 11:41
And that breaks the retain cycle. It’s a bummer to have to worry about things like this, but it’s just how it goes when dealing with reference types.
— 11:49
Now that we have a publisher of Route values we can just pipe its emissions directly into the InventoryViewModel ‘s route . In fact, because route is a @Published field there’s even a convenient way of doing this that avoids retain cycles and doesn’t need to keep around cancellable: itemRowViewModel.$route .map { [id = itemRowViewModel.id] route in route.map { Route.row(id: id, route: $0) } } .assign(to: &$route)
— 12:08
And that’s all there is to it.
— 12:09
Next we need to playback changes on the inventory’s route to the row’s route. This is a little trickier because we need to make sure the ids match before we change the row’s route. We’ll start by mapping on the $route publisher that we get in the InventoryViewModel by virtue of the fact that the property is @Published : $route .map { route in }
— 12:26
In here we want to make sure that the route is of the .row case, for those are the only routes that the row domain understands. If it is not of that case then it means we should nil out the row’s route: $route .map { route in guard case let .row(id: routeRowId, route: route) = route else { return nil } }
— 12:52
Further, even if we are a .row route we need to also make sure that the id from the route matches the id of the item in the row, and in order to prevent a retain cycle we again need to capture the id : $route .map { [id = itemRowViewModel.id] route in guard case let .row(id: routeRowId, route: route) = route, id == routeRowId else { return nil } }
— 13:15
And once we have done all of that work we can finally return the route and assign it to the row’s view model: $route .map { [id = itemRowViewModel.id] route in guard case let .row(id: routeRowId, route: route) = route, id == routeRowId else { return nil } return route } .assign(to: &itemRowViewModel.$route)
— 13:28
This finishes the synchronization logic and it is a bit gnarly. It requires some pretty nuanced understanding of when and how to playback route changes, and even worse, it’s technically not correct. If we run the application in the simulator we will see it hangs for awhile and then crashes.
— 14:05
Turns out we have an infinite loop in this code. When one of these publishers emits, say $route , it will write to itemRowViewModel.$route . But then itemRowViewModel.$route emits, causing us to write to $route . Which then causes $route to emit, and on and on and on.
— 14:27
This was a problem we discussed at length in our “Derived Behavior” series of episodes. It’s tricky, but the fastest way to fix this is to simply tack on a .removeDuplicates to each publisher change: itemRowViewModel.$route … .removeDuplicates() .assign(to: &$route) $route … .removeDuplicates() .assign(to: &itemRowViewModel.$route)
— 14:43
Which means we need to make the inventory view model Route enum Equatable : class InventoryViewModel: ObservableObject { … enum Route: Equatable { case add(Item) case row(id: ItemRowViewModel.ID, route: ItemRowViewModel.Route) } … }
— 14:51
Now the code compiles, but one thing that doesn’t seem to be working is deep linking. We clearly have a route filled in for the delete alert on the first row in the entry point of the application: route: .row(id: keyboard.id, route: .deleteAlert)
— 15:01
This is very subtle, but the publishers we subscribed to emit their current value immediately. And since each child ItemRowViewModel has a nil route they are each overwriting the route in the InventoryViewModel with nil .
— 15:15
The fix is easy enough, we just need to drop that first emission and only take future emissions: itemRowViewModel.$route .map { [id = itemRowViewModel.id] route in route.map { .row(id: id, route: $0) } } .removeDuplicates() .dropFirst() .assign(to: &$route)
— 15:31
Now when we launch the application we are immediately presented with the delete alert, and if we confirm deletion the first row animates away.
— 16:22
That completes our refactor to move some of the child’s routing domain into the parent. We certainly made the domain modeling more concise, in that now only a single route can be active across the entire list, but also implementing this logic came at a pretty significant technical cost. We had to implement nuanced synchronization logic: making sure to not accidentally create a retain cycle or an infinite loop, making sure to check ids so that we don’t accidentally apply routing to the wrong row, and even drop the first emissions from one side of the synchronization so that we don’t accidentally overwrite our route when we want to deep link.
— 16:59
We think it’s really up to you and your team whether all of these new complexities were worth the added conciseness in the domain. Previously, when each row held the source of truth of routing, the code was quite simpler, but we did allow for some non-sensical navigation states. You have to decide where you are willing to take a little bit of extra complexity and where you want simplicity. The Grand Unified Theory of SwiftUI Navigation
— 17:23
Interestingly, we were inspired to take on this refactor because the tag:selection: initializer on NavigationLink allows for one to have many links in a list where at most one link can be active at a time, but we never ended up using that initializer. There may be uses for it, but it seems like the tools we’ve built and the way we’ve modeled our domains it isn’t particularly helpful for us.
— 17:51
While the tag:selection: initializer of NavigationLink isn’t particularly helpful for us, and we prefer the tools we built, we can take some inspiration from its API design. It has interestingly separated two concepts that our tool smashed together into a strange conflation. The tag:selection: API allows us to first specify a domain from which we will take values to figure out when a navigation link is activated, and then a binding that holds the actual value. When the value in the selection binding matches the value in the tag the navigation is triggered.
— 18:26
It may be hard to see, but we do basically the same in our custom initializer, just all in one step. Providing the binding for the unwrap argument is done by first deriving a binding to the optional route (this is kind of like the selection argument from SwiftUI’s initializer), and then we transform it to match only against the case .duplicate (this is kind of like the tag argument):
— 18:43
Providing the binding for the unwrap argument is done by first deriving a binding to the optional route (this is kind of like the selection argument from SwiftUI’s initializer), and then we transform it to match only against the case .duplicate (this is kind of like the tag argument): NavigationLink( unwrap: $viewModel.route.case(/ItemRowViewModel.Route.duplicate), … )
— 19:09
Perhaps we can improve this API by separating these two concepts. It would make it easier for users to know what they need to do to properly use the API. The NavigationLink.init(unwrap:) initializer will still be a handy overload to have around for when navigation is driven off of a non-enum optional, but for the more advanced, enum form of navigation domain modeling I think it will be helpful to separate the concepts.
— 19:38
Let’s give this a shot. Although we are going to take inspiration from the tag:selection: initializer we aren’t going to use its naming convention. We feel that would unnecessarily conflate our tools with Apple’s tools, so instead we are going to give it name that is more similar to some of the other tools we built.
— 19:54
Let’s start by imagining what the call site might look like. What if we could provide the binding to the optional route enum as a dedicated argument, and then an additional argument to specify which case of the route to look out for in order to trigger navigation, which past experience tells us will probably need to be a case path. We additionally need an argument for us to hook into when navigation is activated and deactivated, which allows us to create the state necessary to drive the navigation: NavigationLink( unwrap: $viewModel.route, case: /ItemRowViewModel.Route.edit, onNavigate: viewModel.setEditNavigation(isActive:), destination: { $item in … } ) This API looks quite similar to many of the other tools we have developed. At its core it transforms a binding of an optional into an honest binding, which is then handed to the destination view. The main difference is that we also need a case path to aid in that transformation and the onNavigate hook.
— 20:15
So, let’s see if we can implement this initializer. We can get a signature in place. It will be similar to the NavigationLink.init(unwrap:) we implemented previously, but this time it will need a generic for the route enum and the case we want to isolate from that enum: extension NavigationLink { init<Enum, Case, WrappedDestination>( unwrap optionalValue: Binding<Enum?>, case casePath: CasePath<Enum, Case>, onNavigate: @escaping (Bool) -> Void, @ViewBuilder destination: @escaping (Binding<Case>) -> WrappedDestination, @ViewBuilder label: @escaping () -> Label ) where Destination == WrappedDestination? { } }
— 20:52
To implement this we can call to our existing initializer, we just need to transform the binding before passing it to unwrap : init( unwrap: optionalValue.case(casePath), onNavigate: onNavigate, destination: destination, label: label )
— 21:16
That’s it! The entire project is back in compiling order, and if we were to run the application everything would work just as it did before.
— 21:27
And you know what, this new API for constructing navigation links is really nice. We really like how it separates the idea of specifying the route that drives the navigation from specify the case in the route that we listen for to know when to trigger navigation.
— 21:40
In fact, we like it so much that we think some of our other navigation helpers could be made better by implementing this style. For example, more advanced .sheet and .popover helpers could be defined like so: extension View { func sheet<Enum, Case, Content>( unwrap item: Binding<Enum?>, case casePath: CasePath<Enum, Case>, @ViewBuilder content: @escaping (Binding<Case>) -> Content ) -> some View where Case: Identifiable, Content: View { sheet(item: item.case(casePath)) { _ in if let value = Binding(unwrap: item.case(casePath)) { content(value) } } } func popover<Enum, Case, Content>( unwrap item: Binding<Enum?>, case casePath: CasePath<Enum, Case>, @ViewBuilder content: @escaping (Binding<Case>) -> Content ) -> some View where Case: Identifiable, Content: View { popover(item: item.case(casePath)) { _ in if let value = Binding(unwrap: item.case(casePath)) { content(value) } } } }
— 22:26
Heck, even the .alert and .confirmationDialog helpers we wrote many episodes ago can fit into this API design: extension View { func alert<A: View, M: View, Enum, Case>( title: (Case) -> Text, unwrap data: Binding<Enum?>, case casePath: CasePath<Enum, Case>, @ViewBuilder actions: @escaping (Case) -> A, @ViewBuilder message: @escaping (Case) -> M ) -> some View { alert( title: title, presenting: data.case(casePath), actions: actions, message: message ) } func confirmationDialog<A: View, M: View, Enum, Case>( title: (Case) -> Text, unwrap data: Binding<Enum?>, case casePath: CasePath<Enum, Case>, @ViewBuilder actions: @escaping (Case) -> A, @ViewBuilder message: @escaping (Case) -> M ) -> some View { confirmationDialog( title: title, presenting: data.case(casePath), actions: actions, message: message ) } }
— 23:26
This would greatly simplify some of our call sites where we need to drive an alert, dialog, sheet or a popover, such as where we show a popover in the row view: .popover( unwrap: $viewModel.route, case: /ItemRowViewModel.Route.duplicate ) { $item in … }
— 23:50
But most importantly it completely unifies pretty much every type of navigation we have considered so far in this series. For example, our .sheet helper can be thought of as a transformation that takes a binding of an optional enum, a case path for extracting a particular case from that enum, and a function that accepts bindings of the case and returns some view: .sheet: (Binding<E?>, CasePath<E, C>, (Binding<C>) -> some View) -> some View
— 24:24
Our new .popover helper looks basically the same: .popover: (Binding<E?>, CasePath<E, C>, (Binding<C>) -> some View) -> some View
— 24:29
As do our .alert and .confirmationDialog helpers: .alert: (Binding<E?>, CasePath<E, C>, (Binding<C>) -> some View) -> some View .dialog: (Binding<E?>, CasePath<E, C>, (Binding<C>) -> some View) -> some View
— 24:34
And even our NavigationLink helper looks the same, though it’s often treated as a fundamentally different form of navigation in SwiftUI: NavLink: (Binding<E?>, CasePath<E, C>, (Binding<C>) -> some View) -> NavLink
— 23:55
And even the IfCaseLet view basically fits this shape, but it works on a binding of an honest enum, not an optional one. IfCaseLet: (Binding<E>, CasePath<E, C>, (Binding<C>) -> some View) -> IfCaseLet
— 25:20
All together: .sheet: (Binding<E?>, CasePath<E, C>, (Binding<C>) -> some View) -> some View .popover: (Binding<E?>, CasePath<E, C>, (Binding<C>) -> some View) -> some View .alert: (Binding<E?>, CasePath<E, C>, (Binding<C>) -> some View) -> some View .dialog: (Binding<E?>, CasePath<E, C>, (Binding<C>) -> some View) -> some View NavLink: (Binding<E?>, CasePath<E, C>, (Binding<C>) -> some View) -> NavLink IfCaseLet: (Binding<E>, CasePath<E, C>, (Binding<C>) -> some View) -> IfCaseLet
— 25:21
This shows the grand unified theory of navigation patterns in SwiftUI. No matter how different these forms of navigation seem, at their core they are all basically doing the same thing. We’re seeing over and over that navigation boils down to:
— 25:37
first, a domain modeling problem, where we model mutually exclusive screens as a simple enum
— 25:43
second, a domain transformation problem, where we cook up tools to transform bindings of enums into bindings of their case cases
— 25:51
third, and finally, a view API design problem, where we craft initializers or view modifiers that work with our tools to allow us to express exactly how our domain controls navigation in the view.
— 26:05
These tools allow you to model all possible navigation in a particular feature as one single enum with associated data, and from there you can spin up any kind of navigation you want for each case. Your route enum could hold a case for a couple of different alerts, as well as a sheet and a popover, and also maybe a few links. You would have just one single field in your view model to trigger a navigation link, and it would be impossible to represent invalid navigation states, such as two alerts being shown at the same time, or a sheet and a popover. Testing the edit feature
— 27:03
Further, as we’ve seen a few times already, the more we model our application’s state in a single package, the more chances we have to write comprehensive tests that assert on deep properties of the application and how multiple parts of the app interact with each other.
— 27:11
We’ve already written a few tests for the application, but our many refactors have probably broken them, so let’s see what we need to get them building and passing again.
— 27:28
We only have a couple build errors, and that’s where we were testing the add functionality, which is now driven by a route enum instead of an optional: func testAddItem() throws { let viewModel = InventoryViewModel() viewModel.addButtonTapped() // let itemToAdd = try XCTUnwrap(viewModel.itemToAdd) let itemToAdd = try XCTUnwrap( (/InventoryViewModel.Route.add) .extract(from: XCTUnwrap(viewModel.route)) ) viewModel.add(item: itemToAdd) // XCTAssertNil(viewModel.itemToAdd) XCTAssertNil(viewModel.route) XCTAssertEqual(viewModel.inventory.count, 1) XCTAssertEqual(viewModel.inventory[0].item, itemToAdd) }
— 28:22
Everything’s now building, still passing, as well! But we set up some pretty nuanced synchronization logic between the inventory domain and row domain, so let’s make sure our tests assert that this logic is working. For instance, in the test for deleting an item, let’s assert that the parent view model’s route is synchronized with the row’s: viewModel.inventory[0].deleteButtonTapped() XCTAssertEqual(viewModel.inventory[0].route, .deleteAlert) XCTAssertEqual( viewModel.route, .row(id: viewModel.inventory[0].id, route: .deleteAlert ) viewModel.inventory[0].deleteConfirmationButtonTapped() XCTAssertEqual(viewModel.inventory[0].count, 0) XCTAssertEqual(viewModel.route, nil) XCTAssertEqualFailed: “InventoryViewModel.Route.row(…)” does not equal “nil”
— 28:22
Well it looks like we have a small bug! When we confirm deletion, the row is removed, but nothing nil s out the route. If we look at deleteConfirmationButtonTapped : func deleteConfirmationButtonTapped() { onDelete() }
— 29:42
We’ll see we’re notifying the parent we should be deleted, but we’re not nil -ing out our own route. So let’s fix that: func deleteConfirmationButtonTapped() { onDelete() route = nil }
— 30:04
And now everything’s now building passing!
— 30:19
We could improve the test for duplicating an item, as well, but let’s write a brand new test instead, this time for the edit flow. We can start the view model off in a state with a single item in the inventory: func testEditItem() { let item = Item( name: "Keyboard", color: .red, status: .inStock(quantity: 1) ) let viewModel = InventoryViewModel(inventory: [.init(item: item)]) }
— 30:33
We will then emulate the user tapping the row corresponding to that item: viewModel.inventory[0].setEditNavigation(isActive: true)
— 30:49
After that occurs we expect the route to switch to the .edit case and hold the item. It’s a little verbose to do this without making the Route enum equatable, but we can do it using case paths by trying to extract the edit case: XCTAssertNotNil( (/ItemRowViewModel.Route.edit) .extract(from: viewModel.inventory[0].route) )
— 31:13
This test passes which means we are correctly updating the route .
— 31:16
Next we can simulate making some edits to the item by re-setting the route value in the row’s view model: var editedItem = item editedItem.color = .blue viewModel.inventory[0].route = .edit(editedItem)
— 31:34
And then finally we can emulate committing the edits to the row view model, which should cause the route to nil out and should update the item held in the row: viewModel.inventory[0].edit(item: editedItem) XCTAssertNil(viewModel.inventory[0].route) XCTAssertNil(viewModel.route) XCTAssertEqual(viewModel.inventory[0].item, editedItem)
— 32:00
We unfortunately get a test failure: XCTAssertNil failed: “edit(…)”
— 32:06
This is because the edit endpoint is not synchronous. Recall that when the save button is tapped, the view model spins off some asynchronous work that waits a second before applying the edits and dismissing the item screen. We even have an isSaving boolean that flips to true, so we could maybe assert against that: XCTAssertEqual(viewModel.inventory[0].isSaving, true)
— 32:37
We can then await the amount of time it takes for edit to do its job, and assert that we are no longer saving, we just need to beef our test up to be async throws : func testEdit() async throws { … try await Task.sleep(nanoseconds: NSEC_PER_SEC) XCTAssertEqual(viewModel.inventory[0].isSaving, false) XCTAssertNil(viewModel.inventory[0].route) XCTAssertEqual(viewModel.inventory[0].item.color, .blue) }
— 33:04
When we run the test, though, it still fails. This is because even though we kick the edit work off first, and wait the same amount of time, it’s not enough to get things to pass. We have to wait an additional 100 milliseconds or so to get a passing test: try await Task.sleep(nanoseconds: NSEC_PER_SEC + 100*NSEC_PER_MSEC)
— 33:29
But 100 milliseconds may not even always be enough, if my computer is taxed or we have a CI with fewer resources, so we may even need to bump that to 200 or more.
— 33:55
This is the exact same problem we encountered back when we attempted to write a test for SwiftUI’s new “refreshable” API . We wanted to assert that a boolean flipped to true when awaiting a refresh, and the false again when the refresh completed. There too we had to wait small amounts of time to get things passing, and we didn’t have a whole lot of confidence in those assertions.
— 34:15
One thing we could do to maybe make it a little more predictable is to beef up the edit endpoint to be async. Then we could simply wait for it to complete. And so we should now have a little bit of faith that the view model roughly works how we expect.
— 34:23
For example, if we hopped over to the .edit(item:) method in the view model and commented out the line that nil s out the route : item = item // route = nil
— 34:30
We instantly get a test failure: XCTAssertNil failed: “edit(…)”
— 34:33
This shows that if when committing the save we are not clearing out the route, which means we will not pop that screen off the navigation stack. That would be a serious bug, and it’s nice that we can write a test that captures some of that behavior. Next time: adding behavior to the item view
— 34:48
So, this is all looking really promising. We have an incredible amount of power in deep linking and testing in our application. When we first started discussing navigation links earlier in this episode we mentioned that they are the most prototypical form of navigation, but also at the same time the most “complicated.”
— 35:03
However, we just introduced a pretty complex navigation link for editing an item, and even showed how we could gate the navigation based on asynchronous effects. This is some of the most complicated navigation you can do in an iOS application, and not only did we accomplish it pretty quickly, but we also got deep linking for free along the way. To contrast with modal sheets and popovers, it took us three very long episodes to develop the techniques that allowed us to model them in state and support deep linking.
— 35:39
Perhaps this form of navigation isn’t so complicated after all?
— 35:43
Well, it definitely is, but it’s come much easier to us thanks to all of the tools we built in previous episodes. The only reason we are able to model our navigation routes as a simple enum and implement a NavigationLink initializer that transforms binding of optionals into bindings of honest values is thanks to all the binding transformation helpers we have defined in past episodes.
— 36:05
So, we’re starting to see that by putting in a little bit of upfront work to try to put structs and enums on the same footing when it comes to binding transformational operators, we unlock powerful tools that help make navigation in SwiftUI a breeze. Just as dynamic member lookup allows us to slice of a binding for a piece of sub-state and hand it off to a child component, the .case transformation allows us to slice off a binding for a particular case of an enum and hand it off to a child component. And when you combine that with navigation tools such as sheets, popovers, and links, you instantly unlock the ability to drive navigation off of state.
— 36:39
But let’s push things even further. Right now the ItemView has no behavior. It’s decently complex, especially since we are transforming the item status binding into bindings for each case of the status enum. But even so, there’s no real behavior in the screen. We aren’t executing any asynchronous work or side effects. The SwiftUI view is just reading from bindings and writing to bindings.
— 37:04
This has worked well for us so far, but as soon as we want to introduce some behavior to the view, and do so in a testable way and in a way that allows for deep linking, we can no longer get away with using simple bindings. We must use an observable object. We’ve already got two observable object view models in this project: one for the inventory list feature and one for the row of the list.
— 37:27
We’re going to introduce yet another view model, this time for the item view. In order to justify this new view model we are going to add some complex behavior to the screen. We are going to add some validation logic to the form, and we’re going to make the color picker more robust by having it load additional colors from an asynchronous effect.
— 37:45
Let’s start by trying to implement these features without a view model and show where things go wrong…next time! 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 0166-navigation-pt7 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 .