EP 285 · Modern UIKit · Jul 1, 2024 ·Members

Video #285: Modern UIKit: Unified Navigation

smart_display

Loading stream…

Video #285: Modern UIKit: Unified Navigation

Episode: Video #285 Date: Jul 1, 2024 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep285-modern-uikit-unified-navigation

Episode thumbnail

Description

We have built the foundation of powerful new UIKit navigation tools, but they’re not quite finished. Let’s improve these APIs to handle dismissal by leveraging another SwiftUI tool: bindings. We will see how SwiftUI bindings are (almost) the perfect tool for UIKit navigation, and we will see where they fall short.

Video

Cloudflare Stream video ID: 8b2a1766c0d83a596dc0ab1d7fa9d43d Local file: video_285_modern-uikit-unified-navigation.mp4 *(download with --video 285)*

References

Transcript

0:05

So we have now taken a huge step towards making a generic, reusable navigation helper for UIKit. This little helper could even be extracted out into a library and imported into any project that wants a powerful navigation tool for UIKit. Brandon

0:18

However, there is still one big problem with the tool, and that is we are not properly detecting when the controller is dismissed by the user, such as swiping down on a sheet. We need to detect that situation so that we can update the state in the model so that our model and UI can remain consistent.

0:38

Let’s give that a shot. Dismissal

0:41

