EP 286 · Modern UIKit · Jul 8, 2024 ·Members

Video #286: Modern UIKit: Tree-based Navigation

smart_display

Loading stream…

Video #286: Modern UIKit: Tree-based Navigation

Episode: Video #286 Date: Jul 8, 2024 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep286-modern-uikit-tree-based-navigation

Episode thumbnail

Description

While SwiftUI bindings were almost the perfect tool for UIKit navigation, they unfortunately hide some crucial information that we need to build out our tools. But never fear, we can rebuild them from scratch! Let’s build @Binding and @Bindable from scratch to see how they work, and we will use them to drive concise, tree-based navigation using enums.

Video

Cloudflare Stream video ID: 30540aed019e2b7328f333b3fe559ce5 Local file: video_286_modern-uikit-tree-based-navigation.mp4 *(download with --video 286)*

References

Transcript

0:05

OK, we clearly see the need for more information from our bindings. We would like to where the binding was derived from, and what key paths were used to do the deriving. That would give us the important information we need to distinguish between multiple presented controllers. Stephen

0:21

But SwiftUI’s Binding doesn’t give us this information, so it is on us to build our own binding from scratch. It may sound a little intimidating to build something like Binding from scratch, but it’s not so bad.

0:35

Let’s give it a shot. Binding from scratch

0:38

So, let’s start making it. What should we call it? Well, since it’s a binding type that is going to be tuned specifically for UIKit, maybe it should be called UIBinding : struct UIBinding<Value> { }

0:53

And it will hold onto something quite similar to what Shared holds onto. It will hold onto a base object from which the binding is being derived from, as well as an AnyKeyPath that focuses in on just one part of the base: struct UIBinding<Value> { let base: AnyObject let keyPath: AnyKeyPath }

1:03

And these types must be type erased so that we don’t need to understand where the binding was derived from. We just know it’s a binding of some value.

1:10

This is how SwiftUI works too. When you declare that a view has a binding of an integer like this: @Binding var count: Int // Binding<Int>

1:21

…you have completely erased the origins of this binding. It could have been derived from a CounterModel , or a SettingsModel , or an ActivityModel , or anything really.

1:29

If this root type was not erased then you would be forced to specify the generic everywhere you use a binding: @Binding<CounterModel, Int> var count: Int

1:42

And not only is this noisy syntax-wise, but it’s also annoying ergonomics-wise. This forces you to always provide a binding that is derived specifically from a CounterModel . But maybe sometimes you want to provide a binding from some other model. That wouldn’t be possible.

1:56

So the type erasure is good, but it does mean we will have to be careful to make sure that the types all do match up under the hood, even if Swift doesn’t know what the types are statically.

2:06

The first addition we would like to make to our @UIBinding is to make it a property wrapper: @propertyWrapper struct UIBinding<Value> { … }

2:11

And this means we must provide a wrappedValue property: var wrappedValue: Value { get { } set { } }

2:19

Implementing this is tricky, but we did face a similar problem when implementing the @Shared property wrapper.

2:25

We will take it one step at a time here.

2:26

First, naively we would hope we could just key path into the base and return that: get { base[keyPath: keyPath] }

2:36

This is not correct because key pathing into a value with an AnyKeyPath returns an Any , and it even returns an optional Any? at that since the types may not match up.

2:41

So, maybe we can just force cast the Any? to Value because we are taking the responsibility to make sure the types of base and keyPath always match up: get { base[keyPath: keyPath] as! Value }

2:49

However that does not work because you are not allowed to construct key paths that have AnyObject as a root.

2:56

We think this is a bug with Swift’s diagnostics and that it probably should be possible to subscript into an AnyObject with an AnyKeyPath , but to work around this bug we can further erase to Any : get { (base as Any)[keyPath: keyPath] as! Value }

3:04

But the setter is more complicated. In order to mutate the underlying object we need a writable key path, and in order to get that we need to get at the actual static root type lurking behind the shadows of the AnyObject . This sounds like a job for open existentials, which we have come across a few times in past Point-Free episodes. In the past we have opened existential values of protocols to get at their underlying static type, but this time we need to do it for a class. The principle is basically the same.

3:28

In modern Swift this typically starts by defining a generic local function that gives you access to the static typing information, and then in the body of that function you can do whatever want with that type. func open<Root>(_ root: Root) { }

3:46

In here we would like to set through the key path: root[keyPath: keyPath] = newValue

