EP 228 · Composable Navigation · Mar 27, 2023 ·Members

Video #228: Composable Navigation: Destinations

smart_display

Loading stream…

Video #228: Composable Navigation: Destinations

Episode: Video #228 Date: Mar 27, 2023 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep228-composable-navigation-destinations

Episode thumbnail

Description

While we just tackled drill-down navigation, sadly the API we used was deprecated in iOS 16. Let’s get things working with the new navigationDestination view modifier, and see what testing in the Composable Architecture has to say about navigation.

Video

Cloudflare Stream video ID: bdc4a60f07d73fe4507c9254382d18cd Local file: video_228_composable-navigation-destinations.mp4 *(download with --video 228)*

References

Transcript

0:05

OK, I feel like we’ve said this too many times in this series of episodes but we can’t help say it again: this stuff is really incredible.

0:12

We have now built tools to support 6 different forms of navigation: alerts, confirmation dialogs, sheets, popovers, covers and now navigation links. Sure, it was the deprecated form of navigation links, but it’s still really impressive stuff.

0:25

If you’re willing to put in a little bit of upfront domain modeling work when building your features, then you get to treat all of these forms of navigation as all basically the same thing. You just have some optional state, you invoke a reducer operator to integrate a child feature with the parent, and you invoke a method in the view layer to integrate a child view with the parent. Brandon

0:44

But once you do that there are massive benefits. Parent and child domains get a really simple way to communicate with each other. Just a moment ago we saw that the parent inventory feature could instantly see every edit made to an item inside the ItemFormFeature , and used that power in order to update the items collection instantly. And deep linking basically just comes along for free.

1:09

Testing is another super power that is unlocked once you perform the upfront work. We’ve seen this time and time again so far in this series, but let’s do it again. Testing links

1:19

We are going to write a test for the edit flow. We will get a basic stub in place: func testEditItem() async { }

1:28

And we will construct a test store holding onto the InventoryFeature that starts with a single item in the inventory: let item = Item.headphones let store = TestStore( initialState: InventoryFeature.State(items: [item]), reducer: InventoryFeature() )

1:51

We will then emulate the user tapping on an item, which should cause a drill-down to the ItemFormFeature screen: await store.send(.itemButtonTapped(id: item.id))

2:03

We expect the state to mutate by populate the editItem field with some state that holds onto the item that was tapped: await store.send(.itemButtonTapped(id: item.id)) { $0.editItem = ItemFormFeature.State(item: item) }

2:16

Next we will emulate the user typing into the name field of the edit form in order to change the item to “Bluetooth Headphones”: await store.send( .editItem( .presented( .set(\.$item.name, "Bluetooth Headphones") ) ) )

2:30

Now there’s one obvious state change we expect here, which is the item inside the editItem state should mutate to the new item name: await store.send( .editItem( .presented( .set(\.$item.name, "Bluetooth Headphones") ) ) ) { $0.editItem?.item.name = "Bluetooth Headphones" }

2:45

Finally, let’s emulate the user dismissing the edit screen causing the editItem state to be cleared out but also the items collection is updated: await store.send(.editItem(.dismiss)) { $0.editItem = nil $0.items[0].name = "Bluetooth Headphones" }

3:40

That is the full test, and if we run it we will see it passes.

3:42

This test is absolutely amazing. We are actually testing what happens when you drill down to a screen, perform some actions in that screen, and then pop back to the root, and verifies that the two features communicated with each other correctly. And this is a unit test that runs in a tiny fraction of a second rather than a UI test, which would takes multiple seconds and be flakey.

4:11

We also had the style where we mutated the edited item in place. We could bring this back and get some test overage around that behavior, which is more difficult to see when running the app. First, let’s update the state for every edit item action, not just dismissal. case .editItem/*(.dismiss)*/: … state.items[id: item.id] = item

4:56

