EP 168 · SwiftUI Navigation · Nov 15, 2021 ·Members

Video #168: SwiftUI Navigation: The Point

smart_display

Loading stream…

Video #168: SwiftUI Navigation: The Point

Episode: Video #168 Date: Nov 15, 2021 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep168-swiftui-navigation-the-point

Episode thumbnail

Description

We’ve claimed that the way we handle navigation in SwiftUI unlocks the ability to deep link to any screen in your application, so let’s put that claim to the test. We will add real-world deep linking to our application, from scratch, using the parsing library we open sourced many months ago.

Video

Cloudflare Stream video ID: a4036e78497969577ef85f15c0e2aebb Local file: video_168_swiftui-navigation-the-point.mp4 *(download with --video 168)*

References

Transcript

0:05

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.

0:20

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.

0:58

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.

1:08

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.

1:37

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.

1:55

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. Deep linking in iOS

2:26

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.

2:57

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.

3:18

Let’s look at that…

3:22

To create this URL scheme we just need to go to the project settings, and then the “Info” tab, and add our scheme to the “URL Types” section.

3:35

Let’s use the scheme “nav” to keep things short.

3:39

With this one change we can run our application, then go to Safari and type “nav://” into the URL, and then hitting “go” will immediately launch us into our navigation application.

4:00

So that’s already pretty cool. But we can also intercept that URL that launched the application by using SwiftUI’s .onOpenURL view modifier. It takes a closure that is invoked when a URL is opened, and the URL is passed as an argument: struct ContentView: View { @ObservedObject var viewModel: AppViewModel var body: some View { TabView(selection: self.$viewModel.selectedTab) { … } .onOpenURL { url in } } }

4:20

So this is where we need to perform the work of parsing the URL to figure out what destination it represents, and then updating the state of the view model so that SwiftUI will actually navigate us to that destination.

4:32

Now, if we put the work directly in this view modifier closure then we’ll have no ability to write tests for it. So, let’s call out to a method on the view model: .onOpenURL { url in self.viewModel.open(url: url) }

4:47

And then that method is where we really do the work: class AppViewModel: ObservableObject { … func open(url: URL) { } }

4:53

One naive attempt at destructuring this nebulous URL into values we accept is to simply switch on its path: func open(url: URL) { switch url.path { } }

5:07