3:54

However this does not work because root is immutable in this context. But root is also known to be an AnyObject , and AnyObject s can be mutated with reference writable key paths. But right now we only have an AnyKeyPath .

4:07

We can force cast the key path to a reference writable key path, and again if we do our due diligence to make sure all the types match up, this should be a safe thing to do: root[ keyPath: keyPath as! ReferenceWritableKeyPath<Root, Value> ] = newValue

4:17

This compiles, and so we can now pass our existential to this open function to perform the setting: open(base)

4:23

Ideally this would be all it takes, but we will soon see that things are a little trickier. But we will leave it like this for now.

4:29

We now have the concept of a UIBinding , but we don’t have any way of constructing bindings. In fact, UIBinding doesn’t even have any publicly available initializers.

4:36

Instead we need something analogous to Bindable from SwiftUI, which gives you a safe way to derive bindings from a model. At the call site we’d like to be able to use a @UIBindable property wrapper to hold onto our model: final class CounterViewController: UIViewController { @UIBindable var model: CounterModel … }

4:56

And then we’d like to use syntax like this: self.present(item: self.$model.fact) { fact in … } …to derive a UIBinding to the fact field in the model.

5:04

This means we need to a new type, UIBindable , that serves as the property wrapper, and we need a projected value for the property wrapper so that we can unlock the $model syntax.

5:07

So, let’s start with the basics of a new type that is a property wrapper: @propertyWrapper struct UIBindable<Value> { }

5:16

And it will hold onto the underly value that it can derive bindings from, and its projected value will be self because we will be able to derive bindings directly from this type. No other secondary type will be necessary: @propertyWrapper struct UIBindable<Value> { var wrappedValue: Value var projectedValue: Self { get { self } set { self = newValue } } }

5:33

Typically Value will be an AnyObject , but SwiftUI’s @Bindable type has decided to allow Value to be completely unconstrained and instead constrains the public APIs that are defined on Bindable to only work with AnyObject . The only guess we have for why this might be is because perhaps Apple hopes that these APIs will work with any type, not just references.

5:48

For example, to initialize the property wrapper Value must be both Observable and AnyObject : @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension Bindable where Value: AnyObject, Value: Observable { public init(wrappedValue: Value) }

5:59

…and so we will repeat that here, but we will use the back-ported Perceptible instead of Observable : init(wrappedValue: Value) where Value: Perceptible, Value: AnyObject { self.wrappedValue = wrappedValue }

6:22

Further, dynamic member lookup is also hidden behind an AnyObject constraint: @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension Bindable where Value: AnyObject { public subscript<Subject>( dynamicMember keyPath: ReferenceWritableKeyPath< Value, Subject > ) -> Binding<Subject> { get } } And we will do the same. Given a reference writable key path from the Bindable ’s value to a member, we can derive a UIBinding to that member: subscript<Member>( dynamicMember keyPath: ReferenceWritableKeyPath< Value, Member > ) -> UIBinding<Member> where Value: AnyObject { UIBinding(base: self.wrappedValue, keyPath: keyPath) } And we will also need to mark UIBindable to be @dynamicMemeberLookup : @propertyWrapper @dynamicMemberLookup struct UIBindable<Value> { … }

7:06

This all compiles, and it is actually all we need to get multiple presentations working.

7:10

But while we are here let’s also go ahead and add dynamic member lookup to UIBinding . That’s what allows you to take a binding and deriving a new binding focused on some sub-part of the original binding.

7:15

So we’ll add the @dynamicMemberLookup attribute to UIBinding : @dynamicMemberLookup @propertyWrapper struct UIBinding<Value> { … }

7:19

And then we need to implement the following subscript: subscript<Member>( dynamicMember keyPath: WritableKeyPath<Value, Member> ) -> UIBinding<Member> { }

7:34

A UIBinding focused on the Member type can be defined by passing along the base reference, and appending key paths: UIBinding<Member>( base: base, keyPath: self.keyPath.appending(path: keyPath)! )

7:53

And that’s all it takes to define dynamic member lookup on bindings. Simultaneous presentation solution

7:56

We have just done some really wild stuff. We have created our own version of SwiftUI’s Binding type so that we can facilitate communication between a child feature that is presented, and the parent feature doing the presenting. In particular, when the user takes matters in their own hands by dismissing the child feature, we need the ability to clear out the state in the model, and that is exactly what our UIBinding does. Brandon

8:17

However, we aren’t actually using the binding yet. And why again did we even go down this road to recreate SwiftUI’s Binding ?

8:23

Well, as we showed before, our current navigation helpers have a problem. It is not possible to present multiple things at once, even though that is a completely valid thing to do in UIKit. If a second feature is presented over another feature, it will trample over the associated object we set in the parent controller.

8:39

So, we need some way to manage multiple associated objects, and this is exactly what our new UIBinding type is going to help with.

8:47

Let’s take a look.

8:49

We are already using the @UIBindable property wrapper in our controller: @UIBindable var model: CounterModel And this means that down in the view, when we use $model with dot-chaining syntax, we are actually deriving UIBinding ’s to hand over to the push method: navigationController? .pushViewController(item: $model.fact) { fact in FactViewController(fact: fact.value) } navigationController? .pushViewController(item: $model.secondaryFact) { fact in FactViewController(fact: fact.value) }

8:52

However, none of our navigation APIs speak the language of UIBinding yet. They are still using SwiftUI bindings. So let’s also update all of our navigation helpers to use UIBinding instead of Binding : func present<Item>( item: UIBinding<Item?>, content: (Item) -> UIViewController ) { … } … func pushViewController<Item>( item: UIBinding<Item?>, content: (Item) -> UIViewController ) { … }

9:31

This is compiling, but we still have a bunch of code commented out because we did not know how to properly track presented controllers. But we now have the perfect tool for this thanks to UIBinding .

9:58

Internally it has a lot of information that helps uniquely determine where the binding came from. We know the base reference object from which the binding was derived, and we know the key path that describes which field is being focused on in the object.

10:14

We can decide that we are only going to allow one single presentation to take place per reference/key path combo. In fact, we can even make UIBinding hashable: @dynamicMemberLookup @propertyWrapper struct UIBinding<Value>: Hashable { let base: AnyObject let keyPath: AnyKeyPath func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(base)) hasher.combine(keyPath) } static func == (lhs: Self, rhs: Self) -> Bool { lhs.base === rhs.base && lhs.keyPath == rhs.keyPath } … }

11:04

The binding will be the key we used to find the controller associated with it.

11:09

And there is one left because Swift is complaining that we are not allowed to mutate the item ’s wrappedValue since it is immutable: item.wrappedValue = nil However, mutating immutable bindings is kind of the whole point!

11:36

The fix is to mark wrappedValue ’s set as nonmutating since it doesn’t actually mutate the binding struct. It only mutates the underlying object reference: var wrappedValue: Value { get { … } nonmutating set { … } }

11:42

Then when assigning the controller in the dictionary we can do this: presented[item] = Presented(controller: controller)

11:54

And when decided when to dismiss the controller we can do this: } else if item.wrappedValue == nil, let controller = presented[key]?.wrappedValue { controller.dismiss(animated: true) presented[key] = nil }

12:00

So, when we want to see if a controller is not currently being presented, we can do so like this: presented[item] == nil

12:02

Now everything compiles, and I would hope it works. Let’s run it in the simulator, and we drill down just fine, but when we pop back…well it crashes: Thread 1: Could not cast value of type ‘ReferenceWritableKeyPath<CounterModel, CounterModel.Fact?>’ to ‘ReferenceWritableKeyPath<AnyObject, CounterModel.Fact?>’

12:40

This is happening because it turns out that Swift can’t really open AnyObject existentials. If we put a print at the top of open to see what the underlying type is:

12:50

And it just turns out this is a limitation of existentials in Swift right now, and in times like this we have to turn to an older tool that existed long before existentials had broader support in Swift. We need to use the underscored _openExistential function from the Swift standard library: nonmutating set { func open<Root>(_ root: Root) { root[ keyPath: keyPath as! ReferenceWritableKeyPath<Root, Value> ] = newValue } _openExistential(base, do: open) }

13:18

And now things will magically work just as they did before, even though now we are using our UIBinding type rather than SwiftUI’s Binding type. When we request a fact, two screens are pushed onto the stack. And we can pop each one off the stack with no glitchiness.

13:36

This is why we need to create our own UIBinding type that mimics SwiftUI’s Binding . We need access to a lot more information that Binding gives us publicly. We need to know from what base reference was the binding derived, and what key path is being used to extract state from that object. That allows us to identify where the binding came from, and allows us to make sure that one single navigation can happen per binding.

14:16

