EP 213 · SwiftUI Navigation · Nov 21, 2022 ·Members

Video #213: SwiftUI Navigation: Stacks

smart_display

Loading stream…

Video #213: SwiftUI Navigation: Stacks

Episode: Video #213 Date: Nov 21, 2022 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep213-swiftui-navigation-stacks

Episode thumbnail

Description

When all-new SwiftUI navigation tools were announced at WWDC, the one that got the most attention by far was NavigationStack, which powers navigation with an array. It is extremely powerful, but comes with trade-offs and new complexities.

Video

Cloudflare Stream video ID: df207466c6d666de21669f9568a24234 Local file: video_213_swiftui-navigation-stacks.mp4 *(download with --video 213)*

References

Transcript

0:05

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

0:34

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

1:02

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

1:19

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

1:38

Let’s take a look at this last new tool of SwiftUI navigation in iOS 16. Stacks, links, destinations

1:44

Currently the only place we are using NavigationStack is at the root of the InventoryView : public struct InventoryView: View { … public var body: some View { NavigationStack { … } } }

1:53

We needed to do this in order to use the navigationDestination(isPresented:) view modifier because that does not work with the old, now deprecated NavigationView .

2:08

There is another way to initialize a NavigationStack that takes a binding. In fact, there are two such initializers: NavigationStack( path: <#Binding<NavigationPath>#> ) NavigationStack( path: <#Binding<MutableCollection & RandomAccessCollection & RangeReplaceableCollection>#> )

2:17

The first takes a binding of what is called a NavigationPath , which is a brand new type in iOS 16

2:23

The documentation describes it as a kind of “type erased” collection, and each element in the collection represents the data needed to show the corresponding screen on the navigation stack.

2:38

However, calling this type a “collection” is a bit of a stretch.

2:42

It does come with some collection-like functionality, such as being able to append to the end, remove from the end, and count the number of elements inside: But that’s about all you can do.

3:00

You can’t insert or remove values anywhere except at the end. You also can’t iterate over the elements inside, or even access a particular element. All of that is completely opaque from the outside.

3:12

The second takes a binding of a concrete collection of elements that must be of all the same type, and hence not type erased. It is very generic, can be any mutable, random access, range replaceable collection, but more often than not you are probably just going to use an array. And because it is a concrete collection, you have the full power of the collection API at your disposal. You can insert and remove elements anywhere inside the collection, you can iterate over elements, and you can access elements at any index.

3:45

These two initializers that take a binding greatly differ from how we are currently modeling navigation destinations in the inventory app. Currently, the destinations are modeled as a series of nested enums, which turns our destinations into a tree like structure. In order to specify a destination, no matter how deep into the application, you can have a conversation with the compiler where you are asked a series of questions of where you want to go.

4:14

First, it will ask you where you want to go in the inventory feature: destination: <#InventoryModel.Destination?#>

4:26

In there you have 3 choices: add, edit, help, or nil which represents being at the root of the inventory feature. Let’s choose edit, in which case we are confronted with another choice: where in the edit item screen do you want to navigate to: destination: .edit( ItemModel( destination: <#ItemModel.Destination?#>, item: keyboard ) )

4:47

In there you have 2 choices: the color picker, or nil which represents being at the root of the edit feature. We will choose colorPicker : destination: .edit( ItemModel( destination: .colorPicker, item: keyboard ) )

4:59

And that completes our conversation with the compiler. We can immediately see the tree-like structure of these nested enums by virtue of the fact that at each branch we pick up an extra level of indentation.

5:12

The path binding for NavigationStack s abandons the deeply nested, tree interpretation of destinations in favor of a flat collection of destinations. Each destination stands on its own as an independent unit. This completely removes the concept of local navigation. That is, a child feature no longer decides where it wants to navigate to, and instead all navigation is coalesced at the parent integration point, and hence no longer forms a tree.

5:34

It allows you to do some interesting things with navigation, such as describe a deep navigation destination with a simple, flat array. However, it’s hard to see the benefits of this form of navigation from our current inventory application. It has a pretty simple set of destinations. It can only drill down to an edit screen and then further drill down to a color picker.

5:52

If we were to model this situation as an array of destinations, then you could have the edit drill down represented as an array of with a single element: path = [ .edit ]

6:06

And then when the color picker is pushed it will append an element: path = [ .edit, .colorPicker ]

6:10

But it doesn’t really make a ton of sense to represent this as a free-form array of destinations because it allows you to express some really non-sensical stacks. For example, what would it mean if only the color picker was on the stack: path = [ .colorPicker ]

6:21

The whole point of the color picker is to modify the edit screen that came before it.

6:25

Even more nonsensical, we could have multiple color pickers on the stack: path = [ .colorPicker, .colorPicker, .colorPicker, .colorPicker ]

6:30

What does that mean?

6:32

Or we could have a color picker and then an edit item screen: path = [ .colorPicker, .edit ]

6:37

Again, what does this mean? After saving the item you will be popped back to a color picker?

6:43

What we are seeing here is that array-based navigation state doesn’t really make a lot of sense for our inventory application. Its navigation model is quite simple with a small number of destinations that can only occur in a very specific order, and this is exactly where tree-like navigation makes the most sense.

6:57

Where array-based navigation state really starts to shine is in “wiki” style applications. These are applications where you can drill down any number of layers deep to many kinds of screens. For example, in a Wikipedia application you would be able to drill down to an article, but then any link in the article can drill down to another article. And there are categories that you can drill into that show a list of articles, and then you can further drill down into an article.

7:21

The App Store app on your phone is another example of this.

7:28

You can navigate to an app’s page, and at the bottom there’s a button you can tap to see all apps from that developer. In that screen you can drill down to another app. And if you scroll down to the bottom you will see a list of similar apps. Tapping on any of those drills down to another app, and you can start the process all over again going any number of layers deep.

7:45

These kinds of navigation schemes are very difficult, if not impossible, to do with tree-like state modeled on enums. The reason is that they require very deeply nested enums, and often those enums need to be recursive so that the same feature can show up multiple times in the navigation stack. But as soon as you are dealing with a recursive enum you make it very difficult to modularize your features since that introduces circular dependencies, which SPM does not allow.

8:09

There are elaborate tricks you can employ to get around this, but eventually it feels like you are fighting the tools too much, and so may start to wonder if there is a better way.

8:18

So, we don’t feel we can do this kind of navigation justice by trying to shoehorn it into our inventory application. We need to start with something fresh, and so that we don’t have to create a whole new, complex application from scratch, we are going to work a little bit in the abstract. The application will have all the same shapes of the problems people encounter in real world applications every day, but we will clear the fog of having a bunch of complex functionality to maintain.

8:41

So, let’s start a fresh project that we can explore with:

8:53

And let’s put a NavigationStack at the root of the default content view: struct ContentView: View { var body: some View { NavigationStack { } } }

9:00

Let’s say we wanted to have 3 buttons on this screen for going to 3 destinations. We will keep things abstract right now and just call these screen “A”, “B” and “C”: struct ContentView: View { var body: some View { NavigationStack { List { Button { <#???#> } label: { Text("Go to screen A") } Button { <#???#> } label: { Text("Go to screen B") } Button { <#???#> } label: { Text("Go to screen C") } } .navigationTitle("Root") } } }

9:30

The question is, what can we do in the action closures of these buttons to push a new screen onto the stack? Previously we would manage some local optional state and use the navigationDestination(isPresented:) view modifier to drive navigation, but that only works for tree-like state modeled on optionals and enums.

9:47

To deal with this there is a new initializer on NavigationLink that allows you to associate a bit of data with the link, and when the link is tapped the data is appended to the collection of values powering the navigation stack: init<P: Hashable>( value: P?, label: () -> Label )

10:01

The only requirement is that the data be hashable.

10:04

So let’s use that, and for now we will just associate the data of the strings “A”, “B” and “C”: List { NavigationLink(value: "A") { Text("Go to screen A") } NavigationLink(value: "B") { Text("Go to screen B") } NavigationLink(value: "C") { Text("Go to screen C") } }

10:28

Already we are seeing how the new navigation APIs allow for decoupling of the source and destination of navigation. In order to construct these navigation links we didn’t have to mention any symbols for the view we are navigating to. Instead we describe a piece of data, in this case a string, and then some mechanism somewhere else will need to interpret that data.

10:47

And in fact, SwiftUI will even give us a hint at how this interpretation process is supposed to happen if we run the app and tap on a link. We will see that nothing happens, but if we know to check the console we will see a message from the bowels of SwiftUI: A NavigationLink is presenting a value of type “String” but there is no matching navigationDestination declaration visible from the location of the link. The link cannot be activated. Note: Links search for destinations in any surrounding NavigationStack, then within the same column of a NavigationSplitView.

11:08

It’s a bit of a bummer that this warning is hidden in the logs and can only be caught at runtime, but at least it lets us know that we are missing half the story when it comes to navigation.

11:17

When the navigation link is tapped, it sends that value piece of data up the view hierarchy, and at any point in the hierarchy we can intercept the data in order to figure out where we should navigate.

11:28

We can do this by using the other overload of the navigationDestination view modifier that we briefly mentioned last episode: func navigationDestination<D: Hashable, C: View>( for data: D.Type, destination: @escaping (D) -> C ) -> some View

11:31

It takes a type as an argument, which is the type of data that we want to listen for and intercept. When it sees a piece of data going up the view hierarchy that matches D , it will grab it, hand it off to the destination closure, and whatever view we return from that closure will be what is pushed onto the stack.

11:52

So, let’s intercept the letter and push on a simple text view that displays the letter: List { … } .navigationDestination(for: String.self) { letter in Text("Screen \(letter)") .navigationTitle(Text(letter)) }

12:19

And now when we run the preview and tap on a button we drill down to a screen that displays that letter.

12:34

But how does this work?

12:35

Well, when you tap a navigation link, not only does it send the data up the view hierarchy to be intercepted and interpreted, it is also appended to a little internal collection deep inside NavigationStack and hidden from us. And then when you tap the back button that piece of data is popped off that internal collection of data.

12:52

The initializers of NavigationStack that allow you to provide a binding allow you to publicly surface that collection so that you can make changes to it and observe it, and that’s exactly what allows us to drive navigation from state. But before we get to that there’s a few other things left to explore.

13:06

One thing you may notice is that the code that describes the navigation, in particular the NavigationLink , is quite far from the code that interprets the data, in particular navigationDestination . It’s on us to make sure that these APIs match each other. That is, if we put a string in the NavigationLink then we should also intercept a string with navigationDestination somewhere up the view hierarchy. In fact, a moment ago we saw that if we do this incorrectly then the navigation just doesn’t work and we get logs printed into the console.

13:37

This seems precarious, but it can also be kind of powerful. It allows you to construct navigation links with other kinds of data attached, and then you can just chain along another navigationDestination to intercept that new kind of data.

13:49

For example, we could have a link to a number instead of a letter: NavigationLink(value: 42) { Text("Go to screen 42") }

13:56

And then intercept it in order to push on another screen: .navigationDestination(for: Int.self) { number in Text("Screen \(number)") .navigationTitle(Text("\(number)")) }

14:16

And just to make things a little more interesting, let’s move the list to its own view: struct ListView: View { var body: some View { List { NavigationLink(value: "A") { Text("Go to screen A") } NavigationLink(value: "B") { Text("Go to screen B") } NavigationLink(value: "C") { Text("Go to screen C") } NavigationLink(value: 42) { Text("Go to screen 42") } } } }

14:38

…and then use that view as both the root of the navigation stack as well as the destination to drill down to: NavigationStack { ListView() .navigationDestination(for: String.self) { letter in ListView() .navigationTitle(Text(letter)) } .navigationDestination(for: Int.self) { number in ListView() .navigationTitle(Text("\(number)")) } .navigationTitle(Text("Root")) }

14:49

Now we can drill down any number of levels, and if we tap and hold the back button we will see a little menu of the breadcrumbs of past screens. State-driven stacks

14:59

This is looking good, but currently this navigation is not state driven. It may look state driven since we are decorating these navigation links with data and then interpreting that data to determine what screen to show in a drill down, but it’s still not possible to construct a piece of state, hand it to SwiftUI, and be automatically restored to some deeply nested screen.

15:17

In order to do that we need to actually start making use of one of the binding initializers of NavigationStack . By far the easiest one to start with is the NavigationPath binding: NavigationStack(path: <#Binding<NavigationPath>#>)

15:41

In order to get a binding of a NavigationPath we need to introduce some state. The easiest way to do that is to introduce some local @State : struct ContentView: View { @State var path = NavigationPath() var body: some View { NavigationStack(path: self.$path) { … } } }

15:57

That’s all it takes and now navigation is driven by state.

16:05

If we run the app it all works exactly the same:

16:12

But secretly, under the hood, every time we push a new screen onto the stack or navigate back, the path state is being mutated. We can even add an .onChange to the view in order to see when it changes: .onChange(of: self.path) { dump($0) } Drilling down a few layers will print out something like this: ▿ SwiftUI.NavigationPath ▿ _items: SwiftUI.NavigationPath.(unknown context at $105bac850).Representation.eager ▿ eager: 4 elements ▿ SwiftUI.(unknown context at $105bac5d8).CodableItemBox<Swift.String> #0 ▿ super: SwiftUI.NavigationPath_ItemBoxBase - isDoubleDispatchingEqualityOperation: false - base: "A" ▿ SwiftUI.(unknown context at $105bac5d8).CodableItemBox<Swift.String> #1 ▿ super: SwiftUI.NavigationPath_ItemBoxBase - isDoubleDispatchingEqualityOperation: false - base: "B" ▿ SwiftUI.(unknown context at $105bac5d8).CodableItemBox<Swift.String> #2 ▿ super: SwiftUI.NavigationPath_ItemBoxBase - isDoubleDispatchingEqualityOperation: false - base: "C" ▿ SwiftUI.(unknown context at $105bac5d8).CodableItemBox<Swift.Int> #3 ▿ super: SwiftUI.NavigationPath_ItemBoxBase - isDoubleDispatchingEqualityOperation: false - base: 42 - subsequentItems: 0 elements - iterationIndex: 0

16:50

This is showing the guts of NavigationPath . And even though NavigationPath does not expose any of this to us publicly, we can of course use a mirror to traverse into the structure and grab whatever we want. It just wouldn’t be super safe to do that since this internal representation can change with any update of iOS.

17:20

But the really cool thing is that now we can start up the application with a bunch of state already in the path: @State var path = NavigationPath(["A", "B", "C", 42])

17:39

Well, this doesn’t compile because apparently NavigationPath ’s initializer doesn’t allow for a heterogenous array of any Hashable s. To do this we need to break it out into multiple lines: @State var path = { var path = NavigationPath() path.append("A") path.append("B") path.append("C") path.append(42) return path }()

18:12

And now when we run the preview we see it immediately starts with the 42 screen displayed, and we can pop back to “C”, “B” and then “A”.

18:28

So, this shows that navigation is being drive by state, and it works bidirectionally. As the user is performing actions on the screen, the path state is being mutated, and further, if we programmatically decide to mutate the state, SwiftUI will figure out what needs to be pushed and popped, and will handle animations automatically.

18:53

This can actually be pretty impressive. For example, suppose we added a button to the top of the screen that randomly created a collection of letters and numbers, and then stuffed that into the navigation path: var body: some View { VStack { Button { self.path = NavigationPath() for _ in 0...Int.random(in: 3...6) { if Bool.random() { self.path.append(Int.random(in: 1...1_000)) } else { self.path.append( String( Character( UnicodeScalar( UInt32.random( in: "A".unicodeScalars.first!.value ... "Z".unicodeScalars.first!.value ) )! ) ) ) } } } label: { Text("Random stack") } NavigationStack(path: self.$path) { … } } } When the button is tapped we pop everything off the stack, loop a random number of times between 3 and 6, and then randomly decide between appending a random integer or a random letter.

20:39

Running the preview we can see that tapping the button fully updates the path. Further, if the new path has more elements than the old, it does a drill down animation, and if the path has fewer it does a pop back animation. And if the number of elements is the same, no animation is performed.

21:12

This is pretty cool stuff, and it’s hard to imagine how we would create this using the nest tree-like structure of navigation state that we previously discussed. However, this makes for a great demo, but of course this isn’t a real world use case. In a real life application each of these screens would have behavior of their own, and we may need them to interact with each other in complex ways, and we may need to analyze the entire stack of screens being presented so that we can aggregate some kind of information.

21:51

So, let’s start layering on more functionality and see how to best leverage these new navigation APIs.

21:58

Let’s give one of these screens some more complex behavior. Let’s add the most prototypical form of behavior of any SwiftUI application: a counter.

22:08

Perhaps when we tap the “Go to screen 42” button we can navigate to a different kind of view that houses a counter that starts at 42. And then we can count up and down, and just to make things a little more interesting we will have a button that when pressed makes a network request to fetch a fact about that number, and shows the fact in a sheet.

22:31

We are going to paste in the basics of such a view since there isn’t anything too special about it yet: struct CounterView: View { @State var count = 0 @State var fact: FactSheetState? var body: some View { List { HStack { Button("-") { self.count -= 1 } Text("\(self.count)") Button("+") { self.count += 1 } } .buttonStyle(.borderless) Button { Task { @MainActor in let (data, _) = try await URLSession.shared.data( from: URL( string: "http://numbersapi.com/\(self.count)" )! ) self.fact = FactSheetState( message: String(decoding: data, as: UTF8.self) ) } } label: { Text("Number fact") } .buttonStyle(.borderless) } .sheet(item: self.$fact) { fact in Text(fact.message) } .navigationTitle(Text("\(self.count)")) } } struct FactSheetState: Identifiable, Hashable { var message = "" var id: Self { self } }

23:18

And then in the navigationDestination for Int we will use this view instead of showing another ListView : .navigationDestination(for: Int.self) { number in CounterView(count: number) }

23:38

Now when we run the preview we can drill down to the counter view, increment and decrement the counter, and fetch a fact for the number. So it works!

23:59

Let’s also make it possible to further drill down to the other parts of the application from the counter view so that we can continue drilling down deeper: Section { NavigationLink(value: "A") { Text("Go to screen A") } NavigationLink(value: "B") { Text("Go to screen B") } NavigationLink(value: "C") { Text("Go to screen C") } NavigationLink(value: self.count) { Text("Go to screen \(self.count)") } }

24:46

So we now have a bit more of complicated app and it still works with deep linking. We can start the app off with a bunch of numbers on the stack: @State var path = { var path = NavigationPath() … path.append(42) path.append(1_000) return path }

24:57

And we now have 2 counter views on the stack, and each functions independently.

25:04

So, this is seeming pretty good. We can introduce behavior to the individual screens and things just work.

25:11

But things aren’t as great as they seem. First of all, it may seem like we can deep link into the application, but that is only true for the navigation stack. We don’t further have the ability to drill down into additional states of the screens inside the navigation stack.

25:22

For example, want if we wanted to deep link into a state where a counter is pushed onto the stack and also its fact sheet is showing?

25:33

That currently is not possible because the only thing that represents the screen we are drilled down to is a simple integer. We have no opportunity to further specify that we want the fact sheet to be open.

25:48

Now one thing we could do is introduce some state that represents the counter destination, so that we can specify not only the initial count of the screen, but also whether or not the fact sheet is up: struct CounterDestination: Hashable { var initialCount = 0 var fact: FactSheetState? }

26:19

Then we would update the navigationDestination modifier to listen for this kind of data rather than a plain integer, and pass along the parts to CounterView : .navigationDestination(for: CounterDestination.self) { destination in CounterView( count: destination.initialCount, fact: destination.fact ) }

26:40

This compiles, but it can’t possibly work, because anywhere we construct a navigation link we should now associate a CounterDestination instead of a plain integer: NavigationLink( value: CounterDestination(initialCount: 42) ) { Text("Go to screen 42") } … NavigationLink( value: CounterDestination(initialCount: self.count) ) { Text("Go to screen \(self.count)") }

27:00

The application runs exactly as it did before, but now we can further deep link into a state with a counter pushed onto the stack and a fact sheet presented: path.append( CounterDestination( initialCount: 1_000, fact: FactSheetState( message: """ 1,000 is the number or words a picture is worth. """ ) ) )

28:10

So, it works, but it’s also pretty weird. Do we really have to maintain this extra data type just so that we can communicate deeper into the counter feature? And if the counter feature picks up more destinations we will have to repeat all of that state in CounterDestination if we want a chance at deep linking into those screens.

28:41

Even more complicated, what if the fact sheet had a navigation stack of its own? And what if we wanted to be able to deep link into a state where the counter view is pushed onto the stack, a fact sheet is presented, and we are further drilled down to a specific screen in the fact screen. We are going to have to add all of that state to CounterDestination , which is going to make that type grow and become complex. And it’s a weird type to maintain since its only purpose is to facilitate deep linking into the application. We construct it, hand it to SwiftUI, but never need to touch it again.

29:10

It’s possible to fix that, but before doing that let’s point out another weird thing in our code right now, and it will dovetail into the fix. What if we wanted to be able to aggregate all the counts in the entire navigation stack? Maybe there’s a button up at the top that when tapped can compute it: Button { <#???#> } label: { Text("Sum the counts") }

30:05

You might hope you could just iterate over the elements of the path, try casting them to CounterDestination , and then add up the counts: var count = 0 for element in self.path { guard let counter = element as? CounterDestination else { continue } count += counter.initialCount } print(count)

30:45

This doesn’t work because, as we mentioned before, NavigationPath isn’t really a collection: For-in loop requires ‘NavigationPath’ to conform to ‘Sequence’

30:49

The documentation bills it as a kind of type-erased collection, but its collection functionality is quite limited. We don’t have the ability to iterate over the path or subscript into it to access individual elements. So, this isn’t really possible without delving into a mirror on NavigationPath which can be brittle and error prone.

31:14

But, even if it was possible, it still wouldn’t be correct. The CounterDestination value in the navigation path represents the state of the drill down at the moment of when the drill down happens. It doesn’t update as we increment and decrement the count inside the view. The count and fact sheet state is all local to the view, and it has no influence over the value stored in the navigation state. So, even if we were able to sum over all the elements in the navigation path, we would only be seeing the counts at the moment of the drill down and not their current values. Deep-linking and observing

32:27

The two problems we are seeing here, that of the navigation path not actually reflecting the current state of the screen, and that of needing to maintain a data type of duplicate state just so that we could transfer it to the screen, are intimately related and can be fixed at the same time.

32:42

Rather than using simple data types that have a bunch of duplicated code to represent navigation destinations, why don’t we just reuse the model from the destination view?

32:56

Well, we can’t do that right now because the CounterView doesn’t have a proper model. It’s implemented all of its logic directly in the view using local @State : struct CounterView: View { @State var count = 0 @State var fact: FactSheetState? … }

33:05

This style of development is great for getting your feet wet but does not scale well. As we just saw it does not play well with deep linking, but also it is difficult to test this functionality because it is locked up in the runtime behavior of the view. The only way to test this logic is to literally run the view in a simulator via a UI test, which is slow and flakey.

33:23

A more future proof way to encapsulate a view’s logic is via a dedicated observable object conformance. The easiest way to do this is to make a new class that conforms to ObservableObject , and then transfer all @State properties to the class, but make them @Published : final class CounterModel: ObservableObject { @Published var count = 0 @Published var fact: FactSheetState? }

33:45

Now you could technically stop here, and in the view you would just continue performing all of the logic and reaching directly into the model to mutate its state. But it’s better to create methods on this model that represent the user’s actions, such as tapping buttons, and then perform all the logic in those methods: final class CounterModel: ObservableObject { @Published var count = 0 @Published var fact: FactSheetState? func incrementButtonTapped() { self.count += 1 } func decrementButtonTapped() { self.count -= 1 } func factButtonTapped() { Task { @MainActor in let (data, _) = try await URLSession.shared.data( from: URL( string: "http://numbersapi.com/\(self.count)" )! ) self.fact = FactSheetState( message: String(decoding: data, as: UTF8.self) ) } } }

34:18

And we’ll add an initializer so that it’s possible to create a CounterModel with a sheet already showing if we so desire: init(count: Int = 0, fact: FactSheetState? = nil) { self.count = count self.fact = fact }

34:36

Then the view can become really simple by just calling out to those methods rather than performing any logic itself, which as we said a moment ago is very difficult to test when trapped in a view.

34:43

But now we have a choice. Do we store this model as an @ObservedObject or a @StateObject ? struct CounterView: View { @ObservedObject var model: CounterModel // vs @StateObject var model = CounterModel() … }

35:08

An @ObservedObject is preferable if you want to be able to interact with the model in a way that is observable from the outside. Any changes you make to the model in the view can be seen from the parent and vice-versa.

35:20

This allows the source of truth for this view to live higher up the view hierarchy, which can be great for integrating many features together in a concise way. You can even write integration tests for many features at once by just testing a single observable object, which is exactly what we did multiple times in the inventory application from previous episodes.

35:37

Alternatively, a @StateObject is preferable if you want the source of truth of this view to reside locally with the view itself, and not influenced from the outside at all. The parent view will have no insight into what is happening on the inside of CounterView , and the CounterView will not be able to influence the parent view by mutating this model.

35:54

Now, that doesn’t sound like what we want here. The whole reason we want a model for this view is so that the model can live in the navigation stack, allowing the parent view to observe what is happening in the counter feature.

36:05

So, we are going to go with an @ObservedObject in the hopes that it will make aggregating and analyzing data across the entire navigation stack possible, such as summing up all the counts. struct CounterView: View { @ObservedObject var model: CounterModel … }

36:14

And we need to update the view to interact with the model rather than mutating local state directly: HStack { Button("-") { self.model.decrementButtonTapped() } Text("\(self.model.count)") Button("+") { self.model.incrementButtonTapped() } } … Button { self.model.factButtonTapped() } label: { Text("Number fact") } … NavigationLink( value: CounterDestination( initialCount: self.model.count ) ) { Text("Go to screen \(self.model.count)") } … .sheet(item: self.$model.fact) { fact in Text(fact.message) } .navigationTitle(Text("\(self.model.count)"))

36:29

Now we have a compiler error where we describe the navigationDestination because the CounterView now takes a model. What if we could use a CounterModel as the data we associate with our navigation links rather than a simple data type such as a string or integer: NavigationLink( value: CounterModel(count: self.model.count) ) { Text("Go to screen \(self.count)") } … .navigationDestination(for: CounterModel.self) { model in CounterView(model: model) }

37:00

It may seem a little weird, but that does allow us to pass a model down to the CounterView from the outside.

37:05

Now, navigationDestination does require that the data be Hashable , so we must implement that on our CounterModel : final class CounterModel: ObservableObject, Hashable { … static func == ( lhs: CounterModel, rhs: CounterModel ) -> Bool { lhs === rhs } func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(self)) } }

37:15

This is no different from what we encountered in our inventory application, and these implementations are the most reasonable thing we can do. Equality of two CounterModel s will be decided by their reference type identity, and to hash the model we will simply hash the ObjectIdentifier , which is a stable identity for the duration of the application.

37:50

Things are compiling, but this isn’t right yet. We are using navigationDestination to listen for CounterModel values, but we haven’t updated any navigation links to use CounterModel . They are all still using CounterDestination .

38:03

Let’s delete CounterDestination since we know we don’t want to maintain that code anymore.

38:09

And then we just have two places to fix. We can update the two call sites that previously used CounterDestination : NavigationLink(value: CounterModel(count: 42)) { Text("Go to screen 42") }

38:26

If we run the app in the preview everything works exactly as it did before, but now we can more easily deep link into an exact place of the application. We can just construct a CounterModel with the precise state we want, hand it off to SwiftUI, and let it do it’s thing: path.append( CounterModel(count: 42) ) path.append( CounterModel( count: 1_000, fact: FactSheetState( message: """ 1,000 is the number of words a picture is worth. """ ) ) )

39:32

And we can also get the “Random stack” functionality working again by appending a CounterModel to the path instead of a random integer: if Bool.random() { self.path.append( CounterModel(count: .random(in: 1...1000)) ) }

39:37

And now that works too.

39:45

This works without any secondary data type to maintain because we are only dealing with the view’s model and nothing else.

40:03

So, this little toy app has gotten pretty complex, and we are seeing that if we want full deep linking capabilities in our application, meaning not only push a few screens onto the stack but also further open sheets, covers and what-have-you in those screens, then we need to embrace observable objects for each screen. And further, we must use them as @ObservedObject s instead of @StateObject s, and we must put those models in the navigation links.

40:28

Things are looking good, but now let’s revisit that feature where we wanted to have a button for computing the sum of all the counts on the stack. Is that any more possible now?

40:39

Well, sadly no.

40:41

We have solved part of the problem because now we are storing full-blown CounterModel s in the path, which means the data in the path represents the current count of each screen. Not just the value of the count at the moment of pushing

40:50

But the other part of the problem is that NavigationPath is still completely type erased, and so we have no easy way of getting access to the data on the inside. We could of course resort to reflection, but that will be brittle and prone to breaking with each iOS update.

41:05

So, what are we to do?

41:07

The fix for this brings us to the final new navigation tool that iOS 16 introduced, and it’s the third initializer on NavigationStack that allows you to specify a binding of a concrete, non-type-erased collection: NavigationStack( path: <#Binding<MutableCollection & RandomAccessCollection & RangeReplaceableCollection>#> )

41:24

Since the source of all our problems with NavigationPath is that it is type erased it may not be too surprising that the fix is to use a statically typed collection.

41:32

So, let’s swap out the nebulous NavigationPath for a simple array of CounterModel s: @State var path: [CounterModel] = []

41:40

Now already this doesn’t seem quite right because we also want to be able to push on other types of screens besides just the CounterView , but let’s get our feet wet before jump into the deep end.

41:50

The first error we have is in the “Random stack” button, where now we can empty out the path by just assigning to an empty array: self.path = []

42:00

And we will comment out the else of the conditional since we are only going to deal with the counter feature for now.

42:08

And that’s all it takes. Everything is now compiling, and mostly works the same as it did before. We can drill down into counter views, ask for facts, and even put in a random stack, but we can’t drill down to any of the other screens. We will take care of that in a moment.

42:23

Before doing that, let’s make sure deep linking still works: @State var path: [CounterModel] = [ CounterModel(count: 42), CounterModel( count: 1729, fact: FactSheetState( message: "1,729 is a good number." ) ) ]

42:48

This also works. The application instantly launches with a sheet up, and if we dismiss the sheet we see we are drilled down multiple levels.

42:59

And already we can see a benefit to this change. Because the path of the navigation is now statically typed, rather than erased like it was when using the NavigationPath , we can implement that theoretical function that sums up all the counters in the entire navigation stack: Button { var count = 0 for model in self.path { count += model.count } print("Sum", count) } label: { Text("Sum the counts") }

43:56

This gives us a ton of flexibility to inspect what is happening inside our navigation stack, and that can be super powerful.

44:01

Now what does it take to support more types of destinations beyond just the counter screen? We can model all of the possible destinations as an enum and then hold onto an array of the enum: @State var path: [Destination] = [] enum Destination: Hashable { case counter(CounterModel) case letter(String) } This allows us to push on any number of counter views, as well as “letter” views, and we can even weave them together.

44:31

We’ll have a few things to fix. First of all, in the action closure of the “Random stack” button we need to specify which destination we are appending to the path: if Bool.random() { self.path.append( .counter( CounterModel(count: .random(in: 1...1000)) ) ) } else { self.path.append( .letter( String( Character( UnicodeScalar( UInt32.random( in: "A".unicodeScalars.first!.value ... "Z".unicodeScalars.first!.value ) )! ) ) ) ) } Next we need to update that theoretical functionality for summing up all the counts because we now have to inspect the destinations in the path to only sum up the counter models: Button { var sum = 0 for destination in self.path { switch destination { case let .counter(model): sum += model.count case .letter: break } } print("Sum", sum) } label: { Text("Sum counts") }

45:14

Next we need to update the .navigationDestination view modifier to listen for the Destination type, which we can switch on an decide which view we want to show: ListView() .navigationDestination(for: Destination.self) { destination in switch destination { case let .counter(model): CounterView(model: model) case let .letter(string): Text("Screen \(string)") .navigationTitle(Text(string)) } }

46:04

And finally we need to update all NavigationLink(value:) initializations to associated a Destination with the link.

46:45

And just like that the application works exactly as it did before. We can drill down to either text or counter viewers, and we can even deep link into a state in which there are some counter views and some text views on the stack. Decoupling features

47:00

So this is looking really great. By just constructing an array of a bunch of types of destinations we have restored a deep stack in the view. And it all just works without a bug in sight.

47:10

But, we have also lost something in our translation.

47:15

We are now using the models of each of our features directly in the navigation stack, and that fixed all the problems we saw previously, but now our features are tightly coupled together. Before that refactor we could just decorate the navigation links with simple value types that were later interpreted.

47:30

But now in order for the counter feature to navigate to some other feature, it has to actually be able to compile everything in that feature.

47:39

This doesn’t seem right, so let’s see what can be done about it.

47:43

We have accidentally coupled all of our destinations domains together. In order for a view to construct a navigation link to the counter screen, it has to be able to construct a CounterModel , and further even a case of the Destination enum. NavigationLink( value: ContentView.Destination.counter( CounterModel(count: self.model.count) ) ) { Text("Go to screen \(self.model.count)") }

47:53

This means the view must be able to compile the CounterModel , and that’s a huge problem. The CounterModel holds all of the data types, logic, behavior and dependencies for the counter feature. It is a significant cost to need to compile that just so that we can navigate to it. And worse, it must be able to compile the entire Destination enum, which includes all other destinations. We don’t have anything else heavyweight in the other cases now, but over time this enum will take longer and longer to compile.

48:20

There is a way to fix this, but sadly we cannot use this NavigationLink initializer since it requires us to provide the data immediately. We need to come up with a system for having a child feature communicate to the navigation stack that it wants to drill down to some destination without the child feature needing to compile anything from that destination.

48:42

We can adopt a style that we used a lot in the inventory application from past episodes by using delegate closures that the parent can hook into. For example, in the CounterModel we add closures that represent either a number or letter being tapped: final class CounterModel: ObservableObject, Hashable { var onTapNumber: (Int) -> Void = { _ in } var onTapLetter: (String) -> Void = { _ in } … }

49:43

The most important feature of these closures is that they do not need to reference any of the symbols in the destinations. So if the “letter” feature becomes large and complex, we will never need to compile that code. Instead we just tell the parent that we tapped a letter, and it can decide how it should perform the navigation.

50:07

Next we need to actually invoke these closures when the number or letter buttons are tapped in the UI. We can create dedicated methods on the CounterModel for that: func numberButtonTapped(number: Int) { self.onTapNumber(number) } func letterButtonTapped(letter: String) { self.onTapLetter(letter) }

50:36

Now, you may wonder why we need this extra level of indirection when we could just invoke the onTapNumber or onTapLetter closures directly from the view. But, by having dedicated methods we can layer on additional functionality, such as tracking analytics, or maybe even performing a side effect before we notify the parent.

51:02

Next we can update the view to use plain Button s instead of NavigationLink s so that we can invoke the model methods: Button { self.model.goToLetterTapped(letter: "A") } label: { Text("Go to screen A") } Button { self.model.goToLetterTapped(letter: "B") } label: { Text("Go to screen B") } Button { self.model.goToLetterTapped(letter: "C") } label: { Text("Go to screen C") } Button { self.model.goToNumberTapped(number: self.model.count) } label: { Text("Go to screen \(self.model.count)") }

51:46

It’s definitely a bummer that we have to give up using NavigationLink s just because we want to use a static type for the NavigationStack path binding and decouple our views, but sadly that is the state of things. We’ve filed feedbacks with Apple to request more flexible NavigationLink initializers, and we highly recommend our viewers do the same if this affects you.

52:16

Everything now compiles, but of course nothing will work because no one is hooking into these delegate closures that we have exposed in the CounterModel . However, we do at least get some nice runtime warnings that things are quite right. If we run the application, drill down to the counter screen, and then tap a button we will get instant notification that we haven’t overridden the closure.

52:26

So, this question is: where can we do this work?

52:31

It needs to be done anytime a new screen is added to the navigation stack’s path. Right now that state is managed locally as an @State variable directly in the view. If we want to be able to layer on additional logic to bind models that are added to the stack, we need to move this logic out of the view and into its own observable object.

52:53

So let’s do that: class AppModel: ObservableObject { @Published var path: [Destination] init(path: [Destination] = []) { self.path = path } enum Destination: Hashable { case counter(CounterModel) case text(String) } }

53:22

…and update the view to take an @ObservedObject : struct ContentView: View { @ObservedObject var model: AppModel … }

53:28

And the NavigationStack can take a binding to the model: NavigationStack(path: self.$model.path) { … } …

54:36

With this set up we can now listen for any changes to the path in the AppModel and perform any binding necessary: @Published var path: [Destination] { didSet { self.bind() } } func bind() { for destination in self.path { switch destination { case let .counter(model): model.onTapLetter = { [weak self] letter in guard let self else { return } self.path.append(.letter(letter)) } model.onTapNumber = { [weak self] number in guard let self else { return } self.path.append( .counter(CounterModel(count: number)) ) } case .letter: break } } }

56:14

With that small change the application is back to working. We can drill down to the counter screen, and then further drill down into another screen. This works because when the counter model was pushed onto the path, this bind method was called, and we were able to tap into the delegate closures.

56:44

If in the future more delegate closures are added to CounterModel we will want to override them here. Or, if the “letter” feature gets its own model with its own delegate closures, we will want to make sure to override them here.

57:02

So things are looking good, but deep linking is not going to work right now. If we start up the application with some screens already on the stack, we will see that tapping a letter or number row does not cause a drill down and we even get one of those purple warnings.

57:29

That is happening because if we start the AppModel in a state with the path already having elements, we are not given an opportunity to bind those models. The didSet is not called.

57:41

The fix is easy enough. We just need to bind in the initializer: init(path: [Destination] = []) { self.path = path self.bind() }

57:51

Now deep linking works exactly as we expect. Conclusion

57:58

So, we have now shown off all of the new navigation tools that were introduced in iOS 16, and if there was one word I would use to sum up our experiences in the past two episodes it would be… rollercoaster.

58:10

There were moments of excitement where we could see how the new APIs were going to solve some real pain points in the older tools, and then immediately came crashing back down to earth as we uncovered new bugs or limitations. And then it all repeated again and again.

58:24

First we saw that the new navigationDestination view modifier that takes a binding was going to fix the problems that occur when putting navigation links directly in a list, and even helped us decouple two domains. This allowed us to leverage the powerful pattern of representing navigation as a deeply nested tree of enums.

58:44

But then we came crashing back down once we saw that deep linking is utterly broken. If you try to deep link with that API it will just completely break your ability to ever drill down to destinations. Luckily we could work around the bug, but there are still bugs lurking in the shadows.

59:02

Then we saw that there is an initializer on NavigationStack that takes a binding to something called a NavigationPath , which is like a flat collection of data representing all the screens pushed on the stack. And if we use that initializer we can separate navigation into two steps: a piece of data that describes the navigation, and then the interpretation of that data to present a view. This allows us to massively decouple each screen inside the navigation stack.

59:29

But then we came crashing back down once we realized that NavigationPath is completely opaque to us. We are not allowed to iterate over it, access elements on the inside, or even add elements to it anywhere other than at the very end.

59:42

Then we saw that the other initializer on NavigationStack takes a binding to a static, homogenous collection of data. If we use that initializer we can inspect the current data inside the navigation stack in order to aggregate it or layer on additional logic.

59:58

But then we came crashing back down yet again once we saw that leads back to feature coupling. If feature A wants to navigate to feature B, then feature A needs to be able to build feature B. Luckily we did find a workaround by using plain Button s instead of NavigationLink s, which allowed the child feature to communicate to the parent that it wants to navigate somewhere, and then the parent actually constructs the model and appends it to the path. This allowed us to coalesce all coupling to just the root of the navigation stack, and then all screens in the stack could remain independent.

1:00:28

But with that rollercoaster ride coming to a stop, this concludes our series of episodes on vanilla SwiftUI navigation, and so you might hope that our next series will finally be our long awaited series on navigation in the Composable Architecture. The navigation tools we are building for the Composable Architecture are absolutely incredible. They solve most of the problems we encountered with vanilla SwiftUI, and thanks to the new dependency management system we introduced recently, they bring full blown super powers to navigation.

1:00:55

We can’t wait to share it with everyone, and that is why it makes us so sad to say that Composable Architecture navigation is not our next series of episodes.

1:01:02

Before we move on, we want to show that it is possible to build a complex, modern, vanilla SwiftUI application using the tools we have open sourced in our SwiftUINavigation library. So, if you or your team aren’t ready to buy into the Composable Architecture, and we know there are a lot of people out there that can’t, we still want to show you how to model your domains as concisely as possible, how to integrate many features together, and how to write comprehensive tests.

1:01:28

That is why next week we will build a brand new vanilla SwiftUI from scratch, using all of our tools, to show how a modern SwiftUI application can look. We will be rebuilding a demo that Apple built in order to show off SwiftUI, and that will also give us an opportunity to see how our style compares with theirs.

1:01:47

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 Downloads Sample code 0213-navigation-stacks-pt3 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 .