Video #227: Composable Navigation: Links
Episode: Video #227 Date: Mar 20, 2023 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep227-composable-navigation-links

Description
We have a single navigation API powering alerts, dialogs, sheets, popovers, and full screen covers, but what about the prototypical form of navigation, the one that everyone thinks of when they hear “navigation”? It’s time to tackle links.
Video
Cloudflare Stream video ID: 2b4f62e92e741062b5cdcbdb13e1844a Local file: video_227_composable-navigation-links.mp4 *(download with --video 227)*
References
- Discussions
- Composable navigation beta GitHub discussion
- 0227-composable-navigation-pt6
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
OK, things are starting to look really amazing. We now have the tools to model 5 different types of navigation in our domains, alerts, dialogs, sheets, popovers, and covers, and the tools to hook up those domains to SwiftUI. And these tools help us out with lots of sharp edges and subtleties, such as cancelling inflight effects when a feature is dismissed. Brandon
— 0:24
But there is a huge, gaping hole in our tools for navigation, and it has to do with the kind of navigation that probably all of our viewers think of when we say the word “navigation.” And that’s “drill-down” navigation.
— 0:36
This is the style of navigation that occurs when you tap a button in the UI, and the screen transitions to the next screen. And you get some niceties built into iOS, such as a back button in the top-left, or even the ability to perform a swipe gesture to go back.
— 0:47
The APIs to accomplish this style of navigation have recently gone through some big changes in iOS 16. In particular, many of the navigation APIs that were available in SwiftUI on day 1 were deprecated, and all new tools were added in iOS 16.
— 0:57
We are going to first get our feet wet with drill-down navigation by building the tools on top of the older, deprecated APIs. That may seem weird, but also the vast majority of people right now are still targeting versions of iOS less than 16, and so these tools are still very much necessary. And luckily understanding the older APIs will help us with the new ones too, so it’s a win-win.
— 1:30
So, let’s see what it takes to add a drill-down navigation to our application. Links
— 1:35
The feature we will add to explore this type of navigation is editing an existing item. We already have a sheet for adding an item and a popover for duplicating an item. Now we want to support editing.
— 1:50
We will make it so that when you tap on a row in the items list, you drill-down to the ItemFormFeature , and can make any edits you want on that screen.
— 1:57
Let’s start with the domain modeling.
— 1:59
We’ll add another piece of optional state to our feature to represent being drilled down to the form: struct State: Equatable { var editItem: ItemFormFeature.State? … }
— 2:09
And we’ll add another case to the Action enum to represent the drill-down form: enum Action: Equatable { case editItem(PresentationAction<ItemFormFeature.Action>) … }
— 2:23
For right now we will handle that action in the reducer but won’t do anything interesting because we’re not yet sure if we need to layer on additional functionality whenever the edit feature executes its logic: case .editItem: return .none
— 2:33
Then we’ll tack on another .ifLet method at the end of the main inventory reducer: var body: some ReducerOf<Self> { Reduce<State, Action> { state, action in … } … .ifLet(\.editItem, action: /Action.editItem) { ItemFormFeature() } }
— 2:54
It’s worth noting that the bottom of this reducer is starting to look really cool: .ifLet(\.alert, action: /Action.alert) .ifLet(\.addItem, action: /Action.addItem) { ItemFormFeature() } .ifLet(\.duplicateItem, action: /Action.duplicateItem) { ItemFormFeature() } .ifLet(\.editItem, action: /Action.editItem) { ItemFormFeature() }
— 3:00
We are handling 4 completely different types of navigation on this screen, an alert, sheet, popover and drill-down, and we can integrate all of their logic and behavior together with this one single method. It’s incredible.
— 3:18
There’s still a little more we need to do in the reducer to truly hook everything up, but to understand what that is, let’s move onto the view.
— 3:24
In the pre-iOS 16 world there is an initializer on NavigationLink that takes a binding of a boolean: NavigationLink( isActive: <#Binding<Bool>#>, destination: <#() -> View#>, label: <#() -> View#> )
— 3:39
When the binding flips to true a drill-down animation occurs and the destination view is presented on screen.
— 3:49
Let’s implement each of these placeholders from first principles, and then we will see how we can extract it out to a helper that can be used many times. We’ll start with the isActive binding, which we can construct from scratch: isActive: Binding( get: <#() -> Value#>, set: <#(Value) -> Void#> ),
— 4:07
To check if the binding is active we need to add some more state to the ViewState that this view observes.
— 4:31
In fact, before doing that we can clean up some things, because the ViewState no longer needs to keep track of addItemID . All of that has been hidden away in the sheet view modifier. So, let’s delete it: struct ViewState: Equatable { let items: IdentifiedArrayOf<Item> init(state: InventoryFeature.State) { self.items = state.items } }
— 4:41
Now we’ll add some state back to this struct so that we can observe when the editItem field changes from nil to non- nil . We could just have a boolean that checks if the state is nil or not: struct ViewState: Equatable { var editItemIsActive: Bool let items: IdentifiedArrayOf<Item> init(state: InventoryFeature.State) { self.editItemIsActive = state.editItem != nil self.items = state.items } }
— 4:54
But this isn’t going to be strong enough. Because we are going to have a NavigationLink in every single row of the list, it would mean that when one link becomes active they all become active. We need someway to know when just one particular row is activated, and this means we should keep track of the ID of the edit being edited: struct ViewState: Equatable { var editItemID: Item.ID? let items: IdentifiedArrayOf<Item> init(state: InventoryFeature.State) { self.editItemID = state.editItem?.id self.items = state.items } } Soon we will even be able to stop observing the editItemID state here because all of that will be hidden away in a NavigationLink helper, just as was done with the sheet helper.
— 5:34
With that done we can now implement the get of the binding: isActive: Binding( get: { viewStore.editItemID == item.id }, set: <#(Value) -> Void#> ),
— 5:46
The set is a little trickier: set: { isActive in if isActive { } else { } }
— 5:58
If true is written to this binding it means that the user tapped on the link in order to activate it. And if false is written it means that the user either tapped the back button or swiped on the edge of the screen to pop the feature off the stack.
— 6:15
The latter event already has a representation as an action in the feature: it’s the dismiss presentation action: set: { isActive in if isActive { } else { viewStore.send(.editItem(.dismiss)) } }
— 6:33
And for the other branch of the if we just need to add a new action to the feature: set: { isActive in if isActive { viewStore.send(.itemButtonTapped(id: item.id)) } else { viewStore.send(.editItem(.dismiss)) } }
— 6:34
We’ll add that to the Action enum: enum Action: Equatable { … case itemButtonTapped(id: Item.ID) }
— 6:58
And I’m sure you are starting to feel uneasy at how large this enum is getting, but don’t worry. We will have ways of slimming this down quite a bit later.
— 7:08
Next we handle the new action in the reducer: case let .itemButtonTapped(id: id): guard let item = state.items[id: id] else { XCTFail("Can't edit when not found") return .none } state.editItem = ItemFormFeature.State(item: item) return .none OK, we’re getting closer and closer.
— 8:04
Next up we have the destination argument to fill in. We want the destination to be the ItemFormView , like this: destination: { ItemFormView(store: <#StoreOf<ItemFormFeature>#>) }
— 8:29
But to do that we need a store of the ItemFormFeature domain. We can certain scope down to the editItem state: ItemFormView( store: store.scope( state: \.editItem, action: { .editItem(.presented($0)) } ) )
— 8:59
However, editItem is optional, and so this doesn’t compile.
— 9:11
We can turn to our handy IfLetStore view that is specifically designed for transforming stores of optional state into stores of honest state: destination: { IfLetStore( store.scope( state: \.editItem, action: { .editItem(.presented($0)) } ) ) { store in ItemFormView(store: store) .navigationTitle("Edit item") } },
— 9:43
And finally we must implement the label argument, but that will just consist of all of the views that currently make up the row: } label: { HStack { … } }
— 10:00
Phew. It took some work, but that does it! There is one small change we need to make before we can test this and that is make sure we are using NavigationView s instead of NavigationStack s. When first starting this app we used NavigationStack everywhere because that’s the new hotness, but this old deprecated NavigationLink API doesn’t play nicely with that. So, let’s quickly convert all NavigationStack s to the deprecated NavigationView .
— 10:47
Now the application should mostly work how we expect. We can even drill down to an item, start the timer, wait a few ticks and the screen will be popped off the navigation stack. That’s pretty incredible. The feature we previously built to allow child features to dismiss themselves works for navigation links just as well as it did for sheets.
— 11:00
However, one thing that does not work is editing. If we make changes on the edit drill down screen and then back out, we will see that the changes were not applied.
— 11:10
When we first explored this edit flow way back in our original SwiftUI navigation series , over a year ago, we had to contort ourselves in order to facilitate parent-child communication so that changes made in the child feature were reflected in the parent. We had to do similar things when building the Standups app in vanilla SwiftUI for our “Modern SwiftUI” series . That glue code is pretty complex, difficult to maintain, and easy to get wrong.
— 11:35
But things are much simpler for our application thanks to the Composable Architecture. Because all of our features are completely integrated together from the get-go, we have the ability to snoop on any actions that happens inside the editItem feature. In particular, we replay any changes made to the item back to the parent feature: case .editItem: guard let item = state.editItem?.item else { return .none } state.items[id: item.id] = item return .none It’s as easy as that.
— 12:33
Now when we drill down to an edit, make some changes, and then back out, we will see that the changes were applied to the item in the list.
— 12:39
We could also make a very small tweak and only capture the change of the item at the moment of dismissing the screen rather than doing it for every single edit. All it takes is overriding the dismiss action rather than every editItem action: case .editItem(.dismiss):
— 13:03
And the cool thing here is that when this dismiss action is sent we took special care in the ifLet operator to make sure it is invoked before the child state is nil ’d out: case let (.some(childState), .some(.dismiss)): let effects = self.reduce(into: &state, action: action) state[keyPath: stateKeyPath] = nil This gives the parent reducer one last chance to inspect the child state before it is cleared out, which is exactly what we need here so that we can save the changes back to the root items collection: case .editItem(.dismiss): guard let item = state.editItem?.item else { return .none } state.items[id: item.id] = item return .none
— 13:46
It’s pretty incredible how much power we have in the ifLet reducer operator to determine when and how we want to invoke child and parent behavior. With that done we can ignore all the remaining editItem actions: case .editItem: return .none
— 13:56
Now we can drill down, make some edits, pop back and we will see our edits applied. This is pretty amazing. We are really starting to see the benefits of integrating our features together.
— 14:10
One small difference is that we don’t see the edited version of the data until the pop animation has completed. This is just due to the fact that SwiftUI does not write to its bindings until a screen is fully dismissed. Personally I think it’s nice that it draws a bit of attention to the row that was edited, but if you don’t like that you can always create a custom toolbar for the ItemFormFeature so that you can have exact control over the situation. But this is something you would have to do in vanilla SwiftUI too, and so really isn’t a Composable Architecture-specific concern. Nicer links in TCA Stephen
— 15:01
So, things are mostly looking pretty great, but of course the call site of the NavigationLink looks really gnarly. We would never want to have to write code like this for every time we need to perform a drill-down navigation to a screen. Let’s see what it takes to make it nicer.
— 15:16
Let’s first see what we would want the call site to look like. Ideally we can construct a NavigationLink like usual: NavigationLink( … ) But this initializer will take different arguments than the regular one does.
— 15:31
Just like all the view modifiers we have made in the past, this initializer will take a store that is focused on a child feature’s domain, and in particular an optional piece of state and a presentation action: NavigationLink( store: self.store.scope( state: \.editItem, action: InventoryFeature.Action.editItem ) )
— 15:56
Next perhaps we can provide a closure that constructs the destination to drill-down to when the child domain becomes non- nil . It will accept a store as an argument, and we could return the ItemFormView inside: NavigationLink( store: self.store.scope( state: \.editItem, action: InventoryFeature.Action.editItem ) ) { store in ItemFormView(store: store) .navigationTitle("Edit item") }
— 16:25
And then perhaps there’s another trailing closure for the label of the link, and we can put all of the row’s view hierarchy in there: NavigationLink( store: self.store.scope( state: \.editItem, action: InventoryFeature.Action.editItem ) ) { store in ItemFormView(store: store) .navigationTitle("Edit item") } label: { HStack { … } }
— 16:55
So this would be pretty cool, but before even trying to make this compile there is a very clear problem with it.
— 17:01
First, unlike all the other forms of navigation we have covered, navigation links are an actual button that the user can tap on to activate the navigation. This means we need to also expose an action closure to all us to execute some logic when the user taps, which we will make the first trailing closure: NavigationLink( store: self.store.scope( state: \.editItem, action: InventoryFeature.Action.editItem ) ) { viewStore.send(.itemButtonTapped(id: item.id)) } destination: { store in ItemFormView(store: store) .navigationTitle("Edit item") } label: { HStack { … } }
— 17:35
This is the API we will try to create now. There is actually one more problem with this interface, but it’s easier to explain once we come across it more concretely.
— 17:44
So, let’s start with a extension on NavigationLink that adds a new convenience initializer: extension NavigationLink { init( ) { } }
— 18:00
The first argument provided will be a store that is focused on some optional child state and some presentation actions: init( store: Store< ChildState?, PresentationAction<ChildAction> > )
— 18:13
…which means we need some generics for this initializer: init<ChildState, ChildAction>( store: Store< ChildState?, PresentationAction<ChildAction> > )
— 18:17
Next we will take the action closure, which is just a void-to-void function: init<ChildState, ChildAction>( store: Store< ChildState?, PresentationAction<ChildAction> >, action: @escaping () -> Void )
— 18:32
Then we will take a destination closure just like the regular NavigationLink initializer, except this time it will take a Store of the child domain as an argument: init<ChildState, ChildAction>( store: Store< ChildState?, PresentationAction<ChildAction> >, action: @escaping () -> Void, @ViewBuilder destination: @escaping (Store<ChildState, ChildAction>) -> Destination )
— 18:56
And finally this will take an argument for the label: init<ChildState, ChildAction>( store: Store< ChildState?, PresentationAction<ChildAction> >, action: @escaping () -> Void, @ViewBuilder destination: @escaping (Store<ChildState, ChildAction>) -> Destination, @ViewBuilder label: () -> Label )
— 19:04
Now we just need to implement the body of this initializer.
— 19:08
We need to call out to some other initializer on NavigationLink , and so we will choose the one that exposes the isActive boolean: self.init( isActive: <#Binding<Bool>#>, destination: <#() -> View#>, label: <#T##() -> View#> )
— 19:15
And already we are hitting a roadblock.
— 19:22
In order to call this initializer we need to be able to construct a binding, but in the Composable Architecture the only way to do that is to observe via a view store and derive a binding from that.
— 19:32
Currently we have no view store available, and we can’t simply use WithViewStore : WithViewStore(store) { viewStore in self.init(<#???#>) }
— 19:49
This is showing that we can’t actually use NavigationLink directly. We need to create a new type of view so we can observe state however we want, and then we will construct a NavigationLink under the hood in that view.
— 20:02
And this is a totally fine thing to do, and in fact we’ve had to do it before in the Composable Architecture. We have a view called ForEachStore that is like ForEach , except it works on stores of collections of data. We couldn’t leverage ForEach directly for the same reasons we can’t leverage NavigationLink directly.
— 20:21
So, using the same naming convention, perhaps we need a NavigationLinkStore : struct NavigationLinkStore: View { var body: some View { EmptyView() } }
— 20:30
Each argument we wanted to pass to the NavigationLink initializer will become instance variables on this view: struct NavigationLinkStore: View { let store: Store< ChildState?, PresentationAction<ChildAction> > let action: () -> Void @ViewBuilder let destination: (Store<ChildState, ChildAction>) -> Destination @ViewBuilder let label: () -> Label … }
— 20:53
And we have to add a bunch of generics: struct NavigationLinkStore< ChildState, ChildAction, Label: View, Destination: View >: View { … }
— 21:05
Now we just need to implement the body of this view.
— 21:08
We can start by observing some state because we know we need to eventually derive a binding to pass to NavigationLink : WithViewStore(store, observe: { $0 }) { viewStore in }
— 21:26
Currently we are observing everything, but is that the best idea? That will mean that no matter what change happens in the child domain we are going to recompute everything, even though for the purposes of navigation we only care about when the state flips from nil to non- nil , or vice-versa.
— 21:40
So, let’s just observe whether or not the state is nil : WithViewStore( store, observe: { $0 != nil } ) { viewStore in }
— 21:48
Now that we are observing state we can finally try constructing a NavigationLink : WithViewStore( store, observe: { $0 != nil } ) { viewStore in NavigationLink( isActive: <#Binding<Bool>#>, destination: <#() -> _#>, label: <#() -> _#> ) }
— 21:56
First we have to provide the isActive binding. We might hope we can use the binding helper that is defined on view stores: isActive: viewStore.binding(send: { isActive in }),
— 22:04
However, to do so we need to return an action that should be sent back into the store whenever the isActive state changes. That’s not always possible though.
— 22:15
For example, when the use taps on the link, true is written to this binding, but what action can be possible send here? We don’t know anything about the child domain. This is the reason why we added the action closure argument. It gives us an escape hatch to ask the user of this API to go do whatever work they need to do in order to activate the navigation link.
— 22:34
So, we actually need to construct a Binding from scratch here so that we can inject this extra logic: isActive: Binding( get: <#() -> _#>, set: <#(Bool) -> Void#> ),
— 22:42
The get can just return the state held in the view store, which is just whether or not the child state is nil : get: { viewStore.state },
— 22:49
And the set can check if the link is being activated, in which case we can call out to the action closure, and otherwise we can send the dismiss action to tear down the child feature: set: { isActive in if isActive { action() } else { viewStore.send(.dismiss) } }
— 23:09
We can even preemptively fix a problem here, which is something we did in the alert and sheet view modifiers too. If false is being written to the binding but the state is already nil , then there is no need to send the dismiss action: set: { isActive in if isActive { action() } else if viewStore.state { viewStore.send(.dismiss) } }
— 23:28
Next we need to supply the destination closure, and just as we did for sheets, popovers and covers, we will use an IfLetStore under the hood: destination: { IfLetStore( self.store.scope( state: { $0 }, action: { .presented($0) } ) ) { store in self.destination(store) } },
— 24:12
And finally the label can be passed along: label: { self.label }
— 24:20
Things compile now, but we are getting a deprecation warning, so let’s also deprecate NavigationLinkStore since it’s only meant to be used on old versions of iOS: @available(*, deprecated) struct NavigationLinkStore< ChildState, ChildAction, Label: View, Destination: View >: View { … }
— 24:36
That’s all it takes, and that greatly simplifies constructing the NavigationLink in the inventory: NavigationLinkStore( store: self.store.scope( state: \.editItem, action: InventoryFeature.Action.editItem ) ) { viewStore.send(.itemButtonTapped(id: item.id)) } destination: { store in ItemFormView(store: store) .navigationTitle("Edit item") } label: { … }
— 24:48
This is much nicer, however there is a problem.
— 24:52
If we run the preview and tap on a row we will see it drills in just fine. But we are seeing glitchy behavior where other rows are highlighting on dismissal.
— 25:08
This is happening because technically all 4 navigation links became activated at the same time. After all, the logic that determines whether or not a navigation link is activated simply checks if the state in the store is non- nil : WithViewStore( store, observe: { $0 != nil } ) { viewStore in
— 25:25
This single condition is going to be true or false for all links at the same time.
— 25:29
Whereas previously, when we constructed the NavigationLink in an ad-hoc fashion directly in the view, we sprinkled in a bit more logic to further check that the IDs matched: get: { viewStore.editItemID == item.id },
— 25:38
We need to capture this logic in our NavigationLink helper.
— 25:41
But how’s that possible?
— 25:44
Well, we can take some inspiration from another deprecated initializer on NavigationLink . It’s the one whose main purpose was to be used in lists such that each link is driven by a single binding derived from the root, just like we have in our situation: init<V: Hashable>( tag: V, selection: Binding<V?>, destination: () -> Destination, label: () -> Label )
— 26:09
You provide it both a “tag” and a binding to some optional data. When it detects the binding flips to something non- nil and that unwrapped data matches the tag, then a drill down occurs.
— 26:23
That sounds pretty similar to our situation, and so we will copy this API a bit. We are going to force the ChildState to be Identifiable , just as it is required for the sheet , popover and fullScreenCover SwiftUI view modifiers: struct NavigationLinkStore< ChildState: Identifiable, … >: View { … )
— 26:39
…and we are going to require an ID to be passed in that can be used to check against the child state: struct NavigationLinkStore<…>: View { let id: ChildState.ID? … }
— 26:50
And now we can beef up the state we are observing by confirming that the child state’s ID matches the ID passed to the NavigationLinkStore : WithViewStore( store, observe: { $0?.id == self.id } ) { viewStore in
— 27:00
And when constructing the NavigationLinkStore we can pass along the item’s ID so that the link knows when it should actually activate: NavigationLinkStore( … id: item.id, ) {
— 27:09
And everything works exactly as it did before. We can now drill down into any item to edit it, but the call site for constructing a navigation link looks much nicer.
— 27:26
We also now have instant access to deep linking directly to the edit screen for a particular item. For example, down in the preview we can start in a very particular state where we are already drilled down to the edit screen for the headphones: initialState: AppFeature.State( inventory: InventoryFeature.State( editItem: ItemFormFeature.State(item: .headphones), items: [ .monitor, .mouse, .keyboard, .headphones ] ), selectedTab: .inventory )
— 27:49
That’s all it takes. When we run in the simulator it immediately boots up with the drill down active to the headphones item, and any changes we make will be reflected back in the root list.
— 28:02
It also works in the preview. We can start up the inventory preview in a very specific state where we are pre-drilled down to an item. This can be incredibly handy.
— 28:36
For example, suppose we wanted to play around with the integration logic we implemented a moment ago where we allowed the edits made in the item form feature to affect the item in the root list. We had two different implementations of that logic we were deciding between: we could either play back every single edit to the root list, or we could wait until the edit screen is dismissed before committing the edit. There was even a 3rd option of providing our own toolbar so that we could be more explicit with things.
— 28:59
If we wanted to iterate on this logic, and possibly play around with each of the 2 options to see which we liked best, our iterative cycle would be severely hampered because with each change we make we would have to tap on an item, make an edit, and then hit back. Why do that repetitive work over and over and over when we can just start the preview in a very particular state where the edit screen is presented and some edits are already made: editItem: ItemFormFeature.State( item: Item( id: Item.headphones.id, name: "Bluetooth Headphones", color: .red, status: .inStock(quantity: 100) ) ),
— 29:47
Now all we have to do is run the preview and hit “Back” to see how our logic is implemented. That’s going to be a huge time saver, and little things like this can really improve the experience of working in your app. It makes it a joy to implement new features and not something to dread. Next time: Testing, and destinations
— 30:04
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.
— 30: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.
— 30:24
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
— 30:43
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.
— 31:08
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…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 0227-composable-navigation-pt6 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 .