And this is all information that SwiftUI’s Binding type has too, we just don’t get access to any of it.

14:21

But we still have one of our helpers commented out, let’s update it, as well. Tree-based navigation

15:09

So we are now use the full powers of our UIBinding . All of the hard work we performed to keep type erased data hidden inside the binding while still exposing a type safe public interface paid off because we can now instantly inspect any binding to see exactly where it came from. And that is what allows us to properly support presenting as many child features as one wants. Stephen

15:31

So things are looking great, but also our work on supporting multiple presentations at once brings up another important topic in navigation, and that is: typically we only want to allow at most one single child feature to be presented at a time.

15:44

A typical feature can have many different places it can navigate to next, but usually only a single one of those destinations can be active at a time. The easiest way to represent multiple destinations is to simply hold onto multiple pieces of optional state in your model. When one flips to a non- nil value a navigation event will be triggered. Brandon

16:02

However, the problem is that it’s possible for multiple of those optionals to be non- nil at the same time, and typically presenting two features at once is an error in UIKit and SwiftUI. And multiple optionals leads to an explosion of state combinations, most of which are completely invalid. For example, if your feature can navigate to 5 different destinations, and you use 5 optionals to represent this, then you now have 2^5 = 32 different states in your domain for every combination of nil and non- nil . But, only 6 of those combinations are actually valid. Either they are all nil , or exactly one is non- nil . Stephen

16:39

This kind of problem is perfectly solved by Swift’s enum type, which allow you to model a value that can be exactly one of many different choices. And modeling navigation with enums is what we like to call “tree-based navigation” since nesting these enum structures creates a tree-like structure, and this is something we have talked about a ton on Point-Free, both in the context of vanilla SwiftUI and the Composable Architecture.

17:03

So, let’s see how tree-based navigation can work in UIKit.

17:08

Let’s quickly go back to a version of our demo where we just have one single piece of fact state, and we are presenting it in a sheet:

17:23

Let’s quickly add a new feature that we want to be able to navigate to, in addition to the FactViewController . Suppose there was a settings screen we could drill-down to.

17:30

I’m going to create a new file, SettingsFeature.swift.

17:35

We are going to paste in a very, very basic feature for our settings. At this moment we aren’t really interested in anything happening inside the settings feature, we are just interested in how we can navigate to the settings feature.

17:46

So we will define a simple perceptible model that would encapsulate all of the logic of settings, but right now we will just have it hold onto a single boolean: import Perception import SwiftUI @MainActor @Perceptible class SettingsModel { var isOn = false }

17:53

Next we will paste in the SwiftUI version of the settings view: struct SettingsView: View { @Perception.Bindable var model: SettingsModel var body: some View { Form { Toggle(isOn: $model.isOn) { Text("Is on?") } } } }

18:00

It’s always amazing to see just how simple SwiftUI views can be.

18:03

And next we will paste in a UIViewController version of this view: class SettingsViewController: UIViewController { let model: SettingsModel init(model: SettingsModel) { self.model = model super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBackground let isOnSwitch = UISwitch() isOnSwitch.addAction( UIAction { [weak model = self.model, weak isOnSwitch] _ in guard let model, let isOnSwitch else { return } model.isOn = isOnSwitch.isOn }, for: .valueChanged ) isOnSwitch .translatesAutoresizingMaskIntoConstraints = false view.addSubview(isOnSwitch) NSLayoutConstraint.activate([ isOnSwitch.centerXAnchor.constraint( equalTo: view.centerXAnchor ), isOnSwitch.centerYAnchor.constraint( equalTo: view.centerYAnchor ), ]) observe { isOnSwitch.setOn(self.model.isOn, animated: true) } } } That’s quite a bit more work, but it gets the job done.

18:25

And we can also get some previews in place to see what the views look like: #Preview("SwiftUI") { SettingsView(model: SettingsModel()) } #Preview("UIKit") { UIViewControllerRepresenting { SettingsViewController(model: SettingsModel()) } }

18:37

Nothing special so far, but at least we have new feature that we can navigate to.

18:41

So, the question is: how can we navigate to the settings feature from the counter feature? Well, as we said before, by far the simplest way to get started is to simply add a new optional to the domain: @MainActor @Perceptible class CounterModel { var count = 0 var fact: Fact? var factIsLoading = false var settings: SettingsModel? … }

18:57

We can also add a method to the model that is called when the “Settings” button is tapped, which will populate this state: func settingsButtonTapped() { settings = SettingsModel() }

19:08

And the act of populating the state should cause the drill-down to happen.

19:12

This is actually quite easy to do in SwiftUI. We can simply point the the navigationDestination view modifier at a binding of optional state, and as soon as that state becomes non- nil a drill-down will occur: .navigationDestination(item: $model.settings) { model in SettingsView(model: model) }

19:30

And further we can add a toolbar button in the top-right of the navigation bar to go to the settings: .toolbar { ToolbarItem { Button("Settings") { model.settingsButtonTapped() } } }

19:39

If we run this in the preview we will see it works exactly as we expect.

19:55

Let’s see what it takes for UIKit. Well, amazingly the navigation part is actually really simple, and it looks shockingly similar to how things are done in SwiftUI: navigationController? .pushViewController(item: $model.settings) { model in SettingsViewController(model: model) }

20:23

That’s really all it takes to drive navigation to the settings screen from the model.

20:27

But, we need a button in the UI to tap so that we can actually go to the settings screen. We can put a button in the upper-right corner of the navigation bar just as we did in SwiftUI: navigationItem.rightBarButtonItem = UIBarButtonItem( title: "Settings", primaryAction: UIAction { _ in self.model.settingsButtonTapped() } )

20:56

And now when we run the app we will see that it behaves exactly as we expect. That’s all it takes to add another navigation destination to a UIKit feature.

21:11

And best of all, in our core domain, the CounterModel , we never once had to think about how the settings feature was going to be presented. We didn’t care if it was done as a drill-down, or as a sheet, or even as something super custom. None of that matters. As far as the domain cares, navigation is just the presence or absence of some state. It doesn’t even matter if our view is implemented in SwiftUI or UIKit. We really can’t stress enough how important it is to model your domains first before worrying about view-related concerns.

21:37

So, this all looks really cool, but what isn’t so great is how imprecisely our domain is now modeled. We have two optional values for what should really be just a single optional since only one of these destinations can be active at a time. In fact, UIKit and SwiftUI consider it programmer error for both to be active at the same time.

21:57

We can even see this in our app right now without making any changes. Let’s run the app in the simulator, and in the UIKit version of the app let’s fetch a fact, but while that effect is in flight let’s quickly also navigate to the settings screen. The two forms of navigation do work, but we get the following warning printed in the console letting us know that it is not OK to present a sheet from a controller in the stack that is not the topmost controller: Presenting view controller <FactViewController> from detached view controller <CounterViewController> is not supported, and may result in incorrect safe area insets and a corrupt root presentation. Make sure <CounterViewController> is in the view controller hierarchy before presenting from it. Will become a hard exception in a future release.

22:17

And so while it seems to have worked, UIKit is telling us that its actual behavior is undefined, and in the future this may actually cause the app to crash. And the exact same thing happens if we run the SwiftUI version of the app too.

22:27

And so this is an actual bug in our app. We shouldn’t allow these to features to be presented at the same time, but nothing is stopping us from doing it in our code. But also this imprecise domain modeling leaks complexity into every corner of our code. We need to check two pieces of optional state to see if anything is currently being presented: if fact == nil && settings == nil { // Nothing is presented }

22:45

And if in the future we get a 3rd, 4th or even 5th destination to navigate to, we will need to make sure to add those checks here too. In reality, we have made it very difficult for us to quickly check if something is being presented, or what exactly is being presented. If both of these are non- nil but we know that only one should ever be non- nil , how are we supposed to interpret that.

23:04

One way to fix our problem without refactoring our domain is to simply remember to nil out the settings before we show the fact: settings = nil fact = Fact(fact: String(decoding: data, as: UTF8.self))

23:16

And I guess we should do the same when showing the settings, except we will nil out the fact: func settingsButtonTapped() { fact = nil settings = SettingsModel() }

23:23

This of course isn’t ideal to do because as we add more destinations we will have to remember to clean up all of their state too. However, even putting that aside for a moment, this still doesn’t work because UIKit still sees both of these features being presented for a split second. We can run in the simulator to see that visually it seems to work, but we are still getting the scary warning from UIKit.

23:43

We think this is really just a bug in UIKit. It should be possible for us to simultaneously dismiss and present something without UIKit getting confused. But, since that’s not the case, we do have to insert a little sleep between the two steps: settings = nil try await Task.sleep(for: .seconds(0.1)) fact = Fact(fact: String(decoding: data, as: UTF8.self))

24:08

Now it works as we expect, but of course the domain still is not modeled correctly.

24:20

Luckily there is a better way. Rather than holding onto two independent pieces of optional state, what if we held onto a single optional that represented whether or not a navigation was active, and that optional state was an enum?

24:31

We could call this enum Destination , and it would have a case for each place the feature can navigate to: enum Destination { case fact(Fact) case settings(SettingsModel) }

24:42

And we would hold onto an optional value of this type instead of individual fact and settings optionals: var destination: Destination? // var fact: Fact? // var settings: SettingsModel?

24:50

Then anywhere we were dealing with fact or settings we should instead go through destination . For example, when clearing out the current fact: // fact = nil destination = nil

24:59

And when presenting the fact after fetching it from the network: destination = nil try await Task.sleep(for: .seconds(0.1)) destination = .fact( Fact(fact: String(decoding: data, as: UTF8.self)) ) Ideally we wouldn’t need this sleep, but due to UIKit bugs it is sadly necessary.

25:19

And the methods on the CounterModel can also be updated easily: func decrementButtonTapped() { destination = nil count -= 1 } func incrementButtonTapped() { destination = nil count += 1 } func settingsButtonTapped() { destination = .settings(SettingsModel()) }

25:29

This is looking really fantastic. We just have this one single piece of optional state to worry about when it comes to navigating. If our feature’s logic decides its time to navigate somewhere, it just needs to point the destination state to some case, and the view will hopefully take care of the rest.

25:50

Speaking of which, our views are the only part of this code that is currently not compiling. In the SwiftUI view we are deriving bindings to the optional state to drive the alert and navigationDestination , but now that needs to go through our destination enum.

25:58

We might hope that we could simply chain into the destination property of the model , and then further chain into the fact and settings case of the Destination enum like this: .sheet(item: $model.destination.fact) { fact in Text(fact.value) } .navigationDestination( item: $model.destination.settings ) { model in SettingsView(model: model) }

26:09

It would be pretty amazing if that’s all it took to derive bindings to each case of an enum. But sadly, this does not work.

26:14

Vanilla SwiftUI does not come with the tools necessary to derive bindings to cases of enums, but fortunately for us, our SwiftUINavigation library does.

26:22

We are already importing SwiftUINavigation into this project because we were using its alert view modifier to help with some of the deficiencies of the vanilla SwiftUI alert modifier. In order to derive bindings to cases of an enum we need to first apply a macro to the Destination enum: @CasePathable enum Destination { … }

26:41

This derives “case paths” for each case of the enum, which are like key paths, but are tuned specifically for enums. Once that is done the hypothetical syntax we sketched out a moment is now working in the SwiftUI view. This incredible short, concise syntax: .sheet(item: $model.destination.fact) { fact in Text(fact.value) } .navigationDestination( item: $model.destination.settings ) { model in SettingsView(model: model) }

26:55

…is packing a huge punch. We get to point SwiftUI’s various navigation modifiers towards an optional enum, and then further to one of the cases of that enum, and then the sheet or drill-down will activate depending on how the state changes.

27:09

However, the UIKit controller is still not compiling. What if we could use a similar syntax for deriving bindings to hand to our UIKit navigation helpers: present(item: $model.destination.fact) { fact in FactViewController(fact: fact.value) } navigationController? .pushViewController( item: $model.destination.settings ) { model in SettingsViewController(model: model) }

27:20

This is not compiling because our SwiftUINavigation library only defines these helpers on SwiftUI’s Binding type. It doesn’t know anything about our UIBinding type.

27:28

So, how can we unlock this kind of syntax where we are allowed to use dot chaining with the name of a case on a binding of an optional enum? Well, first let’s see how we achieved this with the regular Binding type in SwiftUINavigation.

27:39

If we go over to the Binding.swift file in SwiftUINavigation and search for the term “CasePath” we will find some dynamic member subscripts that give us this super power. In particular, this one: public subscript<Enum: CasePathable, Member>( dynamicMember keyPath: KeyPath< Enum.AllCasePaths, AnyCasePath<Enum, Member> > ) -> Binding<Member?> where Value == Enum? { self[keyPath] }

27:52

This says that given a binding of an optional enum, and a case key path that can isolate a case inside that enum, we can derive a binding of an optional case.

28:01

When one writes code like this: $model.destination.fact …one is first deriving a Binding<Destination?> : $model.destination as Binding<Destination?>

28:13

So that part fits the shape we see in the subscript. And then by chaining on a case: $model.destination.fact …we are actually constructing a key path from the enum’s cases to a case path on the enum, which is what we like to call a case key path.

28:26

It all sounds a little abstract, and we have past episodes going into detail about case key paths, but we don’t want to get bogged down in those details yet. Instead we are just going to copy-and-paste this into our UIBinding and see what it takes to get it to compile: extension Binding { public subscript<Enum: CasePathable, Member>( dynamicMember keyPath: KeyPath< Enum.AllCasePaths, AnyCasePath<Enum, Member> > ) -> Binding<Member?> where Value == Enum? { self[keyPath] } }

28:42

First thing we have to do is import CasePaths and rename Binding to UIBinding : import CasePaths extension UIBinding { public subscript<Enum: CasePathable, Member>( dynamicMember keyPath: KeyPath< Enum.AllCasePaths, AnyCasePath<Enum, Member> > ) -> UIBinding<Member?> where Value == Enum? { self[keyPath] } }

28:52

And now we have an error letting us know that we are using a subscript that isn’t defined. This is actually a private helper transcript defined in SwiftUINavigation, so let’s grab that too and paste it into our file too: extension Optional where Wrapped: CasePathable { fileprivate subscript<Member>( keyPath: KeyPath< Wrapped.AllCasePaths, AnyCasePath<Wrapped, Member> > ) -> Member? { get { guard let wrapped = self else { return nil } return Wrapped .allCasePaths[keyPath: keyPath] .extract(from: wrapped) } set { guard let newValue else { self = nil return } self = Wrapped .allCasePaths[keyPath: keyPath] .embed(newValue) } } }

29:03

Now this compiles, and the theoretical syntax that we wanted to compile is also compiling: present(item: $model.destination.fact) { fact in FactViewController(fact: fact.value) } navigationController? .pushViewController( item: $model.destination.settings ) { model in SettingsViewController(model: model) }

29:14

And the app works exactly as we want. We can still present sheets and push onto the stack, and everything is driven by a single piece of optional state rather than a whole bunch of optional state. Next time: Stack-based navigation

30:01

OK, we have now accomplished some incredible things, and to be honest, when we first sat down to see how Swift’s new observation tools could be applied to UIKit we never dreamed we would be able to get this far. We can now drive navigation in UIKit controller directly from state in a concise manner, and it looks very, very similar to how one does navigation in SwiftUI.

30:20

This really does go to show that when domain modeling is a top priority in your application, you can build apps for seemingly very different paradigms with very similar code. I think most of us would agree that UIKit and SwiftUI are on opposite sides of the spectrum when it comes to framework design, yet we can see here that it is possible to build views in each style with a lot of similarities. Brandon

30:40

But we can push things even further with observation. We have fully covered the concept of tree-based navigation when it comes to observation, but there is another form of navigation to think about. Where tree-based navigation allows you to drive navigation from optionals and enums, there is something called “stack-based navigation”, where you drive navigation from a flat collection of states. When a value is added to the collection there is a drill-down animation to see a new view come onto the screen, and when a value is removed from the collection there is a pop animation to see the view leave the screen.

31:12

Let’s see how we can use the observation tools in Swift to give us a really nice way to implement stack-based navigation in our apps…next time! References Collection: Modern SwiftUI Brandon Williams & Stephen Celis • Nov 28, 2022 Note What does it take to build a vanilla SwiftUI application with best, modern practices? We rebuild Apple’s Scrumdinger code sample, a decently complex application that tackles real world problems, in a way that can be tested, modularized, and uses all of Swift’s powerful domain modeling tools. https://www.pointfree.co/collections/swiftui/modern-swiftui 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 SwiftUI Navigation Brandon Williams & Stephen Celis • Sep 7, 2021 A library we open sourced. Tools for making SwiftUI navigation simpler, more ergonomic and more precise. https://github.com/pointfreeco/swiftui-navigation CasePaths Brandon Williams & Stephen Celis CasePaths is one of our open source projects for bringing the power and ergonomics of key paths to enums. https://github.com/pointfreeco/swift-case-paths Clocks Brandon Williams & Stephen Celis • Jan 8, 2024 Our back-port of Swift’s observation tools. https://github.com/pointfreeco/swift-perception Downloads Sample code 0286-modern-uikit-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 .