And we can update tests accordingly. The list item will now be updated on every edit: await store.send( .editItem( .presented( .set(\.$item.name, "Bluetooth Headphones") ) ) ) { $0.editItem?.item.name = "Bluetooth Headphones" $0.items[0].name = "Bluetooth Headphones" } And dismissal will simply nil out the edit item state: await store.send(.editItem(.dismiss)) { $0.editItem = nil // $0.items[0].name = "Bluetooth Headphones" }

5:45

And tests pass for the new behavior, but I think I prefer the old behavior, so let’s go back to that before moving on.

5:28

We can have a lot of confidence that we are testing everything happening in the feature. For example, if we didn’t assert that the root items collection was updated when dismissing: await store.send(.editItem(.dismiss)) { $0.editItem = nil // $0.items[0].name = "Bluetooth Headphones" }

5:37

…then we get an immediate test failure letting us know we didn’t assert on everything that happened: testEditItem(): A state change does not match expectation: … InventoryFeature.State( addItem: nil, alert: nil, editItem: nil, duplicateItem: nil, items: [ [0]: Item( id: UUID( 4B6AC4E0-5BED-462F-9592-C1BC93D3CDC4 ), − name: "Headphones", + name: "Bluetooth Headphones", color: Item.Color(…), status: .inStock(quantity: 20) ) ] ) (Expected: −, Actual: +)

5:54

Conversely, if we forgotten to integrate the parent and child domains by not updating the items collection in the inventory feature: case .editItem(.dismiss): // guard let item = state.editItem?.item // else { return .none } // state.items[id: item.id] = item return .none

5:59

Then we also get a failure because it is no longer true that the item’s name updated to “Bluetooth Headphones”: testEditItem(): A state change does not match expectation: … InventoryFeature.State( addItem: nil, alert: nil, editItem: nil, duplicateItem: nil, items: [ [0]: Item( id: UUID( EC07C8B3-9A4C-4976-A679-3137D7EE4280 ), − name: "Bluetooth Headphones", + name: "Headphones", color: Item.Color(…), status: .inStock(quantity: 20) ) ] ) (Expected: −, Actual: +)

6:17

It’s just amazing. As long as you make sure to hook up all the state in your view, and as long as you trust SwiftUI, you can have a high amount of confidence that we are actually testing what would happen when this application runs on our user’s device.

6:31

So, we’ve got some really good test coverage on the basics of this edit flow, but also remember that advanced feature we added in the past episode that allowed the ItemFormFeature to dismiss itself? It happened by starting a timer and once the timer ticked enough times the child was dismissed. That was pretty cool, but also we did that when the feature was being presented in a sheet. Does it work for navigation links too?

6:55

It definitely does and with no additional work on our part. We could prove this by running it in the simulator, but even cooler let’s write a test for it first. We’ll get another stub in place: func testEditItem_Timer() async { … }

7:13

Also we will turn off test store exhaustivity so that we can just focus on the core interaction of the child wanting to dismiss itself: store.exhaustivity = .off(showSkippedAssertions: true)

7:30

Further we know a clock is going to be involved in this test so let’s go ahead and override it: let store = TestStore( … ) { $0.continuousClock = ImmediateClock() }

7:45

To test the flow we will drill-down to the edit screen: await store.send(.itemButtonTapped(id: item.id))

7:55

Then start the timer: await store.send( .editItem(.presented(.set(\.$isTimerOn, true))) )

8:03

And finally we expect to eventually receive the dismiss action once the child wants to dismiss itself, which will nil out the editItem state: await store.receive(.editItem(.dismiss)) { $0.editItem = nil }

8:33

And incredibly this passes, and it shows us all the little assertions that we did not explicitly test. These grey boxes are not test failures, but rather things that would fail if we were using exhaustive testing. It gives us a full view into what exactly is going on in the test store without needing to explicitly assert on everything, such as state changes and receiving effects.

9:37

It’s also incredible that we are able to assert at a very high level on just the bare minimum of things we care about. And if we did get something wrong, we would still get a test failure. For example I am not asserting on the edit item state getting populated: await store.send(.itemButtonTapped(id: item.id)) { $0.editItem = … }

9:54

But if I actually forgot to do that in the reducer: // state.editItem = ItemFormFeature.State(item: item)

10:05

We get a failure when we try to send an edit item action to the test store: await store.send(.editItem(.presented(.set(\.$isTimerOn, true)))) testEditItem_Timer(): A presentation action was sent while child state was nil.

11:05

But let’s get things back in building order.

11:25

And now I can have a good amount of confidence that this is just how the feature will work when run in the simulator.

11:30

But let’s check that. I can run in the simulator, drill down to an item, start the timer, and after a few ticks the screen pops off. Just as the test predicted.

11:55

Now there was one glitchy behavior that happened when we were popped off, and if we watch again with slow animations turned on, we’ll see that the pushed view goes blank before it pops off. This is the same behavior we saw when programmatically dismissing sheets, because the IfLetStore that powers the destination view has state that goes nil , resulting in rendering a blank screen. We can use the returningLastNonNilValue helper we wrote in a previous episode when scoping the store we pass along: IfLetStore( self.store.scope( state: returningLastNonNilValue { $0 }, action: { .presented($0) } ) ) { store in self.destination(store) }

12:59

And with that change, programmatic dismissal works without the screen going blank. Destinations

13:10

It’s kind of incredible that we were able to write a unit test for this dismissal behavior in order to observe how the application would actually behave if we were to run it in the simulator or on device. It of course doesn’t tell the full story, as we could have forgotten to hook something up in the view, but it tells a really large part of the story. And if you keep your views very simple and logicless, then it will be easy to spot the places that you forgot to hook something up.

13:37

So, we now have 6 brand new navigation tools in the Composable Architecture: one for alerts, confirmation dialogs, sheets, popovers, covers and now navigation links. Further, all of these tools we’ve built actually work in iOS 13. We still aren’t doing anything fancy. Stephen

13:53

Let’s start pushing our way into the world of iOS 16 navigation tools, because SwiftUI made a lot of big changes in 2022. As we saw in our past series of episodes covering all aspects of SwiftUI navigation, iOS 16 essentially brought two new tools to the table:

14:10

There is of course the fancy new NavigationStack view that takes a binding to a collection of data so that drill-downs to features can be handled by just appending and popping data from a list. It’s a really powerful API but can sometimes be difficult to wield correctly, especially when parent-child communication and testing is important to you.

14:28

And then there’s the lesser known navigationDestination view modifier that takes a binding to a boolean. This allows you to drive drill-down navigations from a single piece of boolean state, and it’s not tied directly to a NavigationLink .

14:41

We are going to focus on the latter API, navigationDestination , to begin with and then work our way up to the NavigationStack .

14:50

Let’s first quickly see what problem the navigationDestination view modifier solves, because even right now we can have some weird things happening in our inventory list. And then we will refactor the code to use navigationDestination and make things a lot nicer.

15:06

The main problem with our InventoryView is that we have a NavigationLink in each row of the list: NavigationLinkStore( id: item.id, store: self.store.scope( state: \.editItem, action: InventoryFeature.Action.editItem ) ) …

15:14

There are two big problems with this:

15:16

First, each navigation link is driven off the same piece of optional state, and that is what led to the bugs we saw earlier where we accidentally activated every single link when we just wanted to drill down to a single item. That is what motivated us to supply the id argument, but it’s on us to remember to do that when we are embedding links in each row of a list.

15:36

Further, one of these navigation links can only cause a drill down if the row is actually visible on the screen. So, if you wanted to deep link into the last item when there are thousands of rows, you would literally have to scroll to the bottom of the list before the drill-down happens.

15:51

This last one is particular pernicious so let’s take a quick look at it in real concrete terms. We can alter the entry point of the application so that we are deep linked into the edit screen for the headphones: initialState: AppFeature.State( inventory: InventoryFeature.State( editItem: ItemFormFeature.State(item: .headphones), … ), selectedTab: .inventory ) But also, let’s add a bunch of items to the beginning of the inventory so to push the headphones item off the screen: initialState: AppFeature.State( inventory: InventoryFeature.State( editItem: ItemFormFeature.State(item: .headphones), items: (1...100).map { Item( name: "Item \($0)", status: .inStock(quantity: $0) ) } + [ .monitor, .mouse, .keyboard, .headphones ] ), selectedTab: .inventory )

16:21

When we start the application we will see that we are not drilled down to the edit screen of the headphones. We have to scroll all the way to the bottom before the drill down happens, because it is only at that moment that SwiftUI realizes there’s a NavigationLink way that has been activated.

16:41

So, these are pretty big problems, and many people tried working around them by having a single NavigationLink installed in the background of the root view, but you had to be careful to make sure to fully hide it, not just from the user but also from the accessibility screen reader: .background { NavigationLink(…) { EmptyView() } .hidden() .accessibilityHidden() // ??? }

17:32

This is what probably motivated Apple to come up with an API that allows activating a drill-down animation without literally having a NavigationLink .

17:40

The navigationDestination(isPresented:) view modifier takes a binding to a boolean: .navigationDestination( isPresented: <#Binding<Bool>#>, destination: <#() -> View#> )

17:53

When the binding flips to true , a drill-down occurs to the destination view provided, and when the binding flips to false the destination will be popped off.

18:00

We can install this view modifier at the root of the inventory feature, even attaching it directly to the List view, which means whether or not a drill-down occurs is completely decoupled from the row that wants to perform the drill-down.

18:12

So, this is sounding pretty good! Let’s try using it.

18:14

First, we need to drop the NavigationLink from each row and replace it with a simple button: Button { viewStore.send(.itemButtonTapped(id: item.id)) } label: { … }

18:30

Next, at the bottom of the view we want to make use of the navigationDestination(isPresented:) API: .navigationDestination( isPresented: <#Binding<Bool>#>, destination: <#() -> View#> )

18:36

We just have to fill in all of these placeholders.

18:38

First we will fill in the isPresented binding: isPresented: Binding( get: <#() -> Value#>, set: <#(Value) -> Void#> ),

18:47

The get of the binding can just check if the editItemID is non- nil because if it is we will want to perform a drill-down: get: { viewStore.editItemID != nil },

18:54

Notice that we don’t have to check for the ID because this view modifier is being installed outside the row. There is only one single instance of this binding driving navigation, rather than it being repeated over-and-over inside the list.

19:08

Next we have the set . Since there is no button that can be pressed to activate the drill-down, it doesn’t see that true can ever be written to the binding from SwiftUI. We think only false is possible, and in that case SwiftUI wants to dismiss the drill-down, most likely because the user either tapped the “Back” button in the top-left or swiped from the edge of the screen.

19:30

So, in that case we can just send our dismiss action: set: { isActive in if !isActive, viewStore.editItemID != nil { viewStore.send(.editItem(.dismiss)) } }

19:50

Next we have the destination closure, which needs to do exactly what we were doing in an ad hoc fashion when we were first trying to construct the NavigationLink in each row: IfLetStore( store.scope( state: \.editItem, action: { .editItem(.presented($0)) } ) ) { store in ItemFormView(store: store) .navigationTitle("Edit item") } But now we get to do it a single time, at the root of the list.

20:26

That is all it takes to start using navigationDestination(isPresented:) . However, before we can actually test this in a preview or simulator we need to switch the NavigationView s that work with the deprecated style of links, with NavigationStack s.

20:50

Now we can take it for a spin in the preview, and we see that drill downs work perfectly, and we can even already deep link into a particular item by just filling in the appropriate state. Editing also works.

21:10

Now you may be a little surprised that deep linking worked so well just now. After all, during our series on vanilla SwiftUI navigation when we covered the navigationDestination method, and even when we built the Standups app during our Modern SwiftUI series, we saw that deep linking with navigationDestination is seriously broken. Even more broken than navigation links. We employed a few hacks to work around some bugs, but there are still other bugs lurking in the shadows.

21:35

Well, we are actually running Xcode 14.3 beta 2 which was released just a few days before recording this episode, and it turns out that some bugs have been fixed! Deep linking with the navigationDestination view modifier now works a lot better, but there are still few bugs out there and so hopefully those will be fixed soon.

21:52

Another thing that has been massively improved in Xcode 14.3, and really it’s due to Swift 5.8, is that result builders are much more performant. Recall that a few episodes back we had to explicitly provide the type of the viewStore in our view because our view was too complex: WithViewStore( self.store, observe: ViewState.init ) { ( viewStore: ViewStore< ViewState, InventoryFeature.Action > ) in

22:20

This is no longer necessary: WithViewStore( self.store, observe: ViewState.init ) { viewStore in

22:25

Swift 5.8 has no problem at all compiling this view, even for previews. So this is really amazing.

22:35

However, for now let’s go ahead and bring back the explicit types for the view store just so that anyone using Xcode 14.2 or earlier will be able to run their previews: WithViewStore( self.store, observe: ViewState.init ) { ( viewStore: ViewStore< ViewState, InventoryFeature.Action > ) in

22:44

And let’s run the app in the simulator just to make sure everything works as we expect. We can drill down and pop out of items just fine, and we can make edits and see that they changes persist in the root items collection. We can even start the timer to see that after a few ticks the screen is automatically popped.

23:14

OK, so things are looking pretty good, but of course we would never want to write code like we have done to use navigationDestination . There’s a lot of steps to get right, and it’s just really messy. It would be far better if we had our own version of navigationDestination that allowed us to just provide a store holding onto the appropriate domain, and then it would take care of the rest.

24:05

We’d love if it could just look like this: .navigationDestination( store: self.store.scope( state: \.editItem, action: InventoryFeature.Action.editItem ) ) { store in ItemFormView(store: store) .navigationTitle("Edit item") } You hand it a store of some optional state and focused on a PresentationAction , and then it derives a store of the child domain so that we can hand it off to ItemFormView .

24:40

Let’s see what it takes to do this.

24:48

We can get a basic stub into place: extension View { func navigationDestination( ) -> some View { } }

25:00

This method will have an argument for the store, which as we just said needs to be scoped down to the optional child state and presentation action: func navigationDestination<ChildState, ChildAction>( store: Store< ChildState?, PresentationAction<ChildAction> >, … )

25:15

And we’ll need an argument for the destination view, which is a closure that accepts a store of just the child domain: func navigationDestination<ChildState, ChildAction>( store: Store< ChildState?, PresentationAction<ChildAction> >, @ViewBuilder destination: @escaping (Store<ChildState, ChildAction>) -> some View ) -> some View {

25:41

Then inside here we know we need to observe some state in order to derive a binding, like we were doing over in the inventory view when we did this all in an ad hoc fashion. However, we do not need to observe every little state change. We just need to know when the state flips from nil to non- nil , or vice-versa: WithViewStore( store, observe: { $0 }, removeDuplicates: { ($0 != nil) == ($1 != nil) } ) { viewStore in }

26:32

With the view store constructed we can now derive bindings to hand to the navigationDestination(isPresented:) API, just like we did over in the inventory feature. We’ll start by calling out to the vanilla SwiftUI navigationDestination : self.navigationDestination( isPresented: <#Binding<Bool>#>, destination: <#() -> View#> )

26:41

We’ll want to fill in the binding from scratch: isPresented: Binding( get: <#() -> _#>, set: <#(_) -> Void#> ),

26:47

In the get branch we will check that the view store’s state is not nil : get: { viewStore.state != nil },

26:52

And in the set branch we will send the dismiss action if false is being written to the binding and only if the state is actually non- nil so that we have something to dismiss: set: { isActive in if !isActive, viewStore.state != nil { viewStore.send(.dismiss) } }

26:53

And finally in the destination closure we can do the IfLetStore dance: ) { IfLetStore( store.scope( state: returningLastNonNilValue { $0 }, action: { .presented($0) } ) ) { store in destination(store) } }

27:51

And with that everything is building, and it all works exactly as it did before. We are instantly deep-linked into a row, that we can drill out of and update the item.

28:17

Let’s take a quick moment to look at what is happening at the bottom of our inventory view: .alert( store: self.store.scope( state: \.alert, action: InventoryFeature.Action.alert ) ) .sheet( store: self.store.scope( state: \.addItem, action: InventoryFeature.Action.addItem ) ) { store in … } .popover( store: self.store.scope( state: \.duplicateItem, action: InventoryFeature.Action.duplicateItem ) ) { store in … } .navigationDestination( store: self.store.scope( state: \.editItem, action: InventoryFeature.Action.editItem ) ) { store in … }

28:45

This is 4 completely different forms of navigation: an alert, a sheet, a popover and a drill down, yet the way in which we integrate the child views into the parent views all look basically the same. We just scope our feature’s store to the child domain we want to present, and then pass it along to the appropriate view modifier.

29:02

And amazingly this echos what happens over in the reducer too, where at the end of the core reducer that powers the inventory feature we also tack on a bunch of ifLet reducer operators to integrate all of the feature domains together just as we have done in the view: .ifLet(\.alert, action: /Action.alert) .ifLet(\.duplicateItem, action: /Action.duplicateItem) { ItemFormFeature() } .ifLet(\.addItem, action: /Action.addItem) { ItemFormFeature() } .ifLet(\.editItem, action: /Action.editItem) { ItemFormFeature() } Next time: enum routing

29:03

We now have a pretty incredible and comprehensive suite of navigation tools for the Composable Architecture. We can handle alerts, confirmation dialogs, sheets, popovers, covers, navigation links, and even a new iOS 16 style of navigation destinations. And it doesn’t matter which form of navigation you are trying to implement, the steps to do it are all basically the same. You do a bit of domain modeling, you integrate the parent and child features together using the ifLet reducer operator, and then you integrate the view by calling the corresponding SwiftUI view modifier.

29:47

There is of course a big, gaping hole in our navigation tools, and that is the new iOS 16 NavigationStack API that takes a binding. This style of navigation is extremely powerful, though sometimes difficult to handle correctly, and we will have a lot to say about that soon, but there is something more important to address first. Brandon

30:05

While what we have accomplished so far is pretty impressive, after all we have unified 6 different forms of navigation into essentially a single API, we are still modeling our domain in a less than ideal way. Our inventory feature is using 4 pieces of optional state to represent all the different places you can navigate to, and that allows for a lot of non-sensical states which leaks uncertainty into every corner of our codebase.

30:33

For example, it’s technically possible for the addItem and duplicateItem and editItem all be non- nil at the same time. What does that even mean? A sheet, popover, and drill-down would all activated at the same time? And beyond that weirdness we can also never be sure that we know exactly what screen is being displayed at any time. We have to check all 4 optionals to see if something is presented and if 2 or more things are non- nil we just have to guess at what is actually being displayed.

31:02

By using 4 optionals we technically have “2 to the 4th” possible states of things being nil or non- nil , which is 16 possibilities. Only 5 of those are valid: either they are all nil or exactly one is non- nil . That leads us to believe that a single enum is a much better tool for modeling this domain than a bunch of optionals, and that’s a lesson we learned when discussing vanilla SwiftUI navigation , as well as modern SwiftUI .

31:27

We put in a lot of effort to build up navigation tools in vanilla SwiftUI that allowed us to model all destinations as a single enum, and then derive bindings to each case of the enum in order to drive navigation. We need to do the same for the navigation tools we have just built for the Composable Architecture, so let’s see how we can accomplish that…next time! References Composable navigation beta GitHub discussion Brandon Williams & Stephen Celis • Feb 27, 2023 In conjunction with the release of episode #224 we also released a beta preview of the navigation tools coming to the Composable Architecture. https://github.com/pointfreeco/swift-composable-architecture/discussions/1944 Downloads Sample code 0228-composable-navigation-pt7 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .