EP 264 · Observable Architecture · Jan 15, 2024 ·Members

Video #264: Observable Architecture: Observing Navigation

smart_display

Loading stream…

Video #264: Observable Architecture: Observing Navigation

Episode: Video #264 Date: Jan 15, 2024 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep264-observable-architecture-observing-navigation

Episode thumbnail

Description

Observation has allowed us to get rid of a number of view wrappers the Composable Architecture used to require in favor of vanilla SwiftUI views, instead, but we still depend on a zoo of view modifiers to drive navigation. Let’s rethink all of these helpers and see if we can trade them out for simpler, vanilla SwiftUI view modifiers, instead.

Video

Cloudflare Stream video ID: 41b5d748758488434ea53efd119941af Local file: video_264_observable-architecture-observing-navigation.mp4 *(download with --video 264)*

Transcript

0:05

By getting rid of our specialized view helper, the ForEachStore , we have unlocked all new super powers for when modeling lists of features in the Composable Architecture. We get to use all of the fancy collection APIs that come with the standard library in order to slice and dice a collection of data for display in the UI, and everything is still rendered in the most minimal way possible.

0:26

So, we have now gotten rid of the WithViewStore , IfLetStore , SwitchStore , CaseLet and ForEachStore . Those were the only dedicated views that the library shipped in order to deal with optional state, enum state, and collection state. Stephen

0:40

But there’s another class of helpers that ship with the library that are also no longer needed in the world of Swift observation, and that’s navigation view modifiers. We currently have to maintain a whole zoo of view modifiers that mimic ones that come with vanilla SwiftUI but tuned specifically for the Composable Architecture. This includes modifiers for sheets, popovers, fullscreen covers, drill-downs, navigation stacks, and more.

1:07

Well, amazingly, we can stop using all of those helpers and instead just use the vanilla SwiftUI view modifiers. And it’s all thanks to the observation tools in Swift.

1:16

Let’s take a look. Navigation: a recap and rethink

1:20

To explore this we are going to take a look at an integration test case we looked at a bit earlier, and already improved a bit, called PresentationTestCase.swift. It exercises a lot of the subtle edge cases when presenting features using optionals and enums.

1:34

We can run the preview to see what it is capable of. There are buttons for presenting a child feature in a variety of ways, such as popover, fullscreen cover and sheet. Within each of those forms of navigation you can interact with the child feature as a unit isolated from the parent. But, in the sheet we can have the parent observe the child state while the sheet is open.

1:59

This seemingly simple demo does pose some interesting challenges for making sure that views observe the minimal amount of state possible. We want the parent domain to be able to dynamically peek into the state of a child, and for it to observe that state only when accessing it in the view. Let’s take a look at the code to see how this is currently accomplished.

2:18

The Feature reducer has a piece of optional state for driving the presentation of just a sheet, and then a piece of optional enum state for driving the presentation of a mutually exclusive popover and fullscreen cover: @Reducer struct Feature { struct State: Equatable { var isObservingChildCount = false @PresentationState var destination: Destination.State? @PresentationState var sheet: BasicsView.Feature.State? } … @Reducer struct Destination { enum State: Equatable { case fullScreenCover(BasicsView.Feature.State) case popover(BasicsView.Feature.State) } … } }

2:33

And in the view we do the following to present the cover, popover and sheet: .fullScreenCover( store: self.store.scope( state: \.$destination.fullScreenCover, action: \.destination.fullScreenCover ) ) { store in … } .popover( store: self.store.scope( state: \.$destination.popover, action: \.destination.popover ) ) { store in … } .sheet( store: store.scope(state: \.$sheet, action: \.sheet) ) { store in … }

3:01

And to be honest, at the call site this isn’t actually bad at all. In the past few weeks we have had some really big releases of the library that has allowed us to massively simplify these view modifiers.

3:12

Just a few weeks ago we released a version of the library that added caching when creating scoped child stores. So when you do something like this: store.scope( state: \.$destination.popover, action: \.destination.popover )

3:26

…we actually keep a reference to that scoped store inside store so that if you ask for the scope again later, we can just hand you the reference that was previously created.

3:36

That seemingly small change allowed us to use this short, succinct syntax: .popover( store: store.scope( state: \.$destination.popover, action: \.destination.popover ) ) { store in … }

3:39

…as opposed to what we had to do previously: .popover( store: store.scope( state: \.$destination, action: \.destination ), state: \.popover, action: { .popover($0) } ) { store in … } That is, we previously needed to first provide the popover view modifier with a store scoped down to just the destination domain, and then further specify a state transformation for isolating the popover case of the Destination.State enum, and further a action argument for embedding a popover action into Destination.Action . So, that’s certainly a lot more verbose, but even it was an improvement compared to what we had to do previously. Because a few weeks before the store caching release we had another big release of the library that brought macro case paths to the library. And this allowed us to use key path syntax for isolating a single case from an enum. And prior to that release we actually had to write out this view modifier in the much more verbose style: .popover( store: store.scope(state: \.$destination, action: { .destination($0) }), state: /Feature.Destination.State.popover, action: Feature.Destination.Action.popover ) { store in … }

4:54

So, it’s kind of incredible to see how in just a few weeks we went from this very verbose style to this very succinct style: .popover( store: store.scope( state: \.$destination.popover, action: \.destination.popover ) ) { store in … }

5:00

But there is one thing to not like about this code even though it is quite succinct. And that is that the library must provide this popover(store:) view modifier, which is just extra surface area for us to maintain, and more opportunities for us to get something wrong.

5:18

It would be far better if we could somehow leverage the vanilla SwiftUI view modifiers, such as popover(item:) : .popover( item: <#Binding<Identifiable?>#>, content: <#(Identifiable) -> View#> )

5:28

Now there is a very good reason why we can’t do this in a pre-Observation world. The dedicated popover(store:) method is doing a lot of work under the hood to allow observing when the destination changes, but also doing so in the minimal way possible.

5:43

If we jump to the implementation of popover(store:) we will see it is calling something called presentation(store:) : presentation( store: store ) { self, $item, destination in … }

5:53

This is just our internal helper that powers the sheet , popover and fullScreenCover modifiers since all of their implementations are so similar.

6:06

If we jump to its implementation we will eventually see that there is something called PresentationStore powering this method: PresentationStore( store, state: toDestinationState, id: toID, action: fromDestinationAction ) { $item, destination in … }

6:14

And then finally down in PresentationStore we will see that there is indeed a ViewStore powering everything, and its doing its best to minimize the number of times it needs to re-render things: let viewStore = ViewStore( store, observe: { $0 }, removeDuplicates: { toID($0) == toID($1) } )

6:36

In a pre-Observation world all of these things were necessary. But now that views can properly observe just what is accessed in the view, and now that we have a notion of structural identity for minimizing the number of times we re-render, hopefully these hundreds of lines can be deprecated and someday deleted.

7:01

Going back to the signature of popover(item:) we see a few things we have to contend with: .popover( item: <#Binding<Identifiable?>#>, content: <#(Identifiable) -> View#> )

7:10

First of all, this method takes a binding , not a store. And it takes a binding of something that is identifiable.

7:16

Let’s theorize how we might be able to shoehorn our Store into this situation. We need a binding, and there are two ways to get a binding to a property held in a view. We can use @State , which we are already doing: @State var store = Store(initialState: Feature.State()) { Feature() }

7:30

That alone gives us a binding via the $ syntax: let _: Binding<StoreOf<Feature>> = $store

7:43

It’s a little bizarre to have a binding of a store , but it feels like that is what we need to use popover(item:) .

7:49

And so what if we could define a scoping operation on bindings of stores: $store.scope

7:54

That would allow us to form a whole new binding of a store which has been focused down on some child domain. And so maybe we could pass along the state and action transformations to this scope: $store.scope( state: \.$destination.popover, action: \.destination.popover )

8:06

And even better, this \.$destination key to the projected value of PresentationState won’t even be necessary. The only reason for that was for the tools under the hood to be able to minimize the number of view re-renders by using the identity of PresentationState , but now we are going to get all of that for free thanks to Swift’s Observation tools.

8:24

So hopefully it can just be this: $store.scope( state: \.destination?.popover, action: \.destination.popover )

8:32

And in fact, we hope that sometime in the near future we could entirely get rid of the PresentationState property wrapper and just use plain optionals. That would be even simpler, but unfortunately we can’t do that quite yet.

8:44

Then this would be the binding we could hand to the vanilla SwiftUI view modifier: .fullScreenCover( item: $store.scope( state: \.destination?.popover, action: \.destination.popover ) ) { store in … }

9:12

Then we could also update the fullScreenCover view modifier similarly: .fullScreenCover( item: $store.scope( state: \.destination?.fullScreenCover, action: \.destination.fullScreenCover ) ) { store in … }

9:29

And the sheet modifier: .sheet( item: $store.scope(state: \.sheet, action: \.sheet) ) { store in … } This would be great because now this code looks similar to vanilla SwiftUI since we are using more familiar navigation modifiers. Scoping store bindings

9:38

And this would allow us to get rid of multiple specialized view modifiers and hundreds of lines of library code in exchange for just one single binding scoping operation. That would be incredible. Brandon

9:48

Let’s make this theoretical syntax a reality. We need to define some kind of scoping operation that specifically works on bindings of stores . That sounds kind of wild, but let’s give it a shot.

10:02

We will hop back over to our observation file where all of the observation tools have been implemented. And we can start with some scaffolding of this new method: import SwiftUI extension Binding { public func scope() { } }

10:23

Now this scoping operation only makes sense if the Binding wraps a store, and so we need a constraint on the binding’s Value generic: extension Binding { public func scope() where Value == Store<???, ???> { } }

10:48

And to make this constraint we need to introduce some generics to the method: extension Binding { public func scope<State, Action>() where Value == Store<State, Action> { } }

10:57

And further we should constrain the new State generic to be observable because this new operation can only work if the feature is integrated with the new observation tools: public func scope<State: ObservableState, Action>()

11:17

Next we need the arguments for transforming this store’s state and actions down to a child domain: extension Binding { public func scope<State: ObservableState, Action>( state: <#???#>, action: <#???#> ) where Value == Store<State, Action> { } }

11:26

The first argument is straightforward. We just need a key path that can isolate a piece of optional child state from the parent state, which means a new generic: extension Binding { public func scope< State: ObservableState, Action, ChildState >( state: KeyPath<State, ChildState?>, action: <#???#> ) where Value == Store<State, Action> { } }

11:43

And the action argument needs to isolate a child PresentationAction from the parent domain, and so it needs a case key path: extension Binding { public func scope< State: ObservableState, Action, ChildState, ChildAction >( state: KeyPath<State, ChildState?>, action: CaseKeyPath< Action, PresentationAction<ChildAction> > ) where Value == Store<State, Action> { } }

12:01

The PresentationAction is indeed important here. Because the binding we hand over to SwiftUI holds onto an optional, SwiftUI has full freedom to write nil to that binding anytime it wants. And it does indeed do that in certain cases, such as if you swipe down on a presented sheet, or swipe from left-to-right on a drill-down screen.

12:21

And so when SwiftUI writes nil to our binding, we have to re-interpret that as sending an action to dismiss our feature, and that is exactly the power that PresentationAction gives us. You should think of PresentationAction has being the action equivalent of optional state. Optional enhance state with an additional state of being nil , and PresentationAction enhances an action enum with the additional case of dismiss .

13:26

So, given these arguments, we hope to be able to return a whole new binding, but this time focused down on the child domain: ) -> Binding<Store<ChildState, ChildAction>>

13:47

But really it’s going to have to be a binding of an optional so that we can use it with vanilla SwiftUI’s modifiers, which expects bindings of optional identifiables: ) -> Binding<Store<ChildState, ChildAction>?>

14:01

And so this is the full signature of the new operation we want to implement. It’s quite complex looking, but it is also incredibly powerful.

14:10

Let’s see what it takes to implement it.

14:13

We know we need to return a binding of an optional store, so we can start there: Binding<Store<ChildState, ChildAction>?>( get: <#() -> Store<ChildState, ChildAction>?#>, set: <#(Store<ChildState, ChildAction>?) -> Void#> )

14:30

We need to implement these two closures.

14:35

We can start with the get . It’s a closure that needs to return the child store. We have access to a store by accessing self.wrappedValue : self.wrappedValue as Store<State, Action> That is a store focused on the parent domain.

14:48

We can scope on this store to try to chisel down the parent domain to just the child domain: self.wrappedValue.scope( state: <#KeyPath<ObservableState, ChildState>#>, action: <#CaseKeyPath<Action, ChildAction>#> )

15:04

For the state argument we need a key path that focuses on the child domain inside the parent domain, and that’s exactly the key path that was passed to the scope we are trying to implement: self.wrappedValue.scope( state: state, action: <#CaseKeyPath<Action, ChildAction>#> )

15:14

The action argument needs to be handed a case key path that focuses all the way down on the child action from the parent action. However, we can’t just pass along the action case key path handed to our method: self.wrappedValue.scope( state: state, action: action )

15:26

That doesn’t compile because that case key path only goes into the presentation domain of a child action. We can even see this by assign this scoped store to a temporary variable: let tmp = self.wrappedValue.scope( state: state, action: action ) …and checking the type of the variable: let tmp: Store<ChildState, PresentationAction<ChildAction>>?

15:34

Notice that the action type of this store is PresentationAction<ChildAction> . We have to further dive into the presented case of the PresentationAction enum, and luckily this is quite easy. Case key paths can be appended together just like regular key paths, because, well, they are actually key paths under the hood.

15:49

And so we will append the case key path of \.presented to go all the way down to the child domain: self.wrappedValue.scope( state: state, action: action.appending(path: \.presented) )

16:03

That’s all it takes for get . For set we need to implement the following closure: set: { childStore in }

16:13

…which is handed an optional store.

16:15

Now the interesting thing about the set of this binding is that no one ever sets a non- nil value through the binding. This binding gets handed to SwiftUI, which generally only knows that it’s some optional value. But it doesn’t know anything about the value inside, and doesn’t know how to construct values of the type or even know if it is possible to construct values.

16:45

So the only thing that SwiftUI can do with this binding is write nil to it, which means we only need to do something if the store argument is nil : set: { childStore in if childStore == nil { } }

17:00

When nil is written to the binding we want to send an action to the parent store to dismiss the feature. And thanks to the fact that we used PresentationAction in the signature of this method, we have a dismiss action immediately available: if store == nil { self.wrappedValue.send(action(.dismiss)) }

17:40

That completes the implementation of this method.

17:47

With that things are compiling, and we would hope the popover continues working exactly as it did. And we can run the preview and see that it does.

18:18

And the demo still works exactly as it did before, and it even appears re-renders in the minimal way possible. We can open the sheet and see that incrementing does not cause the PresentationView to re-compute its body. But if we start observing the count from the parent view, then it will re-compute its body when the count changes.

18:34

Another benefit of going this route is that because we are reducing the number of overloads of these view modifiers, as well as reducing the number of arguments and amount of type inference needed to use the method, we are able to use many, many more of these methods without straining the Swift compiler. We can copy and paste the entire popover(item:) modifier multiple times, and the view still compiles just fine. So this is a huge improvement. Next time: Observing bindings

20:38

We have now see that the new observation tools in Swift 5.9 has revolutionized nearly every part of the Composable Architecture. We have been able to completely remove large swaths of concepts that previously were required to make efficient features, and replace it with far more vanilla Swift and SwiftUI constructs. And we can now do less work to implement our features while somehow magically making our features more correct and more performant.

21:03

There is one last area of the Composable Architecture that we want to show off to see how the new observation tools can improve the situation. And this is an area of the Composable Architecture that has been thorny from day 1. And that is bindings. Stephen

21:16

When we first released the Composable Architecture we did not provide any special tools for bindings, which mean that your reducers would become very verbose since you would need a dedicated action for each UI component that uses bindings, and you would need to handle all those actions in the reducer.

21:32

Eventually we did provide some tools that made the situation a lot better, but there were lots of caveats to those tools. And we have multiple times tried to soften those caveats and fill in the gaps, and multiple times we have failed to come up with something that we were truly happy with.

21:46

Well, the new Observation tools in Swift 5.9 finally allow us to implement bindings in the library how we hoped we could from the very first days of the Composable Architecture. We again get to remove superfluous concepts from the library, and use simpler, more familiar constructs.

22:02

Let’s take a look…next time! Downloads Sample code 0264-observable-architecture-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 .