How can we generically determine when some object is deallocated? One way would be to have some kind of “presentation” base controller: open class PresentationController: UIViewController { deinit { // Time to clean up state } }

1:08

And then people who want to use state-driven navigation in UIKit would need to subclass this so that we could be notified of when deinit happens.

1:16

But this is a super inflexible way to allow one to enhance their features with extra functionality. This locks people into one single subclass, and people may have their own controller base classes they want to use.

1:17

Luckily there is a better way, and it’s all thanks to associated objects, again. We can associate an object with the controller being presented so that when the controller is deallocated the associated object is also deallocated, and that will allow us to tap into the moment the controller is fully dismissed.

1:45

So, let’s define an object that can notify the outside when it is deallocated: final fileprivate class OnDeinit { let onDismiss: () -> Void init(onDismiss: @escaping () -> Void) { self.onDismiss = onDismiss } deinit { onDismiss() } }

2:06

And then we will introduce a key to be used for the associated object: private let onDeinitKey = malloc(1)!

2:27

And add a computed property associating an OnDeinit with a view controller: fileprivate var onDeinit: OnDeinit? { get { objc_getAssociatedObject(self, onDeinitKey) as? OnDeinit } set { objc_setAssociatedObject( self, onDeinitKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC ) } }

2:56

And finally we can have the presented controller hold onto an OnDeinit object when it is presented: controller.onDeinit = OnDeinit { }

3:19

It’s not exactly clear what to do in the onDismiss closure of the OnDeinitController , but before getting to that there’s something else to fix. If we put a breakpoint in that closure and run the app in the simulator we will see that the breakpoint never triggers.

3:40

This is happening for the same reason it did a moment ago. We have a strong reference to this controller held in the associated object, and so it will not be released until the presented property is set again.

3:53

We need to employ weak references again, just as we did before. However, unfortunately, associated objects in NSObject do not support weak references. We have to do a little bit of work ourselves.

4:06

We will define a new wrapper type that holds onto a weak reference of an object: fileprivate final class Presented { weak var controller: UIViewController? init(controller: controller) { self.controller = controller } }

4:24

And then this is the object we will store as the associated object: fileprivate var presented: UIViewController? { get { (objc_getAssociatedObject(self, presentedKey) as? Presented)?.controller } set { objc_setAssociatedObject( self, presentedKey, newValue.map { Presented(controller: $0) }, .OBJC_ASSOCIATION_RETAIN_NONATOMIC ) } }

4:53

And with that small change the breakpoint will now catch when we run in the simulator.

5:06

However, it still is not clear what exactly we are supposed to do in this closure: controller.onDeinit = OnDeinit { // ??? }

5:15

We want to somehow reach all the way back to the state that is driving this navigation and nil it out. We certainly can try to do something naive like this: if let unwrappedItem = item, presented == nil { let controller = content(item) controller.onDeinit = OnDeinit { // ??? } … } … …that is kinda what we want to do in spirit, but of course this can’t possible work because the item passed to this method is not mutable. And we also can’t simply make the item argument inout : item: inout Item?, …because inout values cannot be captured in escaping closures, and the onDismiss trailing closure must be escaping since it gets called at a later time after this lexical scope ends.

5:54

Things seem a little hopeless, but we can take inspiration from SwiftUI. A moment ago we remarked how cool it will be to have an API for UIKit that looks like the ones we in SwiftUI: /* .sheet(item: $model.fact) { fact in FactView(fact: fact.value) } */ present(item: model.fact) { fact in FactViewController(fact: fact.value) }

6:13

However, there is a difference between these APIs. The SwiftUI version takes a binding of an optional value, and then UIKit version takes just a plain optional value.

6:22

That is a huge difference. The SwiftUI version of this API is given a connection to the “source of truth” that is driving navigation and it is capable of writing to that data. Of course generically there is only one thing it can write to the binding, and that’s nil , but that is exactly what we want to do here.

6:49

So, does that mean our item argument should actually be a binding so that we are allowed to write nil to it later? import SwiftUI … func present<Item>( item: Binding<Item?>, content: (Item) -> UIViewController ) { if let unwrappedItem = item.wrappedValue, presented == nil { let controller = content(unwrappedItem)) controller.onDeinit = OnDeinit { binding.wrappedValue = nil } presented = controller present(controller, animated: true) } else if item.wrappedValue == nil, let controller = presented { controller.dismiss(animated: true) presented = nil } }

7:11

This would mean that when presenting the sheet we need to derive a binding to the optional state: present(item: $model.fact) { fact in FactViewController(fact: fact.value) } And now this really looks like the SwiftUI version.

7:21

And then I guess in order to derive a binding from the model we should make use of the Bindable property, or in this case @Perception.Bindable since we are still using the perception back port as we are targeting older Apple platforms: final class CounterViewController: UIViewController { @Perception.Bindable var model: CounterModel … }

7:33

Seems kind of bizarre to use SwiftUI tools for UIKit, but it does seem like what we need and it even compiles. And it even works. We can put a print statement in the didSet of fact : var fact: Fact? { didSet { print(fact) } }

8:02

And when we run in the simulator to present a fact and dismiss the fact, we will see that nil is written to the binding: nil Optional(UIKitNav.CounterModel.Fact(fact: "2 is the number of stars in a binary star system (a stellar system consisting of two stars orbiting around their center of mass).")) nil

8:14

And that’s pretty incredible.

8:16

And can this API work for other kinds of navigation? For example, if we wanted to go back to showing an alert instead of a sheet for the fact, we might try to do so like this: present(item: $model.fact) { fact in // FactViewController(fact: fact.value) let alert = UIAlertController( title: "Fact", message: fact.value, preferredStyle: .alert ) alert.addAction( UIAlertAction(title: "OK", style: .default) ) return alert }

8:37

And even this works! And in fact, it even works better than the alert we had before. If we look at the logs then we will see the following: nil Optional(UIKitNav.CounterModel.Fact(fact: "4 is the number of chambers the mammalian heart consists of.")) nil This means that the alert state was nil ’d out when the alert was dismissed, even though we aren’t explicitly nil ’ing out the state when the “OK” button is tapped: alert.addAction(UIAlertAction(title: "OK", style: .default))

9:20

This is working because of that little OnDeinit object that was added to the alert. It gets deallocated when the alert goes away, and so we now have a great way to clean up state when an alert is dismissed.

9:36

What about popovers?

9:38

Well, you can just customize the controller before returning it from present ’s trailing closure: present(item: $model.fact) { fact in let controller = FactViewController(fact: fact.value) controller.modalPresentationStyle = .popover controller.popoverPresentationController? .sourceView = factButton return controller }

9:58

And this works exactly as we expect if we run the app in the iPad simulator.

10:19

What about drill-down navigation?

10:21

Well, unfortunately we do not get that for free right now. Drill-down navigation uses a completely different API than sheets, popovers and alerts. Instead of calling present you need to call pushViewController on the underlying navigationController : self.present self.navigationController?.pushViewController

10:36

And currently our present helper specifically calls UIKit’s present under the hood.

10:44

Sounds like we just need another method, this time defined on UINavigationController for pushing controllers: extension UINavigationController { func pushViewController<Item>( item: Binding<Item?>, content: (Item) -> UIViewController ) { } } The implementation is going to be very similar to what we have in present . In fact, I think the only line that will be different is that we will call pushViewController instead of present under the hood.

10:56

Let’s copy-and-paste the contents of our present helper into our new pushViewController helper, but instead of calling UIKit’s present we will invoke UIKit’s pushViewController : navigationController?.pushViewController( controller, animated: true ) And just like that we are now able to push the fact view controller onto the stack instead of presenting in a sheet: pushViewController(item: $model.fact) { fact in FactViewController(fact: fact.value) }

11:23

However, dismissal of drill-downs is also a little different from sheets in UIKit. You cannot simply call dismiss(animated:) on a controller to dismiss it from a navigation controller. You instead need to use the popToViewController API defined on UINavigationController .

11:56

We need to dismiss the controller that is handed to us from the content closure, but the popToViewController works by telling it what controller you want to pop to : navigationController?.popToViewController( <#UIViewController#>, animated: <#Bool#> ) …not which controller you want to pop specifically .

12:11

So, we have to do a little bit of work to find the controller that comes before the controller we want to pop, and we can even bake it into a little private helper: private func popFromViewController( _ controller: UIViewController, animated: Bool ) { guard let index = viewControllers.firstIndex(of: controller), index != 0 else { return } popToViewController( viewControllers[index - 1], animated: true ) } And now we can use this helper to dismiss: self.navigationController?.popFromViewController( $0, animated: true )

13:24

And this works exactly as we expect. So we now support all of the major forms of navigation in UIKit: sheets, alerts, popovers, and drill-downs, including programatic navigation and dismissal, and even dismissal from the user by writing to a binding. Even better presentation APIs

14:12

So I think this is pretty incredible. We now have a small arsenal of navigation tools that are tuned specifically for UIKit. We can now drive navigation from pure state changes, and the APIs to do so don’t really look that much different from vanilla SwiftUI. Heck, we’re even using bindings from SwiftUI in order to facilitate communication between parent and child features! Stephen

14:34

However, there are a lot of improvements we can make to these APIs. We are going to start by with a small ergonomic improvement to the tools followed by an exploration into how Identifiable can can allow us to easily change the data we present and have the sheet automatically dismiss and re-present itself, just as it does in vanilla SwiftUI

14:58

The first change we want to make is based on how we invoke the navigation APIs: observe { [weak self] in … navigationController?.pushViewController( item: $model.fact ) { fact in FactViewController(fact: fact.value) } }

15:03

Currently this only works if you call this inside an observe closure. If you forget to do that then the tool will be subtly broken.

15:10

It would be better if we baked the observation inside the navigation tools so that you could invoke it right in viewDidLoad : func viewDidLoad() { … navigationController?.pushViewController( item: $model.fact ) { fact in FactViewController(fact: fact.value) } }

15:17

This also means we don’t have to clutter our observe closure with navigation.

15:21

To do this we will just wrap the implementation of pushViewController in observe : func pushViewController<Item>( item: Binding<Item?>, content: (Item) -> UIViewController ) { observe { [weak self] in guard let self else { return } … } } …but in order for that to work we now need to make content escaping: content: @escaping (Item) -> UIViewController

15:40

And that’s all it takes.

15:43

Let’s do the same for the present method too.

15:56

However when we run things, they do not work as we expect: things are no longer getting presented. When we add a breakpoint we see it catch the first time, but never again, and if we dump the item binding, we’ll get a hint that we’re maybe dealing with an implementation quirk of bindings, which is that it caches its value: (lldb) po item ▿ Binding<Optional<Fact>> … - _value : nil

16:30

And so it seems to be evaluating this nil value, which doesn’t trigger observation, and so the closure won’t re-evaluate when something is presented.

16:37

We can work around the problem by lazily accessing the binding, instead. We can update the helper to take an autoclosure: item: @autoclosure @escaping () -> Binding<Item?>,

16:56

And we can evaluate the closure in the body of the observe : observe { [weak self] in … let item = item() … }

17:00

Let’s update the other helper while we’re at it.

17:20

And when we run things in the simulator, they work again. It was a bit strange that we hit this binding quirk, but we can keep moving forward.

17:41

So that’s one small ergonomic improvement to the navigation APIs. Let’s also make a behavioral improvement. As we remarked earlier, the sheet(item:) API in SwiftUI requires that the item being presented is Identifiable : .sheet(item: $model.fact) { fact in Text(fact.value) } …and that is why we introduced a dedicated Fact type to wrap a string.

18:02

Why does sheet require this? Well, it allows for a really nice navigation pattern where you mutate the data driving the sheet while a sheet is being presented. If the ID of the item is changed, SwiftUI will take care of dismissing the sheet and representing it with the new data.

18:16

Let’s see this in concrete terms. After we load a fact in our model, let’s wait for 2 seconds and then load another fact: try? await Task.sleep(for: .seconds(2)) self.fact = try await Fact( value: String( decoding: URLSession.shared.data( from: URL( string: "http://www.numberapi.com/\(count)" )! ) .0, as: UTF8.self ) ) It’s of course a non-sensical thing to do in our current app, but we are just trying to show this behavior.

18:40

If we run the SwiftUI preview and present a fact, we will see that after a few seconds the sheet is dismissed, and a brand new fact is presented. That’s pretty cool, but sadly this does not automatically work in our UIKit demo. If we run that preview we will see that the fact presents, but then nothing. It never dismisses and re-presents. And I think we can capture that SwiftUI behavior with our UIKit navigation APIs.

19:17

Let’s start by making the presents API take an Identifiable item: func present<Item: Identifiable>(…)

19:29

In the body of this method we are already checking to see if the state flips from nil to non- nil or vice-versa, but we now need to beef up this logic to also detect if the ID of the presented item changes. And in that case we want to dismiss the currently presented controller and then present a fresh new controller.

19:44

So it’s no longer true that we want to present a controller only when presented is nil because sometimes we may want to re-present when something is already presented: if let unwrappedItem = item.wrappedValue // , presented == nil { … }

19:55

And then we can handle the two situations separately. If a controller is already presented then we need to check if the ID changed so that we can do the dismiss and present dance, and otherwise if nothing is presented then we can just go ahead and unconditionally present: if let presented { // Check if ID changed } else { // Present }

20:00

To check if the ID has changed we need to have some record of the ID. We could store yet another associated object on the controller for the ID, but we already have this Presented object and so maybe we can squirrel the ID away in there: fileprivate final class Presented { weak var controller: UIViewController? let id: AnyHashable? init( id: AnyHashable? = nil, controller: UIViewController? = nil ) { self.id = id self.controller = controller } }

20:19

But we are currently hiding the Presented from the outside in our computed property, so let’s expose it more publicly: fileprivate var presented: Presented? { get { objc_getAssociatedObject( self, presentedKey ) as? Presented } set { objc_setAssociatedObject( self, presentedKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC ) } } …and fix the resulting compiler errors.

21:18

Now we have the ability to check if the ID of the item being presented differs from the item that is currently presented, in which can we can dismiss: if let presented, let controller = presented.controller { guard AnyHashable(unwrappedItem.id) != presented.id else { return } controller.dismiss(animated: true) }

21:48

And further, when that dismissal finishes we want to present the new item, which we can do in its completion block: controller.dismiss(animated: true) { // Present }

21:53

We now have two code paths that we need to present from, and their logic is identical. So let’s define a little local function to do that work: @MainActor func presentNewController() { let controller = content(unwrappedItem) controller.onDeinit = OnDeinit { item.wrappedValue = nil } presented = Presented( id: unwrappedItem.id, controller: controller ) present(controller, animated: true) }

22:06

And then we can invoke this present helper from both code paths: if let presented, let controller = presented.controller { guard AnyHashable(unwrappedItem.id) != presented.id else { return } controller.dismiss(animated: true) { presentNewController() } } else { presentNewController() }

22:22

When we run things in the simulator, it dismisses, represents, and then immediately dismisses again. This is happening because onDeinit is unconditionally nil -ing out the item when the controller goes away. We need to take special care to only nil things out when the identity doesn’t change: controller.onDeinit = OnDeinit { [weak self] in if AnyHashable(unwrappedItem.id) == self?.presented?.id { item.wrappedValue = nil } } Simultaneous presentation problems

23:07

Amazingly that’s all it takes.

23:10

At this point it feels like we are re-implementing a lot of what SwiftUI does, just in UIKit. We are even recreating some of the more nuanced behavior of SwiftUI, such as when the identity of a presented item changes we want to perform a dismissal animation followed by a presentation. And it’s pretty amazing that we are even able to use SwiftUI’s Binding type to aid in all of this. Brandon

23:30

But as cool as it is to leverage SwiftUI bindings to help with UIKit navigation, it’s also a little strange, and not quite right. Currently we are leveraging an associated object to keep track of what controller is currently being presented. And most of the time that is completely fine, because typically only a single feature can be presented at a time. Stephen

23:48

However, that is not always the case. In fact, it’s completely legitimate for a controller to push multiple controllers onto its underlying navigation controller. And sometimes it’s even possible to present multiple alerts at once, and they just stack on top of each other. Brandon

24:03

And for that reason the single associated object we have is just not going to cut it. But if we need to beef it up to be some kind of collection of associated objects, then we are going to need some way to distinguish all the associated objects so that they know where they came from because when a controller is dismissed we need to then nil out the correct state.

24:26

Let’s see this problem first hand, and then let’s see how we can fix it.

24:31

To see the problem let’s shoehorn another form of navigation into our counter feature. Let’s make it so that when you request a fact, you actually get two facts, and each fact pushes a FactViewController onto the stack.

24:47

We’ll start with the domain modeling. We’ll just add another piece of optional state to represent a secondary fact: @MainActor @Perceptible class CounterModel { var count = 0 var fact: Fact? var secondaryFact: Fact? … }

24:53

And right after we load the first fact, we will sleep for a second and load another fact: try await Task.sleep(for: .seconds(1)) secondaryFact = Fact( fact: String( decoding: try await URLSession.shared.data(from: url).0, as: UTF8.self ) )

25:13

So from a domain modeling perspective there isn’t too much special going on here.

25:17

And then down in the view we will push a FactViewController onto the stack when either of these pieces of state becomes non- nil : navigationController? .pushViewController(item: $model.fact) { fact in FactViewController(fact: fact.value) } navigationController? .pushViewController(item: $model.secondaryFact) { fact in FactViewController(fact: fact.value) }

25:32

If we run the app in the simulator we will see that we get one fact pushed onto the stack, but we aren’t getting that secondary fact. And in UIKit it is perfectly legitimate to push two controllers on from the same controller.

25:58

The problem is that we have a single property that represents what controller is currently presented: fileprivate var presented: Presented? { … }

26:21

But this is too naive. As we see here it is possible for a single controller to be presenting multiple controllers.

26:26

We somehow need to model a whole collection of view controllers that can be presented. And this collection should be a dictionary because it is going to be important to be able to look up a specific controller depending on what binding is being used to drive navigation.

26:36

So, let’s start by upgrading presented to be a dictionary: fileprivate var presented: [AnyHashable: Presented] { get { (objc_getAssociatedObject(self, presentedKey) as? [AnyHashable: Presented]) ?? [:] } set { objc_setAssociatedObject( self, presentedKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC ) } }

26:53

Now anywhere we reference presented we need to subscript into the dictionary to find the controller. But what should the key be?

27:03

The binding could make a great key. After all, it was derived from an observable class, which has a stable ObjectIdentifier , and we used key paths to derive bindings from the observable object to a subset of the object: navigationController? .pushViewController(item: $model.fact) { fact in … } navigationController? .pushViewController(item: $model.secondaryFact) { fact in … }

27:24

And so encoded in a binding is a lot of information that allows us to figure out what exactly is being presented.

27:30

However, SwiftUI’s Binding doesn’t give us access to any of this information. It’s not Hashable itself: item() as any Hashable Cannot convert value of type ‘Binding<Item?>’ to type ‘any Hashable’ in coercion

27:45

…and Binding doesn’t expose its underlying observable object or key paths.

27:57

And this is the main reason we cannot use SwiftUI’s Binding . And we already came to grips with another binding issue, which required us to capture it in a closure so that it could be properly observed.

28:11

For these reasons, and more, it is not appropriate to naively use SwiftUI’s Binding type. Instead we should build our own that tracks more information on the inside that we can leverage to properly present multiple features at once.

28:27

But, that seems pretty intimidating to build our own binding from scratch. Certainly SwiftUI is doing some serious magic under the hood to make bindings work.

28:36

Well, it turns out it’s not so bad. We can take a look at the public Binding interface to get a little bit of direction of where to start, and we can even look at the object dump of a Binding to see what all is on the inside, but for the most part we can just be guided by how we want bindings to work in UIKit to figure out their implementation.

28:58

Let’s first comment out the body of the present and pushNavigationController helpers so that we can get our project back in compiling order…

29:09

We will revisit this helper once we figure out how we want to handle bindings.

29:13

We can look at the Swift interface file to see what SwiftUI’s Binding type looks like from the public viewpoint: @frozen @propertyWrapper @dynamicMemberLookup public struct Binding<Value> { … }

29:17

It’s a seemingly simple struct, which is a little surprising since we know that bindings are quite reference-y. You are allowed to mutate bindings even when they are immutable: import SwiftUI func foo(count: Binding<Int>) { count.wrappedValue = 1 }

29:44

And the clearest sign that something fishy is happening under the hood of Binding is that its underlying wrappedValue has a nonmutating set : public var wrappedValue: Value { get nonmutating set }

29:52

This means you are allowed to mutate this value, but it doesn’t actually mutate the Binding struct. Instead it just mutates some reference type hidden on the inside.

30:12

If we look at the initializers we might be led to believe that Binding is just defined as some simple get and set closures: public init( get: @escaping () -> Value, set: @escaping (Value) -> Void )

30:22

But this is also very far from the truth. If all Binding held onto were these closures, then it would have no identifying information of where the binding was derived from or what sub-state the binding was focused on. That is all very important information that SwiftUI uses to implement its APIs, and we will need access to that information too.

30:49

In fact, let’s quickly construct a deeply nested binding in the entry point of the app and dump it so that we can see what kind of stuff is lurking inside: @main struct ModernUIKit: App { init() { @State var model = CounterModel() model.fact = CounterModel.Fact(fact: "Fact 1") dump(Binding($model.fact)!.fact) } … }

31:49

…and the following is printed to the console: ▿ SwiftUI.Binding<Swift.String> ▿ transaction: SwiftUI.Transaction ▿ plist: [] - elements: nil ▿ location: SwiftUI.LocationBox<SwiftUI.(unknown context at $1ccbddf20).ProjectedLocation<SwiftUI.LocationBox<SwiftUI.(unknown context at $1ccbddf20).ProjectedLocation<SwiftUI.LocationBox<SwiftUI.(unknown context at $1ccbddf20).ProjectedLocation<SwiftUI.LocationBox<SwiftUI.ConstantLocation<UIKitNav.CounterModel>>, Swift.WritableKeyPath<UIKitNav.CounterModel, Swift.Optional<UIKitNav.CounterModel.Fact>>>>, SwiftUI.BindingOperations.ForceUnwrapping<UIKitNav.CounterModel.Fact>>>, Swift.WritableKeyPath<UIKitNav.CounterModel.Fact, Swift.String>>> #0 - super: SwiftUI.AnyLocation<Swift.String> - super: SwiftUI.AnyLocationBase ▿ location: SwiftUI.(unknown context at $1ccbddf20).ProjectedLocation<SwiftUI.LocationBox<SwiftUI.(unknown context at $1ccbddf20).ProjectedLocation<SwiftUI.LocationBox<SwiftUI.(unknown context at $1ccbddf20).ProjectedLocation<SwiftUI.LocationBox<SwiftUI.ConstantLocation<UIKitNav.CounterModel>>, Swift.WritableKeyPath<UIKitNav.CounterModel, Swift.Optional<UIKitNav.CounterModel.Fact>>>>, SwiftUI.BindingOperations.ForceUnwrapping<UIKitNav.CounterModel.Fact>>>, Swift.WritableKeyPath<UIKitNav.CounterModel.Fact, Swift.String>> ▿ location: SwiftUI.LocationBox<SwiftUI.(unknown context at $1ccbddf20).ProjectedLocation<SwiftUI.LocationBox<SwiftUI.(unknown context at $1ccbddf20).ProjectedLocation<SwiftUI.LocationBox<SwiftUI.ConstantLocation<UIKitNav.CounterModel>>, Swift.WritableKeyPath<UIKitNav.CounterModel, Swift.Optional<UIKitNav.CounterModel.Fact>>>>, SwiftUI.BindingOperations.ForceUnwrapping<UIKitNav.CounterModel.Fact>>> #1 - super: SwiftUI.AnyLocation<UIKitNav.CounterModel.Fact> - super: SwiftUI.AnyLocationBase ▿ location: SwiftUI.(unknown context at $1ccbddf20).ProjectedLocation<SwiftUI.LocationBox<SwiftUI.(unknown context at $1ccbddf20).ProjectedLocation<SwiftUI.LocationBox<SwiftUI.ConstantLocation<UIKitNav.CounterModel>>, Swift.WritableKeyPath<UIKitNav.CounterModel, Swift.Optional<UIKitNav.CounterModel.Fact>>>>, SwiftUI.BindingOperations.ForceUnwrapping<UIKitNav.CounterModel.Fact>> ▿ location: SwiftUI.LocationBox<SwiftUI.(unknown context at $1ccbddf20).ProjectedLocation<SwiftUI.LocationBox<SwiftUI.ConstantLocation<UIKitNav.CounterModel>>, Swift.WritableKeyPath<UIKitNav.CounterModel, Swift.Optional<UIKitNav.CounterModel.Fact>>>> #2 - super: SwiftUI.AnyLocation<Swift.Optional<UIKitNav.CounterModel.Fact>> - super: SwiftUI.AnyLocationBase ▿ location: SwiftUI.(unknown context at $1ccbddf20).ProjectedLocation<SwiftUI.LocationBox<SwiftUI.ConstantLocation<UIKitNav.CounterModel>>, Swift.WritableKeyPath<UIKitNav.CounterModel, Swift.Optional<UIKitNav.CounterModel.Fact>>> ▿ location: SwiftUI.LocationBox<SwiftUI.ConstantLocation<UIKitNav.CounterModel>> #3 - super: SwiftUI.AnyLocation<UIKitNav.CounterModel> - super: SwiftUI.AnyLocationBase ▿ location: SwiftUI.ConstantLocation<UIKitNav.CounterModel> ▿ value: UIKitNav.CounterModel #4 - _count: 0 ▿ _fact: Optional(UIKitNav.CounterModel.Fact(fact: "Fact 1")) ▿ some: UIKitNav.CounterModel.Fact - fact: "Fact 1" - _secondaryFact: nil - _factIsLoading: false ▿ _$perceptionRegistrar: Perception.PerceptionRegistrar ▿ _rawValue: Perception.AnySendable ▿ base: Observation.ObservationRegistrar ▿ extent: Observation.ObservationRegistrar.(unknown context at $209903228).Extent #5 ▿ context: Observation.ObservationRegistrar.Context ▿ state: Observation._ManagedCriticalState<Observation.ObservationRegistrar.(unknown context at $2099032f8).State> - buffer: Observation._ManagedCriticalState<Observation.ObservationRegistrar.(unknown context at $2099032f8).State>.(unknown context at $209903144).LockedBuffer #6 ▿ super: Swift.ManagedBuffer<Observation.ObservationRegistrar.(unknown context at $2099032f8).State, Swift.UnsafeRawPointer> ▿ header: Observation.ObservationRegistrar.(unknown context at $2099032f8).State - id: 0 - observations: 0 key/value pairs - lookups: 0 key/value pairs - isPerceptionCheckingEnabled: true ▿ perceptionChecks: Perception._ManagedCriticalState<Swift.Dictionary<Perception.(unknown context at $104f15c58).Location, Swift.Bool>> - lock: <NSLock: 0x600002609800>{name = nil} #7 - super: NSObject - buffer: Perception._ManagedCriticalState<Swift.Dictionary<Perception.(unknown context at $104f15c58).Location, Swift.Bool>>.(unknown context at $104f15988).LockedBuffer #8 ▿ super: Swift.ManagedBuffer<Swift.Dictionary<Perception.(unknown context at $104f15c58).Location, Swift.Bool>, Swift.UnsafeRawPointer> ▿ header: 1 key/value pair ▿ (2 elements) ▿ key: Perception.(unknown context at $104f15c58).Location - file: "/Users/brandon/projects/swift-experiments/uikit-navigation/from-scratch-1/UIKitNav/UIKitNav/CounterFeature.swift" - line: 9 - value: false ▿ _cache: SwiftUI.AtomicBox<SwiftUI.LocationProjectionCache> - buffer: SwiftUI.(unknown context at $1ccbdf8a8).AtomicBuffer<SwiftUI.LocationProjectionCache> #9 ▿ super: Swift.ManagedBuffer<__C.os_unfair_lock_s, SwiftUI.LocationProjectionCache> ▿ header: __C.os_unfair_lock_s - _os_unfair_lock_opaque: 0 - projection: \CounterModel.fact #10 - super: Swift.WritableKeyPath<UIKitNav.CounterModel, Swift.Optional<UIKitNav.CounterModel.Fact>> - super: Swift.KeyPath<UIKitNav.CounterModel, Swift.Optional<UIKitNav.CounterModel.Fact>> - super: Swift.PartialKeyPath<UIKitNav.CounterModel> ▿ super: Swift.AnyKeyPath - _kvcKeyPathStringPtr: nil ▿ _cache: SwiftUI.AtomicBox<SwiftUI.LocationProjectionCache> - buffer: SwiftUI.(unknown context at $1ccbdf8a8).AtomicBuffer<SwiftUI.LocationProjectionCache> #11 ▿ super: Swift.ManagedBuffer<__C.os_unfair_lock_s, SwiftUI.LocationProjectionCache> ▿ header: __C.os_unfair_lock_s - _os_unfair_lock_opaque: 0 - projection: SwiftUI.BindingOperations.ForceUnwrapping<UIKitNav.CounterModel.Fact> ▿ _cache: SwiftUI.AtomicBox<SwiftUI.LocationProjectionCache> - buffer: SwiftUI.(unknown context at $1ccbdf8a8).AtomicBuffer<SwiftUI.LocationProjectionCache> #12 ▿ super: Swift.ManagedBuffer<__C.os_unfair_lock_s, SwiftUI.LocationProjectionCache> ▿ header: __C.os_unfair_lock_s - _os_unfair_lock_opaque: 0 - projection: \Fact.value #13 - super: Swift.KeyPath<UIKitNav.CounterModel.Fact, Swift.String> - super: Swift.PartialKeyPath<UIKitNav.CounterModel.Fact> ▿ super: Swift.AnyKeyPath ▿ _kvcKeyPathStringPtr: Optional(0xffffffffffffffff) ▿ some: 0xffffffffffffffff - pointerValue: 18446744073709551615 ▿ _cache: SwiftUI.AtomicBox<SwiftUI.LocationProjectionCache> - buffer: SwiftUI.(unknown context at $1ccbdf8a8).AtomicBuffer<SwiftUI.LocationProjectionCache> #14 ▿ super: Swift.ManagedBuffer<__C.os_unfair_lock_s, SwiftUI.LocationProjectionCache> ▿ header: __C.os_unfair_lock_s - _os_unfair_lock_opaque: 0 - _value: "Fact 1"

32:05

And we can see there is quite a bit on the inside. A couple of things stand out to me:

32:08

The binding is holding onto a reference to the object from which the binding was derived from, in particular our CounterModel : ▿ location: SwiftUI.ConstantLocation<UIKitNav.CounterModel> ▿ value: UIKitNav.CounterModel #4 And it’s held in a property called “location”, which seems to signify where the binding came from.

33:03

In addition to the location there are projection s that seem to describe all of the key paths used to chisel away at the root model object down to the tiny piece of state we are focused on now: - projection: \CounterModel.fact #10 … - projection: \Fact.value #13

33:19

So, clearly SwiftUI’s Binding type is tracking all types of goodies on the inside, but it doesn’t expose any of that to us publicly.

33:27

We can’t really take too much inspiration from this public interface to figure out how our binding should be implemented. However, we can take some inspiration the @Shared property wrapper type we discussed in our last series of episodes. In that series we mentioned over and over again that we like to think of the Shared as Binding from SwiftUI, except its tuned specifically for the Composable Architecture.

33:47

And if you look at Shared ’s public interface you might be led to believe that it probably holds onto some kind of reference on the inside that wraps a value, and that’s it. But if we jump to the source on GitHub we will see that private it holds onto a type erased reference and a type erased key path: And if you look at Shared ’s public interface you might be led to believe that it probably holds onto some kind of reference on the inside that wraps a value, and that’s it. But if we jump to the source on GitHub we will see that private it holds onto a type erased reference and a type erased key path: @dynamicMemberLookup @propertyWrapper public struct Shared<Value> { private let reference: any Reference private let keyPath: AnyKeyPath … }

34:01

The reference represents the base shared value, and the key path represents the small part of that reference that we want to extract when trying to access the wrappedValue of the shared state.

34:20

And when we do things like dynamic member lookup to derived shared state focused on smaller parts, all we have to do is pass along the underlying reference and append key paths: public subscript<Member>( dynamicMember keyPath: WritableKeyPath<Value, Member> ) -> Shared<Member> { Shared<Member>( reference: self.reference, keyPath: self.keyPath.appending(path: keyPath)! ) }

34:43

This is very similar to how bindings work in SwiftUI, and very similar to what we need to do for our own custom binding type. Next time: Binding from scratch

34:50

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

35:05

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

35:19

Let’s give it a shot…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 0285-modern-uikit-pt5 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 .