Video #167: SwiftUI Navigation: Links, Part 3
Episode: Video #167 Date: Nov 8, 2021 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep167-swiftui-navigation-links-part-3

Description
Over the past weeks we have come up with some seriously powerful tools for SwiftUI navigation that have allowed us to more precisely and correctly model our app’s domain, so let’s exercise them a bit more by adding more behavior and deeper navigation to our application.
Video
Cloudflare Stream video ID: 23e8585e8f155fe10c2cf458ada42682 Local file: video_167_swiftui-navigation-links-part-3.mp4 *(download with --video 167)*
References
- Discussions
- SwiftUI Navigation
- WWDC 2021: Demystifying SwiftUI
- 0167-navigation-pt8
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
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.”
— 0:20
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.
— 0:56
Perhaps this form of navigation isn’t so complicated after all?
— 1:00
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.
— 1:22
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.
— 2:21
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.
— 2:44
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.
— 3:02
Let’s start by trying to implement these features without a view model and show where things go wrong. Item validation and a custom color picker
— 3:13
Suppose that as the user types into the item name text field we want to perform some asynchronous work in order to check if that name is a duplicate of an existing item. This would give the user quick feedback on whether or not their item is valid without having to submit the form.
— 3:35
However, since we don’t have an observable object in place, where should this logic go? How can we even listen for changes to the text field?
— 3:42
Well, SwiftUI comes with a handy view modifier called .onChange that allows one to execute some code when a value changes: TextField("Name", text: $item.name) .background( nameIsDuplicate ? Color.red.opacity(0.1) : Color.clear ) .onChange(of: item.name) { newName in // TODO: Validation logic }
— 4:11
And then in this closure we can perform our fancy validation logic, which for right now we will stub: .onChange(of: item.name) { newName in Task { @MainActor in // TODO: Validation logic await Task.sleep(NSEC_PER_MSEC * 300) } }
— 4:33
Then, when all that fancy validation logic completes we can update some local state in the view that says whether or not the field is valid. For now we’ll simulate the validation logic that “Keyboard” is a duplicate: struct ItemView: View { @Binding var item: Item @State var nameIsDuplicate = false var body: some View { Form { TextField("Name", text: $item.name) .background( nameIsDuplicate ? Color.red.opacity(0.1) : Color.clear ) .onChange(of: item.name) { newName in Task { @MainActor in await Task.sleep(NSEC_PER_MSEC * 300) nameIsDuplicate = newName == "Keyboard" } } … } } }
— 5:20
Now if we run the application, tap “Add”, and type in “Keyboard” we will see that after a very brief delay the field highlights red. And if we change the name the red goes away. So this is something, but of course we have stuffed quite a bit of logic in this view and there’s no way to test it.
— 5:52
So, that’s reason enough for us to introduce an observable object to this view, but we want to push it even further. Right now we are using the default picker view to allow choosing a color for the item. It works well enough, and it certainly gave us a lot of functionality with very little work, but it’s also not super customizable. We can’t change its design much. It’s just a plain list. If we wanted to group multiple rows into sections, like say one section for default colors and another section for user provided colors, we would be out of luck.
— 6:27
The only way to overcome this limitation is to introduce our own custom view rather than leaning on Picker . Fortunately it’s easy enough to do.
— 6:34
Let’s add a view that holds onto a binding of an optional color, that way it can communicate changes to the color back to the item view: struct ColorPickerView: View { @Binding var color: Item.Color? var body: some View { } }
— 6:51
In the body of this view we will have a button for choosing no color, and then separated from that row will be a list of all the default colors one can choose from. This wasn’t possible with the default picker view, but for us it’s quite straightforward: Form { Button(action: { color = nil }) { HStack { Text("None") Spacer() if color == nil { Image(systemName: "checkmark") } } } Section(header: Text("Default colors")) { ForEach(Item.Color.defaults, id: \.name) { color in Button(action: { self.color = color }) { HStack { Text(color.name) Spacer() if self.color == color { Image(systemName: "checkmark") } } } } } }
— 8:02
And then to navigate to this view we can just add a NavigationLink to the item view: NavigationLink( destination: ColorPickerView(color: $item.color) ) { HStack { Text("Color") Spacer() Text(item.color?.name ?? "None") .foregroundColor(.gray) } }
— 8:37
But to make things a bit fancier than what we could achieve with the picker view, let’s also render the currently selected color. NavigationLink( destination: ColorPickerView(color: $item.color) ) { HStack { Text("Color") Spacer() if let color = item.color { Rectangle() .frame(width: 30, height: 30) .foregroundColor(color.swiftUIColor) .border(Color.black, width: 1) } Text(item.color?.name ?? "None") .foregroundColor(.gray) } }
— 8:43
If we run the application we will see that it mostly works, but we’ve lost the behavior that when we select a color the picker view is automatically popped off the stack so that we are returned to the item view. We could thread through a binding to the optional route so that the picker view can nil out the route, but there’s a simpler way.
— 9:21
The SwiftUI environment allows you to dismiss the presentation of any view. In iOS 15 the value is called dismiss . It accomplishes the task by figuring out which binding is powering the presentation of the current view, and then writes false or nil to the binding in order to transition away. We can introduce this environment value to our view: struct ColorPickerView: View { @Binding var color: Item.Color? @Environment(\.dismiss) var dismiss … }
— 9:38
And then invoke it in our button action closures: Button(action: { color = nil dismiss() }) { … } … Button(action: { color = color dismiss() }) { … }
— 9:52
Now it behaves as we expect.
— 10:22
Now suppose that in addition to these default colors that we have immediately available to us in state we also had some additional, “new” colors that can be loaded from the server. We can simulate this by adding some local state to the view: struct ColorPickerView: View { … @State var newColors: [Item.Color] = [] … }
— 10:43
And then using iOS 15’s new .task view modifier to fire off some asynchronous work when the view first appears, which we will stub with a sleep and then hard code in an additional color: .task { await Task.sleep(NSEC_PER_MSEC * 500) newColors = [ .init(name: "Pink", red: 1, green: 0.7, blue: 0.7) ] }
— 11:26
Then we can create a new section to house these new colors once they are loaded: if !newColors.isEmpty { Section(header: Text("New colors")) { ForEach(newColors, id: \.name) { color in Button(action: { color = color dismiss() }) { HStack { Text(color.name) Spacer() if color == color { Image(systemName: "checkmark") } } } } } } Moving behavior to a view model
— 12:16
OK, so we’ve now introduced two new features to our item view, involving nuanced logic and asynchronous work.
— 12:30
But, this isn’t how we would want to build these features typically.
— 12:33
As we’ve seen time and time again on our series of episodes on navigation, when we resort to less powerful APIs such as local @State and fire-and-forget navigation links, we lose the ability to deep link into any state of our application and the ability to easily test our application.
— 12:50
If those two things are important to you and your team, then you have no choice but to introduce an observable object, properly model your domains, and integrate child and parent domains so that everything can speak to each other.
— 13:02
So, let’s do just that.
— 13:04
We are going to upgrade the lowly @Binding in the ItemView to be a proper observable object, which will make it the perfect place to put all of this new logic and asynchronous work. That will instantly unlock deep linking capabilities for us, and it will allow us to write tests.
— 13:21
Let’s start by getting a basic view model into place. Right now the ItemView operates entirely from just a simple binding: struct ItemView: View { @Binding var item: Item … }
— 13:30
We will replace that binding with a view model, which for right now is just going to wrap a single published field for the item: class ItemViewModel: ObservableObject { @Published var item: Item init(item: Item) { self.item = item } }
— 14:04
And then we will use an @ObservedObject in the view: struct ItemView: View { @ObservedObject var viewModel: ItemViewModel … }
— 14:12
And that will force us to make a lot of tiny updates to the view because we now need to go through the viewModel field in order to access the item.
— 14:33
Next we have a compiler error in the preview because we were doing a dance with a wrapper view in order to introduce some state from which a binding could be derived, but now that we are using an observable object we no longer need to do that: struct ItemView_Previews: PreviewProvider { static var previews: some View { NavigationView { ItemView(viewModel: .init(item: .init(name: "", color: nil, status: .inStock(quantity: 1)))) } } }
— 15:08
This file is now compiling, but there are more errors to fix. Let’s start with the inventory view. There’s one error that says we are trying to use an initializer that does not exist: ItemView(item: $itemToAdd) Extra argument ‘item’ in call Missing argument for parameter ‘viewModel’ in call
— 15:23
We instead need to call the .init(viewModel:) initializer, but to do that we need a view model to hand to it. The itemToAdd value we have in this closure is provided to us from the .add case of the Route enum: enum Route: Equatable { case add(Item) … }
— 15:40
So sounds like we need to hold an ItemViewModel in here instead of just a plain Item : enum Route: Equatable { case add(ItemViewModel) … }
— 15:47
This may seem a little strange to you. We’re now holding a reference type inside an enum. Is that an ok thing to do?
— 15:53
Well, not only is it ok, but it’s the absolutely the correct choice for our use case. Just as it was appropriate to store a bunch of view models in a collection when we needed to have a bunch of rows in a list with behavior, it is also appropriate to hold view models in an enum to represent a choice of many types of navigation to those screens.
— 16:18
Unfortunately, even though we think it is appropriate to do, it does come with some complications. First of which is that the Route enum no longer conforms to Equatable . This is because ItemViewModel doesn’t conform to Equatable .
— 16:32
Conforming references types to Equatable is very tricky. Since reference types are an amalgamation of data and behavior, it’s often not clear how to say whether two objects are equal. Two objects could hold the exact same data yet could be performing differently, for example one could be in the middle of performing some asynchronous work and the other could be doing nothing.
— 17:00
The main use for the Equatable conformance in this file is that we use it for the .removeDuplicates() logic down below, which allowed us to play changes back and forth between child and parent domains: $route .map { [id = itemRowViewModel.id] route in guard case let .row(id: routeRowId, route: route) = route, routeRowId == id else { return nil } return route } .removeDuplicates() .assign(to: &itemRowViewModel.$route)
— 17:12
So for the purposes of this logic we are only interested if the entire view model is swapped out for a new one. We don’t care about any of the minute changes to the data inside the view model, for we are just sharing the reference between the child and parent domains anyway.
— 17:28
So, for now, we can just get away with writing a custom Equatable conformance for Route that appeals to reference equality for the ItemViewModel : enum Route: Equatable { case add(ItemViewModel) case row(id: ItemRowViewModel.ID, route: ItemRowViewModel.Route) static func == (lhs: Self, rhs: Self) -> Bool { switch (lhs, rhs) { case let (.add(lhs), .add(rhs)): return lhs === rhs case let ( .row(id: lhsId, route: lhsRoute), .row(id: rhsId, route: rhsRoute) ): return lhsId == rhsId && lhsRoute == rhsRoute case (.add, .row), (.row, .add): return false } } }
— 18:40
The only other errors left are related to the fact that we now have to create ItemViewModel s in certain places rather than just plain Item s. For example, the addButtonTapped method needs to create a view model in order to set the route: func addButtonTapped() { route = .add( .init( item: .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.item.name = "Bluetooth Keyboard" } } }
— 19:17
Next, our usage of the .sheet(unwrap:) operator needs to change because the ItemView we are constructing inside the content closure now wants a view model instead of a binding of an item. We can make a few small changes to make this work: .sheet( unwrap: $viewModel.route.case(/InventoryViewModel.Route.add) ) { $itemViewModel in NavigationView { ItemView(viewModel: itemViewModel) .navigationTitle("Add") .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { viewModel.cancelButtonTapped() } } ToolbarItem(placement: .primaryAction) { Button("Save") { viewModel.add(item: itemViewModel.item) } } } } }
— 19:43
This requires ItemViewModel to conform to Identifiable , which is easy enough to do. class ItemViewModel: Identifiable, ObservableObject { … var id: Item.ID { item.id } … }
— 20:15
And now the sheet is building, but also, since we no longer need to transform the binding of the route into a binding of an item, we can actually stop using the .sheet(unwrap:) helper and go back to using SwiftUI’s regular .sheet(item:) API: .sheet( item: $viewModel.route.case(/InventoryViewModel.Route.add) ) { itemViewModel in NavigationView { ItemView(viewModel: itemViewModel) .navigationTitle("Add") .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { viewModel.cancelButtonTapped() } } ToolbarItem(placement: .primaryAction) { Button("Save") { viewModel.add(item: itemViewModel.item) } } } } }
— 20:52
This highlights one of the strange dichotomies between embracing bindings and observable objects in SwiftUI applications. In a sense, bindings are simpler to use because we can use plain value types, but SwiftUI doesn’t provide all the tools needed to transform them and we can’t layer on behavior in a testable manner. On the other hand, observable objects are in a sense simpler because we can use simpler SwiftUI tools, such as .sheet(item:) instead of our custom .sheet(unwrap:) helper, but then we have to deal with reference types, which means worrying about retain cycles, equatability, and more. It’s very tricky to know exactly when to use what tool.
— 21:28
The next error we have is in the ItemRowView where we are trying to construct NavigationLink to an ItemView given a binding of an item. This needs to be upgraded to a view model, but the binding is coming from a whole new Route enum. This is the route enum that lives inside the ItemRowViewModel , which describes how to navigate to the delete, edit, and duplicate views. This enum currently only holds onto plain Item structs, and so they need to be upgraded to view models: enum Route: Equatable { case deleteAlert case duplicate(ItemViewModel) case edit(ItemViewModel) }
— 22:08
But of course that breaks equatability, so we need to provide another custom conformance: enum Route: Equatable { case deleteAlert case duplicate(ItemViewModel) case edit(ItemViewModel) static func == (lhs: Self, rhs: Self) -> Bool { switch (lhs, rhs) { case (.deleteAlert, .deleteAlert): return false case let (.duplicate(lhs), .duplicate(rhs)): return lhs === rhs case let (.edit(lhs), .edit(rhs)): return lhs === rhs case (.deleteAlert, _), (.duplicate, _), (.edit, _): return false } } }
— 22:44
Our Route enum changes also breaks a few spots where we construct a route, which now needs to wrap the item in a view model: func setEditNavigation(isActive: Bool) { route = isActive ? .edit(.init(item: item)) : nil } … func duplicateButtonTapped() { route = .duplicate(.init(item: item.duplicate())) }
— 23:05
Next we can fix the navigation link: NavigationLink( unwrap: $viewModel.route.case(/ItemRowViewModel.Route.edit), onNavigate: viewModel.setEditNavigation(isActive:), destination: { $itemViewModel in ItemView(viewModel: itemViewModel) .navigationBarTitle("Edit") .navigationBarBackButtonHidden(true) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { viewModel.cancelButtonTapped() } } ToolbarItem(placement: .primaryAction) { HStack { if viewModel.isSaving { ProgressView() } Button("Save") { viewModel.edit(item: itemViewModel.item) } } .disabled(viewModel.isSaving) } } } )
— 23:39
Just like with sheet(unwrap:) we’re no longer using the $itemViewModel binding. But SwiftUI does not ship with a simpler NavigationLink initializer that we can switch over to, so we’ll leave things as is, and cooking up a simpler initializer will be left as an exercise for the viewer.
— 24:07
And the popover down below: .popover( item: $viewModel.route.case(/ItemRowViewModel.Route.duplicate) ) { itemViewModel in NavigationView { ItemView(viewModel: itemViewModel) .navigationBarTitle("Duplicate") .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { viewModel.cancelButtonTapped() } } ToolbarItem(placement: .primaryAction) { Button("Add") { viewModel.duplicate(item: itemViewModel.item) } } } } .frame(minWidth: 300, minHeight: 500) }
— 24:37
If we run the application everything should work exactly as it did before, but we now have a dedicated view model to power just the item view, which means we are ready to start layering on more logic and behavior in a manner that is friendly to testing and deep linking.
— 25:10
For example, take our validation logic. It’s decently complex in that it simulates us performing asynchronous work in order to check if the name of the item is a duplicate of an existing item, and it does all of that work in the view: .onChange(of: viewModel.item.name) { newName in Task { @MainActor in await Task.sleep(NSEC_PER_MSEC * 300) nameIsDuplicate = newName == "Keyboard" } } This means we’d have to fire up an entire UI test suite in order to test this logic.
— 25:27
But, now that we have a view model in place we can move this logic over there and then testing should be more straightforward.
— 25:32
To move this to the view model we need a way for listening to changes to the item’s name so that we can perform this logic. The de facto way to do this before iOS 15 was to subscriber to the $item publisher right in the initializer of the view model: init(item: Item) { self.item = item $item.sink { … } }
— 25:52
However, there’s a fancy new API in iOS 15 that lets you turn any publisher into an AsyncSequence , which means you can use simple for … in syntax to receive every update from the publisher: for await item in $item.values { }
— 26:17
Now in order to do that we need an asynchronous context, so we can wrap this in a new Task : Task { for await item in $item.values { } }
— 26:30
And when we receive a new value we will simulate performing some asynchronous work by sleeping for a few milliseconds, and then we will perform our rudimentary validation logic: await Task.sleep(NSEC_PER_MSEC * 300) nameIsDuplicate = item.name == "Keyboard"
— 26:54
But to do that we need to hold onto another published field for the nameIsDuplicate boolean: @Published var nameIsDuplicate = false
— 27:01
With that we can now drop the @State in our view for this field, drop the .onChange view modifier, and use the new field in the view model to drive the background color of the text field: TextField("Name", text: $viewModel.item.name) .background( viewModel.nameIsDuplicate ? Color.red.opacity(0.1) : Color.clear )
— 27:10
Alright, we’ve moved the logic up into the view model, but we need to make a couple additional changes before moving forward. First, because our task mutates a published field it must also run on the main action. And second, the memory management story for tasks isn’t quite clear yet, but we should probably weakify self . Task { @MainActor in … }
— 27:43
Now that this logic lives in the view model we at least have a shot at testing it. It still won’t be easy because we are performing asynchronous work in an uncontrolled manner, but with enough Task.sleep calls in the test we can do it.
— 27:59
Next let’s turn our attention to the color picker in the item view. There are two things wrong with how we have things now. First, we are using a fire-and-forget navigation link to navigate to the color picker, which means we have no way to deep link into this screen if we want to. And second, we are performing asynchronous work in the color picker view in order to load new items, which means that logic is not easily testable.
— 28:25
Let’s start with the asynchronous work. Right now we are performing this work directly in the view via the .task view modifier: .task { await Task.sleep(NSEC_PER_MSEC * 500) newColors = [ .init(name: "Pink", red: 1, green: 0.7, blue: 0.7) ] }
— 28:33
As we’ve seen time and again, we want to move this logic to the view model to make it testable. Right now the color picker is powered by a binding of an optional color and some local @State . Now we could introduce a whole new view model just for this screen and store it in the ItemViewModel , and in fact that’s probably the correct thing to do, but to keep things moving a lot we will not do that right now. Instead we encourage you to try it out as an exercise.
— 29:01
In lieu of introducing a new view model we will simply pass along the view model to this view: struct ColorPickerView: View { @ObservedObject var viewModel: ItemViewModel @Environment(\.dismiss) var dismiss … }
— 29:24
And to do that we need to update our NavigationLink to pass the view model along to the destination: NavigationLink( destination: ColorPickerView(viewModel: viewModel) ) { … }
— 29:31
In order to move the asynchronous work we need to convert the local @State to a @Published property on the view model: class ItemViewModel: Identifiable, ObservableObject { … @Published var newColors: [Item.Color] = [] … }
— 29:45
And we’ll introduce a method to load the new colors, which basically does the same thing the .task modifier did: @MainActor func loadColors() async { try? await Task.sleep(nanoseconds: NSEC_PER_SEC) newColors = [ .init(name: "Pink", red: 1, green: 0.7, blue: 0.7) ] }
— 30:08
And now we’ll just invoke that method from the .task modifier: .task { await viewModel.loadColors() }
— 30:15
That’s all it takes to move that work out of the view and into our view model.
— 30:19
Next we have the navigation to the color picker view. Currently it’s a fire-and-forget, which means we can’t deep link into it, and the way to remedy that is to properly model the state of whether or not we are navigated to the color picker.
— 30:32
Even though the item view only has the one single destination to navigate to, in the future there may be more, and so we are going to be a little proactive by modeling this view’s navigation as a first class route enum: class ItemViewModel: Identifiable, ObservableObject { … @Published var route: Route? … enum Route { case colorPicker } init(item: Item, route: Route? = nil) { self.item = item self.route = route … } … }
— 31:03
The .colorPicker doesn’t need any associated data because we always have everything necessary to pass down to the color picker view.
— 31:27
With this state modeled we can try constructing a navigation link that is triggered whenever the route is non- nil and is in the .colorPicker case of the Route enum: NavigationLink( unwrap: $viewModel.route, case: /ItemViewModel.Route.colorPicker, onNavigate: ???, destination: { _ in ColorPickerView(viewModel: viewModel) } ) { HStack { Text("Color") Spacer() Text(viewModel.item.color?.name ?? "None") .foregroundColor(.gray) } } A few interesting things to note about this:
— 32:17
First, we don’t yet have anything to use for the onNavigate argument. This argument is a closure that is called whenever the navigation link is activated or deactivated, so we will need to tap into these events in order to create the initial state that drives navigation, which we will do in a moment.
— 32:22
Second, the destination argument is a closure that takes a binding of the associated data of the .colorPicker case, which happens to be Void , and so the value passed to our closure is just a binding a void value. That isn’t very useful, and in fact we already have everything necessary to pass to the ColorPickerView , so we just ignore it with _ .
— 32:47
To provide the onNavigate argument we will introduce a method to our view model that can handle that logic: onNavigate: viewModel.setColorPickerNavigation(isActive:),
— 33:19
And the implementation of that method is quite straightforward: func setColorPickerNavigation(isActive: Bool) { route = isActive ? .colorPicker : nil }
— 33:48
So, with that quick refactor we now have the ability to link to an even deeper part of our application. From the entry point of the application we can construct a route that navigates us to the edit screen for a particular row, and then further navigates into the color picker: route: .row( id: keyboard.id, route: .edit( .init( item: keyboard, route: .colorPicker ) ) )
— 34:42
When we launch the application we are immediately placed in the color picker, and if we change the color to pink, and then commit those changes we see the item was indeed updated in the list.
— 35:35
We can also update the route to point instead to the duplicate route: route: .row( id: keyboard.id, route: .duplicate( .init( item: editedKeyboard.duplicate(), route: .colorPicker ) ) )
— 36:01
And now when we run the application we are immediately opened to a state in which a modal is presented, and inside that modal we are navigated to the color picker.
— 36:22
This is absolutely amazing. We are finally reaping the benefits of all of our hard work to build the tools necessary to make the best use of SwiftUI’s navigation tools. Here we have added a brand new navigation link to our UI by just introducing a proper Route enum to the domain, adding an optional route field to the view model, and then constructing a navigation link that is activated when the optional route flips to the .colorPicker case.
— 37:00
And this sets up a template for us to follow anytime we want to add navigation to any screen. We simply introduce a Route enum, or add a new case if a route enum is already in place, and then construct a navigation link so that it is focused on that particular case of the route enum. Once you do that you will instantly have the ability to deep link into that screen, and even write tests. Next time: the point
— 37:11
So, we have now had 8 entire episodes on navigation in SwiftUI: 2 on just tab views and alerts, 3 on sheets and popovers, and 3 on navigation links. We didn’t expect it to take this long to cover these basic forms of navigation in SwiftUI, but here we are.
— 37:26
Along the way we dove deep into the concepts of driving navigation from state, most importantly optional and enum state, which forced us to construct all new tools for abstracting over the shape of enums, such as case paths. We’ve seen that if you treat navigation primarily as a domain modeling problem, and if you have the tools necessary for modeling your domain, there are a ton of benefits to reap. We instantly unlock the ability to deep link into any part of our application, and it’s as simple as building a nested piece of state that describes exactly where you want to navigate to, hand it off to SwiftUI, and let it do the rest.
— 38:04
And, for free, we also get to test more of our application by asserting on how independent pieces of the application interact with each other.
— 38:14
While exploring these topics we’ve also seen that we had to give up some of SwiftUI’s tools in order to embrace our goals of deep linking and testability. For example, although using local @State in views can be really handy, the moment you do you lose the ability to influence that state from the outside, which means it’s not possible to deep link into those states or test how those states influence your application. And although we didn’t discuss it in this series of episodes, the same applies to @StateObject s too.
— 38:42
Another example of a SwiftUI tool we had to give up was using SwiftUI’s “fire-and-forget” navigation patterns, such as the initializers on TabView and NavigationLink that do not take bindings. Those tools allow us to get things on the screen very quickly, but sadly are not helpful if we need to deep link or write tests.
— 39:00
But now that we are done with the core series on navigation, we’d like to add one more thing. We’ve paid a lot of lip service to deep linking, and we’ve showed how it’s theoretically possible by constructing a large piece of state, handing it to the view, and then letting SwiftUI do its thing, but we haven’t shown how one could add actual, real world deep linking to the application. That is, how does one actually intercept a URL in order to parse it, understand what destination in your application it represents, and then actually navigate to that place.
— 39:32
Let’s first remind ourselves how deep linking works in iOS. The most proper way to deep link in iOS is to add an “apple-app-site-association” file to your website that proves you own the domain you want to connect to your iOS application, and the file lists all the URLs that you want to trigger a deep link. Setting this up requires a lot of work, and there are lots of resources out there explaining step-by-step how to accomplish this, so we will not focus on exactly that topic.
— 40:02
Instead, there’s an older technique for deep linking that is still supported in iOS, and makes it much easier to get our feet wet. It’s possible to register a URL scheme in the Info.plist of our application such that whenever Safari visits a URL beginning with that scheme it will launch our application and pass the URL along so that we can try to figure out where we should direct the user.
— 40:23
Let’s look at that…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 0167-navigation-pt8 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 .