Then we could just list out all the URLs that we understand. For example, we could recognize the path "/one" to mean switching to the first tab: switch url.path { case "/one": self.selectedTab = .one

5:23

And we could recognize "/inventory" as switching to the inventory tab: case "/inventory": self.selectedTab = .inventory

5:30

And we could recognize "/three" as switching to the third tab: case "/three": self.selectedTab = .three

5:35

And because this switch can’t possibly be exhaustive we need a default statement: default: break

5:42

With this we can already test some very basic deep linking from Safari. For example, if we re-run the application, and then type “nav:///three” into Safari we will be launched into our application with the third tab selected.

6:24

Note that we have put three slashes in order to have a valid URL where “three” is considered the path of the URL. This is because technically the first part of the URL after the scheme is the host, for example: nav://pointfree.co/three But, the host isn’t important for our simple deep linking example so we can omit it, although you may have use for the host in your own application.

7:04

So, we’ve got something working, but it quite simplistic. There are much more complicated URLs that we want to be able to handle, such as linking directly to the add screen for adding a new inventory item: nav:///inventory/add

7:17

And further, we may want to populate data in the add item view by using query parameters in the URL: nav:///inventory/add?quantity=100&name=Keyboard

7:45

And we may even want to further deep link into the color picker: nav:///inventory/add/colorPicker?quantity=100&name=Keyboard swift-parsing

7:45

Parsing these kinds of URLs can get tricky really quickly, and so picking apart the URL handed to us in the view model method in an ad hoc manner is not going to scale well. Luckily for us, nearly a year ago we open sourced a library for parsing that is specifically tuned for breaking down large, complex parsing problems into small, understandable ones.

8:04

Using the Point-Free SPM package collection , adding our library to this application is as simple as: import Parsing

8:15

And adding the package.

8:25

Now, if you are not familiar with the concepts of parsing or how our parsing library works, we highly recommend you go watch our past episodes, or at least look over the README in the library’s repo. We are going to give a very quick overview of some of the concepts, but it’s no substitute for a little bit of independent study.

8:44

The core concept in the parser library is that of the Parser protocol: public protocol Parser { associatedtype Input associatedtype Output func parse(_ input: inout Input) -> Output? }

8:48

It describes a type that can attempt to parse an input value in order to produce an output. We say attempt because it may be impossible to parse, such as if you were asked to parse an integer from the string “hello”, and so that’s why this parse method returns an optional Output . Returning nil represents that parsing has failed.

9:11

Further, the act of parsing can cause some of the input to be consumed, and that’s why the input argument is marked as inout . Consuming the input in incremental steps allows us to define small parsers that only parse a little bit of data, but then we can piece them together to form much larger parsers.

9:28

As a simple example, the library ships with a parser that can parse integers off the front of strings, and it’s called Int.parser() . If we run this parser on the string “123 hello” we would see that it produces the number 123 and it consumes the “123” from the beginning of the string: var input = "123 hello"[...] Int.parser().parse(&input) // 123 input // " hello"

10:09

That’s the basics of how a parser works, but the library ships with tons of parsers that accomplish very specific tasks, and it ships with operations that allow you to combine multiple parsers together in very complex ways, which we’ll get into very soon.

10:25

So, let’s start writing our first parser. We could of course define a whole new type, have it conform to the Parser protocol, and implement the parse method, but there’s a simpler way to get started. There’s a type called AnyParser that allows you to just provide a closure that performs the parsing work: let deepLinker = AnyParser<URL, Tab> { url in }

11:25

And then we can move our switch statement into this parser, and instead of mutating the view model we will return the tab: let deepLinker = AnyParser<URL, Tab> { url in switch url.path { case "/one": return .one case "/inventory": return .inventory case "/three": return .three default: return nil } }

11:31

And then to use the parser we can create a mutable copy of the url for us to operate on, run it through the parser, and then see if it produces a tab value for us to set on the view model: var url = url if let tab = deepLinker.parse(&url) { self.selectedTab = tab } Breaking down parsing problems

12:00

So, everything should work exactly as it did before, but we also haven’t accomplished much. All we’ve done is move code around. We aren’t leveraging any of the true power of the parser library, which includes the parsers and operators the library ships with.

12:13

What we really want to do is come up with little parsers that can accomplish a small task, such as parsing a single path component from the URL, or parsing a single query parameter from the URL, and then piece them together into the full deep linker parser.

12:28

To do this we need to decide on two things: what is the type of data that we want to incrementally parse, and what is the type of data that we want to produce from the parsing. Let’s start by figuring out the type of the input.

12:44

For the type of data we are incrementally parsing the choice may seem clear: shouldn’t it be the

URL 13:14

And that’s just the work needed to parse a path component. To parse a query parameter you would need to search for the presence of the name of the key, then make sure it’s followed by an equal sign, then parse everything up until an ampersand, and once all that is done you’d have to remove just that fragment from the URL:

URL 13:30

This work is way too cumbersome to perform in an ad hoc manner, and so we should look to a new type of data structure that makes it makes it easy to consume path components and query parameters.

URL 13:44

What if before parsing we performed a normalization step to extract out all of the URL’s path components and query items into a simple collections. This would make incrementally consuming a path component as simple as removing the first element of the array, and consuming a query parameter would mean just searching for a particular element and then removing it.

URL 14:09

We could model such a data structure like so: struct DeepLinkRequest { var pathComponents: [String] var queryItems: [(name: String, value: String?)] }

URL 14:37

The queryItems field is an array of a tuple of a string and an optional string in order to represent a wide variety of valid configurations of query parameters. For example, it’s valid to have multiple query items with the same name: ?name=Blob&name=BlobJr

URL 15:00

Servers are free to interpret this however they wish, such as thinking of the name parameter as an array that holds multiple values.

URL 15:13

It’s also valid to have a query parameter with a name but not a value: ?name=Blob&name=BlobJr&isAdmin

URL 15:20

Servers can also interpret this however they wish, such as thinking of isAdmin as a boolean and its presence represents “true” and its absence represents “false”. So, an array of a tuple of a string and an optional string gives us enough flexibility to model all of these subtle edge cases.

URL 15:36

Now, this is mostly the right type, but it can be improved. We saw in our parser episodes that the String type is not very parser friendly because we cannot efficiently consume small bits from the beginning of strings. If you remove just the first character from a string, Swift has no choice but to allocate a whole new string to represent that. Luckily there are other string-like types in Swift that are more efficient, such as Substring . For the most part it behaves like a string, but really it represents just a view into some backing string, and so really just consists of a pointer to the beginning and end of a string. This means that dropping the first character of a string consists of simply moving the beginning pointer forward one character, which is a super efficient operation.

URL 16:20

The Parsing library mostly works on these more efficient subsequences in general, so by adopting them in our own type, we’ll have more direct access to those operators.

URL 16:33

So, let’s upgrade the string of our path components and query parameter values to be substrings: struct DeepLinkRequest { var pathComponents: [Substring] var queryItems: [(name: String, value: Substring?)] }

URL 16:40

You may not think that such changes have a real impact on performance, but we showed in our parsing series that there are huge performance benefits to be gained.

URL 16:50

In fact, we can apply this principle to even the arrays. Arrays have a similar performance problem where removing the first element of an array causes the entire array to be copied. Just as there is Substring for representing a view into a String , there is ArraySlice for representing a view into an array. For the most part it behaves just as an array, but it is more efficient for parsing from the beginning and end of the collection. So, let’s upgrade our arrays to be slices too: var pathComponents: ArraySlice<Substring>

URL 17:32

As for query items, consuming an individual item is also not going to be terribly efficient: we have to traverse the array to find the item, and then if we remove the item, the array might need to be resized and copied. Instead, we can use a dictionary to efficiently find and remove particular items, and each item will accumulate an array slice of those optional values. var queryItems: [String: ArraySlice<Substring?>]

URL 18:12

Now that we have the type defined, we need to implement that normalization step, which turns a

URL 20:10

OK, so we now have our input type defined, and even a way to turn URLs into that type. We still haven’t decided what type of output we are ultimately going to parse into, but we will get to that later.

URL 20:21

With our input type defined we can define our first small, focused parser that simply parses a literal string from the first path component on the URL. To do this define a type that conforms to the Parser protocol: struct PathComponent: Parser { }

URL 20:41

This type can hold onto the component that we want to match against the first component of the URL, and we’ll implement a custom initializer that elides the argument name to make the call site succinct: struct PathComponent: Parser { let component: String init(_ component: String) { self.component = component } }

URL 20:58

And then we can implement the parse method so that it takes an inout DeepLinkRequest , checks if its first component matches the one we hold, and if it does consumes it: struct PathComponent: Parser { let component: String init(_ component: String) { self.component = component } func parse(_ input: inout DeepLinkRequest) -> Void? { guard input.pathComponents.first == self.component[...] else { return nil } input.pathComponents.removeFirst() return () } } Note that we are returning Void from this parser because we don’t actually extract anything useful from the request. We simply want to know if the first path component matches or not.

URL 21:49

Amazingly, just defining this one custom parser, and leveraging operators the library comes with, allows us to already recreate the ad hoc switch based parser with something more compact: let deepLinker = PathComponent("one").map { Tab.one } .orElse(PathComponent("inventory").map { .inventory }) .orElse(PathComponent("three").map { .three })

URL 22:40

To make sure this works as before we need to construct a DeepLinkRequest from the url, and then run the parser on that request: func open(url: URL) { var request = DeepLinkRequest(url: url) if let tab = deepLinker.parse(&request) { self.selectedTab = tab } }

URL 22:56

And if we run the application we will see it runs exactly as before, but now we are on our way to breaking down our URL parsing problem into much simpler units.

URL 23:08

Here we repeatedly use the PathComponent parser to test whether or not the URL begins with a particular string, and if it does we map it to the corresponding Tab value. And then we combine all of these parsers into a single one using the .orElse operator. This allows you to combine two parsers into a single one that simply takes the first one that succeeds.

URL 23:20

So, if you had a long string of orElse s: a .orElse(b) .orElse(c) .orElse(d)

URL 23:28

It would try each parser in order until it found one that succeeded, and then stop.

URL 23:33

But let’s beef up our deep link parser so that we can navigate to even more parts of the application. Let’s start simple. Say we wanted to recognize the following URL for routing to the add item modal view: nav:///inventory/add

URL 23:52

Since this is a new route to be recognized we should add a new .orElse to our deep link parser: let deepLinker = PathComponent("one").map { Tab.one } .orElse( PathComponent("inventory") .map { .inventory } ) .orElse( ??? ) .orElse( PathComponent("three") .map { .three } )

URL 23:58

In this orElse we need to describe how to parse the “inventory” path component, and then only if that succeeds how to further parse the “add” path component, and then finally only if both of those steps succeed we will .map into something, but I’m not quite sure what yet.

URL 24:18

The way we sequence a multi-step parser like this is using the .take and .skip operators. Both of them describe how to run one parser after another, but one captures the outputs of both parsers and the other discards the output of the second parser.

URL 24:34

In our situation, we want to parse the “inventory” path component, and doing so returns a Void value because there’s nothing useful to extract from the URL in doing so. We then want to parse the “add” path component, which also produces a Void value, but instead of bundling two void values into a tuple we can opt into simply discarding that second void by using the .skip operator: PathComponent("inventory") .skip(PathComponent("add"))

URL 24:53

This is now a parser that makes sure the first two path components of the URL are “inventory” and “add”. We want to now .map on this parser to turn it into a value that we can later interpret to do the work of showing the add item modal. Currently all of our parsers are simply mapping into the Tab type, but that is insufficient. We not only need to be able to describe which tab to switch to, but then further describe what other screens we navigate to inside a particular tab.

URL 25:31

This brings us to trying to figure out what the output of our parser should be, because clearly the Tab type is not cutting it. We need a way of not only describing that we want to switch to the inventory tab, but further that once that is done we want to open the add item modal.

URL 25:46

Sounds like we want to associate some further routing data to the inventory case of the Tab enum. To do this we can define a data structure that is dedicated to just describing all the possible routes in the application, rather than trying to reuse the Tab enum.

URL 26:01

Such an enum would have a case for each tab, but the inventory case would further hold an associated value, which could be another enum for describing all of the routes we recognize in the inventory tab. We will start with the add route: enum AppRoute { case one case inventory(InventoryRoute?) case three } enum InventoryRoute { case add }

URL 26:06

Note that we have made the .inventory case hold onto an optional InventoryRoute because it’s possible to be on the inventory tab, but then not further navigated to any other screen. This is similar to what we do in our view models where we always hold an optional route to represent that you can be in a state of not navigated anywhere beyond the current screen.

URL 26:39

With this type defined we can update our deep link parser to map each of the smaller parsers into this type, which gives us the opportunity to differentiate between routing to the root of the inventory screen versus routing to the add modal in the inventory screen: let deepLinker = PathComponent("one").map { AppRoutee.one } .orElse( PathComponent("inventory") .map { AppRoute.inventory(.none) } ) .orElse( PathComponent("inventory") .skip(PathComponent("add")) .map { AppRoute.inventory(.add) } ) .orElse( PathComponent("three") .map { AppRoute.three } )

URL 27:05

Now that the parser outputs the AppRoute enum we can switch on it to figure out how we need to update the view model to represent navigating to that destination: var request = DeepLinkRequest(url: url) if let route = deepLinker.parse(&request) { switch route { case .one: self.selectedTab = .one case let .inventory(inventoryRoute): self.selectedTab = .inventory switch inventoryRoute { case .add: self.inventoryViewModel.route = .add( .init( item: .init( name: "", color: nil, status: .inStock(quantity: 1) ) ) ) case .none: self.inventoryViewModel.route = nil } case .three: self.selectedTab = .three } }

URL 28:27

Right now we are doing a switch inside a switch in order to handle the inventory sub-routes, but that is going to get messy for each additional layer we have to navigate into. So, let’s move the responsibility of inventory navigation to a method on the InventoryViewModel : case let .inventory(inventoryRoute): self.selectedTab = .inventory self.inventoryViewModel.navigate(to: inventoryRoute)

URL 28:52

And that method can be implemented as a single, flat switch : extension InventoryViewModel { func navigate(to route: InventoryRoute?) { switch route { case .none: self.route = nil case .add: self.route = .add( .init( item: .init( name: "", color: nil, status: .inStock(quantity: 1) ) ) ) } } }

URL 29:14

So, now if we run the test we’d hope that we could visit the URL “nav:///inventory/add” in Safari to launch the application to the add item modal. However, if we do that all we see is that the tab was changed, but the modal did not come up.

URL 29:38

In fact, if we put breakpoints in the .none and .add cases of our switch and run again, we will see the .none case catches. What gives?

URL 29:53

Well, if we look back at our parser we will see a big chain of .orElse parsers, which as you remember, simply takes the first parser that succeeds and then short circuits all the remaining ones. If we look closely we will see we are parsing the root inventory route before the add route: .orElse( PathComponent("inventory") .map { AppRoute.inventory(.none) } ) .orElse( PathComponent("inventory") .skip(PathComponent("add")) .map { AppRoute.inventory(.add) } ) This means when we try to parse “nav:///inventory/add” the first parser checks that the first path component is “inventory”, which it is, and then assumes its the route we are looking for, and so prevents any other parsers running.

URL 30:24

While we could flip the order to ensure the add route is parsed first, but this is a subtle behavior to introduce to the router. Having to be conscious of the order of our routes to make sure the more specific ones come before the less specific ones sounds like a mess.

URL 30:38

What we need to do is further say that not only did all the path components match how we expect, but also that there were no other path components left to parse. That would guarantee that we proved that the full path matches exactly what we expect and that there’s nothing left to consider.

URL 31:01

To do this we can introduce a new parser that simply checks if the array of path components of the request is empty. If it is we can succeed with a void value, and if not we can fail by returning nil : struct PathEnd: Parser { func parse(_ input: inout DeepLinkRequest) -> Void? { guard input.pathComponents.isEmpty else { return nil } return () } }

URL 31:44

And then we need to update each route to make sure to parse and discard the end of the path: let deepLinker = PathComponent("one") .skip(PathEnd()) .map { AppRoute.one } .orElse( PathComponent("inventory") .skip(PathEnd()) .map { AppRoute.inventory(.none) } ) .orElse( PathComponent("inventory") .skip(PathComponent("add")) .skip(PathEnd()) .map { AppRoute.inventory(.add) } ) .orElse( PathComponent("three") .skip(PathEnd()) .map { AppRoute.three } )

URL 32:08

Now when we run the application everything works how we expect.

URL 32:25

This is looking really nice. All it takes to add support for another recognized deep link route is to add a case to our route enum, add a parser for the route, and then handle the case in a switch somewhere.

URL 32:39

For example, if we wanted to route the following URL: nav:///navigate/add/colorPicker

URL 32:46

We could start by modeling this route in the AppRoute enum: enum AppRoute { case one case inventory(InventoryRoute?) case three } enum InventoryRoute { case add case colorPicker }

URL 32:50

Then describe how to parse the request into that route: .orElse( PathComponent("inventory") .skip(PathComponent("add")) .skip(PathEnd()) .map { AppRoute.inventory(.add(.none)) } ) .orElse( PathComponent("inventory") .skip(PathComponent("add")) .skip(PathComponent("colorPicker")) .skip(PathEnd()) .map { AppRoute.inventory(.add(.colorPicker)) } )

URL 33:05

And finally handle the new case in the switch in the navigate(to:) method: case .colorPicker: self.route = .add( .init( item: .init(name: "", color: nil, status: .inStock(quantity: 1)), route: .colorPicker ) )

URL 33:24

With just those few changes we can now deep link into the add modal view with the color picker open, directly from Safari. Parsing query parameters

URL 33:53

Now let’s kick things up a notch. Suppose we want to further parse out some of the query parameters for doing things like pre-filling the name and quantity of the item we are adding.

URL 34:14

This could be represented in the URL like this: nav:///inventory/add?quantity=1000&name=Keyboard

URL 34:31

We need to come up with a parser that can search for a particular query item in the array of our request, and if it finds it it can succeed, and otherwise it will fail. Let’s start by imagining how this might look at the call site.

URL 34:45

For example, we already have this parser for recognizing the add route: PathComponent("inventory") .skip(PathComponent("add")) .skip(PathEnd())

URL 34:48

What if we could further parse the “name” query parameter like this: PathComponent("inventory") .skip(PathComponent("add")) .skip(PathEnd()) .take(QueryItem("name"))

URL 35:16

This would say that if the “name” query parameter exists then we will take it. It also means that if the “name” query parameter is not present it will fail, which we don’t want to happen. Instead, the absence of the “name” parameter could just mean that we default the name to the empty string. To represent this we can tack on an .orElse parser that always succeeds with an empty string: .take(QueryItem("name").orElse(Always(""))

URL 35:45

Let’s see if we can implement a QueryItem parser that makes this code compile.

URL 35:50

We can start with a new type that conforms to the Parser protocol: struct QueryItem: Parser { }

URL 35:58

It will need to hold onto the name of the query item we want to parser: struct QueryItem: Parser { let name: String init(_ name: String) { self.name = name } }

URL 36:09

Then, the parse method can be implemented by simply searching for the query item, and if we find it we remove it and return the substring value associated, and otherwise we return nil : func parse(_ input: inout DeepLinkRequest) -> Substring? { guard let wrapped = input.queryItems[self.name]?.first, let value = wrapped else { return nil } input.queryItems[self.name]?.removeFirst() return value }

URL 37:34

And with that, our theoretical syntax for parsing a query item is now compiling.

URL 37:48

Let’s try parsing a “quantity” query parameter now. We might hope it’d be as simple as: PathComponent("inventory") .skip(PathComponent("add")) .skip(PathEnd()) .take(QueryItem("name").orElse(Always(""))) .take(QueryItem("quantity").orElse(Always(0)))

URL 38:07

But that can’t be right.

URL 38:11

Recall that query items are stored as simple (String, Substring?) pairs, which means even if we find the “quantity” value sitting in the array, its value will be expressed as an optional substring. We need to further be able to parse this substring value into a proper integer, so it seems that the QueryItem needs to be able to take a parser as an argument to perform that extra step: PathComponent("inventory") .skip(PathComponent("add")) .skip(PathEnd()) .take(QueryItem("name").orElse(Always("")) .take(QueryItem("quantity", Int.parser()).orElse(Always(0)))

URL 38:40

Let’s see what changes we need to make to QueryItem to make this possible.

URL 38:44

Since QueryItem needs to accept a parser as an argument, and that parser can output any type of value, let’s make QueryItem generic over the type of parser we use: struct QueryItem<ValueParser>: Parser { let name: String let valueParser: ValueParser … }

URL 38:59

This new generic will need some constraints. It will need to conform to the Parser protocol and its input will need to be Substring since that is the type of the value in the query items array: struct QueryItem<ValueParser>: Parser where ValueParser: Parser, ValueParser.Input == Substring { … }

URL 39:20

Then we need to hold onto one of the ValueParser s so that we can use it later: let name: String let value: ValueParser init(_ name: String, _ valueParser: ValueParser) { self.name = name self.valueParser = valueParser }

URL 39:30

And we can update the parse method to further make use of the value parser once we find the query parameter we are interested in: func parse(_ input: inout DeepLinkRequest) -> ValueParser.Output? { guard let wrapped = input.queryItems[self.name]?.first, let value = wrapped, let output = self.valueParser.parse(&value), value.isEmpty else { return nil } input.queryItems[self.name]?.removeFirst() return output }

URL 40:21

That gets our second QueryItem usage compiling, but now the first one we wrote for the “name” parameter isn’t compiling. This is because we are now forced to provide a parser, even if we just want to take the whole value unconditionally.

URL 40:36

The easiest way to do this is to use the Rest parser, which simply consumes everything and always succeeds: PathComponent("inventory") .skip(PathComponent("add")) .skip(PathEnd()) .take(QueryItem("name", Rest()).orElse(Always(""))) .take(QueryItem("quantity", Int.parser()).orElse(Always(0)))

URL 40:44

But even better, we could provide a convenience initializer for QueryItem that defaults to Rest in the situation that we do not provide an additional value parser: init(_ name: String) where ValueParser == Rest<Substring> { self.name = name self.value = Rest() }

URL 41:14

Now we can do this: PathComponent("inventory") .skip(PathComponent("add")) .skip(PathEnd()) .take(QueryItem("name").orElse(Always(""))) .take(QueryItem("quantity", Int.parser()).orElse(Always(0)))

URL 41:19

This is looking pretty great, but how do we integrate this into the deep linker parser?

URL 41:24

Currently we are simply ignoring the values being parsed: .map { _ in AppRoute.inventory(.add) }

URL 41:26

We now have actual data we want to associate with the add case.

URL 41:30

The data we want to associate is an Item value, which represents the initial value to hand off to the new item modal. So, let’s update the .add case in the InventoryRoute enum to hold onto an Item : enum InventoryRoute { case add(Item) case colorPicker }

URL 41:42

We should also be able to pre-fill an item’s fields when deep linking into the color picker, so maybe the colorPicker case needs an Item as well: enum InventoryRoute { case add(Item) case colorPicker(Item) }

URL 41:49

But even better, maybe this is an opportunity to refactor the routing of the item view with a new route enum: enum InventoryRoute { case add(Item, ItemRoute? = nil) } enum ItemRoute { case colorPicker }

URL 42:01

Then we can .map on the end of our parser to turn the name and quantity values into an Item : .orElse( PathComponent("inventory") .skip(PathComponent("add")) .skip(PathEnd()) .take(QueryItem("name").orElse(Always(""))) .take(QueryItem("quantity", Int.parser()).orElse(Always(0))) .map { name, quantity in .inventory( .add( Item( name: String(name), color: nil, status: .inStock(quantity: quantity) ) ) ) } )

URL 42:47

And we’ll update the color picker route as well: .orElse( PathComponent("inventory") .skip(PathComponent("add")) .skip(PathComponent("colorPicker")) .skip(PathEnd()) .take(QueryItem("name").orElse(Always(""))) .take(QueryItem("quantity", Int.parser()).orElse(Always(0))) .map { name, quantity in .inventory( .add( Item( name: String(name), color: nil, status: .inStock(quantity: quantity) ), .colorPicker ) ) } )

URL 43:15

This may seem intense, but we will have ways to simplify this soon.

URL 43:20

And then in the .navigate(to:) method we can simplify things by passing the item along in each case: case let .add(item): self.route = .add(.init(item: item))) case let .add(item, .colorPicker): self.route = .add(.init(item: item, route: .colorPicker))

URL 43:57

Now when we launch the application we can visit URLs like this: nav:///inventory/add?name=Keyboard&quantity=1000

URL 44:25

And the application will be immediately launched with the add item modal opened and the name automatically pre-filled with “Keyboard” and quantity set to 1,000.

URL 44:29

This is pretty amazing, but let’s clean up our parser a bit. One of the great things about the parser library is that you can pull apart an existing parser into as many pieces as you want. So all the work we have to do to parse an item from query parameters can be extracted into its own parser: let item = QueryItem("name").orElse(Always("")) .take(QueryItem("quantity", Int.parser()).orElse(Always(0))) .map { name, quantity in Item(name: String(name), color: nil, status: .inStock(quantity: quantity)) }

URL 45:13

And then the add item and color picker routes becomes much simpler: .orElse( PathComponent("inventory") .skip(PathComponent("add")) .skip(PathEnd()) .take(item) .map { .inventory(.add($0)) } ) .orElse( PathComponent("inventory") .skip(PathComponent("add")) .skip(PathComponent("colorPicker")) .skip(PathEnd()) .take(item) .map { .inventory(.add($0, .colorPicker)) } ) Parsing dynamic paths

URL 45:48

We introduced another reusable parser for query items and layered on some impressive deep linking that can pre-fill an item’s form fields. And we were able to refactor our routing quickly due to the way the parsing library breaks parsing problems down into small pieces.

URL 46:01

Now we are going to move forward to even more complicated deep linking scenarios in our application. Let’s try to support the delete, edit and duplicate routes for deep linking.

URL 46:21

URLs for these routes could look like the following: nav:///inventory/:uuid/edit nav:///inventory/:uuid/delete nav:///inventory/:uuid/duplicate

URL 46:38

Let’s start by modeling these new routes in our big route enum. We will add a case to the inventory for a particular row, which needs an item ID to know which row we are dealing with: enum Inventory { case add(Item, ItemRoute? = nil) case row(Item.ID) … }

URL 47:03

And further, each row will have routes that we can navigate to, such as the delete, duplicate or edit routes: enum Inventory { case add(Item, ItemRoute?) case row(Item.ID, RowRoute) } … enum RowRoute { case delete case duplicate case edit }

URL 47:19

Note that we are not holding onto an optional RowRoute in the .row case because it doesn’t make sense to navigate to a “row” without further going into either .delete , .duplicate or .edit .

URL 47:29

Now that we know the types we want to parse into, let’s figure out the parsers. The path components we are trying to parse in the above URLs are a little different from the URLs we’ve dealt with so far because we want to parse some actual data from a path component. Previously we’ve just wanted to check that a path component matches a specific string literal, but now we want to further parse the path component so that we can try to extract a UUID from it.

URL 47:51

To recognize routes like this we need to adapt the PathComponent parser to not only take a string literal for matching a path component, but it should also be possible to pass in a parser. That parser would be run on the first path component, and if it succeeds and consume the whole path component then the whole parser will succeed. We would hope that the call site of such a parser could look like this: PathComponent("inventory") .take(PathComponent(UUID.parser())) .skip(PathComponent("edit")) .skip(PathEnd())

URL 48:48

This expresses that we want to parse “inventory” from the first path component, then parse a UUID from the next path component, and then finally parse “edit” from the last path component.

URL 49:00

And if this were to succeed, we could bundle up that UUID into a route: PathComponent("inventory") .take(PathComponent(UUID.parser())) .skip(PathComponent("edit")) .skip(PathEnd()) .map { id in.inventory(.row(id: id, .edit)) }

URL 49:19

For this to work we need to beef up the PathComponent parser. It’s not enough to just hold onto a string in order to check if the first path component matches it. We need to hold onto an entire parser, which means we need to make the PathComponent type generic over that parser: struct PathComponent<ComponentParser>: Parser where ComponentParser: Parser, ComponentParser.Input == Substring { let component: ComponentParser init(_ component: ComponentParser) { self.component = component } … }

URL 49:57

Then, in the parse method we can run the component parser on the first path component (if it exists), and only succeed if the component parser fully consumes the path component: func parse(_ input: inout DeepLinkRequest) -> ComponentParser.Output? { guard var firstComponent = input.pathComponents.first, let output = component.parse(&firstComponent), firstComponent.isEmpty else { return nil } input.pathComponents.removeFirst() return output }

URL 50:31

Now this parser compiles: PathComponent("inventory") .take(PathComponent(UUID.parser())) .skip(PathComponent("edit")) .skip(PathEnd())

URL 50:37

You may be a little surprised that this compiles, after all we are passing a raw, literal string to the PathComponent initializer. We no longer have an initializer that takes a string, so I would expect that we have to explicitly pass the StartsWith parser here: PathComponent(StartsWith("inventory")) .take(PathComponent(UUID.parser())) .skip(PathComponent(StartsWith("edit"))) .skip(PathEnd())

URL 50:55

What gives?

URL 50:57

Well, a few weeks ago we released a new version of our parsing library that conforms strings, UTF8View s and more to the parser protocol, and under the hood it just behaves exactly like the StartsWith parser. This means we can use a plain string anywhere we would have previously used the StartsWith parser.

URL 51:22

Before moving on, let’s clean things up a bit because the deep linker is getting pretty long. Perhaps we can extract all the inventory routing to its own parser. let inventoryDeepLinker = PathComponent("inventory") .skip(PathEnd()) .map { AppRoute.inventory(nil) } .orElse( PathComponent("inventory") .skip(PathComponent("add")) .skip(PathEnd()) .take(item) .map { .inventory(.add($0)) } ) .orElse( PathComponent("inventory") .skip(PathComponent("add")) .skip(PathComponent("colorPicker")) .skip(PathEnd()) .take(item) .map { .inventory(.add($0, .colorPicker)) } ) .orElse( PathComponent("inventory") .take(PathComponent(UUID.parser())) .skip(PathComponent("edit")) .skip(PathEnd()) .map { .inventory(.row(id: $0, route: .edit)) } )

URL 51:40

Which we can use in the deep linker: let deepLinker = PathComponent("one") .skip(PathEnd()) .map { AppRoute.one } .orElse(inventoryDeepLinker) .orElse( PathComponent("three") .skip(PathEnd()) .map { .three } )

URL 52:00

We can take this cleanup even further, because right now every one of our inventory routers are attempting to parse the “inventory” component, which is more work than we need to do. We can instead factory this step out of the inventory deep linker so that it happens only once, at the app deep linker level: let inventoryDeepLinker = PathEnd() .map { AppRoute.inventory(nil) } .orElse( PathComponent("add") .skip(PathEnd()) .take(item) .map { .inventory(.add($0)) } ) .orElse( PathComponent("add") .skip(PathComponent("colorPicker")) .skip(PathEnd()) .take(item) .map { .inventory(.add($0, .colorPicker)) } ) .orElse( PathComponent(UUID.parser()) .skip(PathComponent("edit")) .skip(PathEnd()) .map { .inventory(.row(id: $0, route: .edit)) } ) let deepLinker = PathComponent("one") .skip(PathEnd()) .map { AppRoute.one } .orElse( PathComponent("inventory") .take(inventoryDeepLinker) ) .orElse( PathComponent("three") .skip(PathEnd()) .map { .three } )

URL 52:54

To test out the edit route, in the navigate(to:) method on InventoryViewModel we need to handle this new .row case in the route. We could perform all the work directly in the case statement, but it might be better to kick that work off to a .navigate(to:) method on the ItemRowViewModel : case let .row(id: id, route: route): guard let viewModel = self.inventory[id: id] else { break } viewModel.navigate(to: route)

URL 53:31

And then the ItemRowViewModel can decide how to interpret the route: extension ItemRowViewModel { func navigate(to route: RowRoute) { switch route { case .delete: self.route = .deleteAlert case .duplicate: self.route = .duplicate(.init(item: self.item)) case .edit: self.route = .edit(.init(item: self.item)) } } }

URL 54:08

And with just that little bit of work we now support deep linking into the edit screen for a particular item. In order to test this we need to know the ids of the rows that we start the app off with, and those are just randomly generated UUIDs, so let’s put in a print statement to get a list of all those ids on launch: private func bind(itemRowViewModel: ItemRowViewModel) { print("bind", "id", itemRowViewModel.id) … }

URL 54:45

Running the application will print something like the following to the console: bind id D2AFFEF0-0629-4EF7-847B-BC7BA5A34EA4 bind id EC5AE3A2-7850-4B85-8653-9E0F4A4D3BCD bind id CC3CE2AC-6795-4B05-8B6B-D7A3E5E7595B bind id 898520FC-7D88-427E-BCD0-55BABBE3D42C

URL 54:49

And now we can pick any of these, say the first one, and construct a URL to its edit screen to feed into Safari: nav:///inventory/D2AFFEF0-0629-4EF7-847B-BC7BA5A34EA4/edit

URL 55:05

And just like that the app immediately launches with the edit screen already displayed. We can even make changes to the screen, hit save, and we see that indeed the changes are reflected in the 1st row of the list.

URL 55:19

That’s pretty incredible, but let’s quickly get one more route into place: delete. .orElse( PathComponent(UUID.parser()) .skip(PathComponent("delete")) .skip(PathEnd()) .map { .inventory(.row(id: $0, route: .delete)) } )

URL 55:36

We’re already interpreting this route, so it’s just a matter of building, navigating to a delete route, let’s say for the last item. And we are instantly launched into the inventory with a delete alert showing. If we hit “delete”, the last item animates away! Conclusion

URL 56:03

Now, there’s a ton more we can do to improve the code we have written here. For one thing, the parser is getting a little unwieldy. We should probably start splitting it up into even smaller pieces, and maybe even moving those pieces closer to the features that do the actual routing. For example, the inventory feature could manage its own route enum and parsers, and then then main app domain could bring in those pieces into its main deep linker parser.

URL 56:29

Also we are currently missing out on parsing a lot of important information. For example, we have no way of deep linking into the edit screen for an item with some of the fields changed, and we have no way to further deep link into the color picker of the edit view of a particular item. This, and more, can be accomplished with some further refactoring, but we are going to leave that as exercises for the viewer.

URL 57:09

But, even though there is still room for improvement, what we have accomplished is quite amazing. We have modeled all navigation in our application as state, either as an optional or an enum, and that meant that navigate to a specific state is as simple as just constructing that state, handing it off to the view model, and letting SwiftUI do the rest. And once navigation was that simple it meant that deep-linking from an actual URL was as easy as parsing the URL to figure out what it represents, and then constructing the state to navigate us to the destination.

URL 57:43

We are accomplishing something quite complex here, but it now seems attainable thanks to all of the hard work we have put into building the tools that allow us to model our domains as concisely as possible.

URL 57:56

It may be hard to believe, but this officially the end of our foundational series of episodes on SwiftUI. We honestly did not think it would take us 9 entire episodes to discuss navigation, but here we are. Navigation is a seriously complex topic with a nebulous definition, and we have tried hard to frame the discussion using concise language.

URL 58:20

To us, navigation is simply a mode change in our application, whether it be a tab changing, an alert display, a modal sliding up, or a drill down. And the best way to model such events is via optional state, or more generally, enum state. When a piece of optional state flips from nil to non-nil, or when a piece of enum state flips to a specific case, the navigation is activated.

URL 58:44

When said that way it all sounds quite simple. We can just hold onto some optional state or enum state in our observable objects, and hopefully make use of SwiftUI’s APIs to trigger navigation when state changes. Well, unfortunately that’s not the case.

URL 58:59

In this series of episodes we saw time and again that if we model our domain to be as concise as possible, in particular using optionals and enums, then we run into roadblocks with SwiftUI’s APIs. They were not always designed with optionals and enums in mind, and sadly Swift does not come with the proper tools to abstract over the shapes of enums.

URL 59:20

Well, luckily for us, we can build these tools ourselves, and once that was done we showed that essentially all forms of navigation in SwiftUI all fall under the same umbrella. They are simply transformations on enums, and we saw the same patterns play out over and over.

URL 59:35

Well, that’s the end of these series, and we’re almost done with episodes for the rest of the year. We have a few more lighthearted episodes to finish off the year, and then next year we’ll start diving into bigger topics.

URL 59:48

Until 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 Swift Parsing Brandon Williams & Stephen Celis • Dec 21, 2021 A library for turning nebulous data into well-structured data, with a focus on composition, performance, generality, and invertibility. https://github.com/pointfreeco/swift-parsing 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 0168-navigation-pt9 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 .