EP 288 · Modern UIKit · Jul 22, 2024 ·Members

Video #288: Modern UIKit: Stack Navigation, Part 2

smart_display

Loading stream…

Video #288: Modern UIKit: Stack Navigation, Part 2

Episode: Video #288 Date: Jul 22, 2024 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep288-modern-uikit-stack-navigation-part-2

Episode thumbnail

Description

We round out our stack navigation tools with support for an @Environment-like feature for holding onto the stack’s path, a NavigationLink-like feature for pushing features onto the stack from anywhere, and we’ll handle every corner case from deep-linking to user dismissal.

Video

Cloudflare Stream video ID: e4d6398015eb7bad4bb8f18e34dd2bf5 Local file: video_288_modern-uikit-stack-navigation-part-2.mp4 *(download with --video 288)*

References

Transcript

0:05

Now, it’s pretty cool we can use bindings like this, but also we probably wouldn’t want follow this pattern literally in a real world code base. We now need to pass this binding through every layer of our application so that child features can perform navigation.

0:19

When confronted with this kind of problem in SwiftUI there are two common approaches. One is to put the path binding in the environment so that every view has immediate access to it, and the other is to provide tools to the view that allow it to push values to the path without actually having access to the path. Stephen

0:39

We are going to discuss both of these approaches, but we will start with the first one, where we put the path binding in the environment.

0:47

Of course, you are probably thinking how does one use the SwiftUI environment in UIKit?

0:51

Well, you don’t really. Instead, UIKit has its own version of the environment, but its called “traits”, and traits even bridge to SwiftUI’s environment if you want. Traits make it possible to set values inside a view or controller hierarchy, and each layer of the hierarchy will get access to the values and even be able to override the values.

1:09

Let’s take a look. Trait system navigation

1:12

Registering a custom trait looks much like registering an environment value. You start by defining a new type that conforms to the UITraitDefinition protocol, rather than the EnvironmentKey protocol, and we can even keep this type private to the file: private enum PathTrait: UITraitDefinition { }

1:31

There is one requirement of this protocol called defaultValue , which is the value that will be used if the value is not overridden: private enum PathTrait: UITraitDefinition { static var defaultValue }

1:35

This is quite similar to EnvironmentKey too.

1:41

The type of our trait will be a UIBinding of an array of AppModel.Path s: private enum PathTrait: UITraitDefinition { static var defaultValue: UIBinding<[AppModel.Path]> { } }

1:50

Now ideally we would use a constant binding in here just as SwiftUI’s Binding has a constant helper: private enum PathTrait: UITraitDefinition { static var defaultValue: UIBinding<[AppModel.Path]> { .constant([]) } }

1:55

Unfortunately we do not have this helper, and it would take some time to implement it, so we are just going to put a placeholder value in: private enum PathTrait: UITraitDefinition { static var defaultValue: UIBinding<[AppModel.Path]> { @UIBindable var model = AppModel() return $model.path } }

2:14

Of course this value is totally detached from the actual model that lives at the root application, so this is not correct to do, but it’ll give us some momentum to get to the next step. Just know that this is not the correct way to do this in the long run.

2:24

Next we will extend UITraitCollection to provide a computed property that gives us access to this path trait: extension UITraitCollection { var path: UIBinding<[AppModel.Path]> { self[PathTrait.self] } }

2:44

However this API is only available in iOS 17 and so we will need to make it as available from such: extension UITraitCollection { @available(iOS 17.0, *) var path: UIBinding<[AppModel.Path]> { self[PathTrait.self] } }

2:48

And then because we want this trait to be overridable we also need to extend UIMutableTraits with a computed property that has a get and set : @available(iOS 17.0, *) extension UIMutableTraits { var path: UIBinding<[AppModel.Path]> { get { self[PathTrait.self] } set { self[PathTrait.self] = newValue } } }

3:09

And further, we can even mark this property as having a fileprivate(set) because people should not be able to override this property from the outside: fileprivate(set) var path: UIBinding<[AppModel.Path]> { … }

3:21

Now after creating our NavigationStackController we can override the path trait to put the real life path binding in the trait system: if #available(iOS 17.0, *) { self.traitOverrides.path = $model.path } else { // Fallback on earlier versions }

3:37

And then in the button action we can now mutate the binding in the trait system rather than the binding handed to the RootViewController directly: let counterButton = UIButton( type: .system, primaryAction: UIAction { [weak self] _ in if #available(iOS 17.0, *) { self?.traitCollection.path.wrappedValue.append( .counter(CounterModel()) ) } else { // Fallback on earlier versions } } )

3:58

That should be enough for this to work. Now in a real application you’d probably only do this if you were targeting iOS 17 or greater, but we just want to show how it works in such a case.

4:13

We can do the same for the settings button.

4:24

In fact, now we can get rid of the path binding from the RootViewController entirely. -RootViewController(path: $model.path) +RootViewController()

4:43

And now the app works exactly as it did before, but now child features can push to the stack without us needing to explicitly pass the path binding through every layer. Pushing path values from anywhere

4:57

So this is pretty cool. We now see that there is something that emulates SwiftUI’s environment in UIKit, and we can use it to propagate the path binding that is driving navigation to any view or controller deep in the hierarchy. Brandon

5:09

But even this code probably isn’t exactly how most people would want to write their apps. It’s a bit of a pain to manage that trait value for each navigation stack you have. Instead it would be better if you could just push directly onto the stack without having access to the path or even thinking about the path.

5:30

In fact, SwiftUI provides this tool, so perhaps we can create a version of it.

5:37

The tool SwiftUI provides is an initializer on NavigationLink that takes a value instead of a destination view: NavigationLink( "Counter w/ value", value: AppModel.Path.counter(CounterModel()) )

6:34

When this link is tapped SwiftUI will travel up the view hierarchy to find the binding that is controlling navigation, and then will attempt to append the value to the path.

6:53

I say “attempt” because there are a few things that could go wrong in this process. First of all, there may not even be a NavigationStack in the view hierarchy that is using a path binding. In such a case this can’t possibly work. And even if a path binding is found, the type of data it holds may not match the type of value used with NavigationLink : NavigationLink( "Counter w/ value", value: 1 )

7:00

Again, in such a case this can’t work, and typically SwiftUI emits some runtime warnings to let the user know that something isn’t right. A NavigationLink is presenting a value of type “Int” but there is no matching navigationDestination declaration visible from the location of the link. The link cannot be activated. Note: Links search for destinations in any surrounding NavigationStack, then within the same column of a NavigationSplitView.

7:20

So, how do we want this to look in UIKit?

7:24

Well, we don’t want to literally create a view that serves the purpose of NavigationLink because that will lock people into our choices for the look of the button, or we will need to be mindful to make the button customizable and extensible. Instead we will provide a method that can be invoked from an action closure for pushing a value onto the path: let counterButton = UIButton( type: .system, primaryAction: UIAction { [weak self] _ in self?.navigationController?.push( value: AppModel.Path.counter(CounterModel()) ) } ) … let settingsButton = UIButton( type: .system, primaryAction: UIAction { [weak self] _ in self?.navigationController?.push( value: AppModel.Path.settings(SettingsModel()) ) } )

8:24

This method will do the work of finding the UINavigationController powering navigation, and then will determine if it’s possible to push the value provided onto the stack.

8:50

We can start by getting a stub of the interface into place: extension UINavigationController { public func push<Element>(value: Element) { } }

9:13

And in fact we will need this Element generic to conform to Hashable since our path can only hold onto hashable elements: extension UINavigationController { public func push<Element: Hashable>(value: Element) { } } Currently we are extending UINavigationController to add this functionality, because that is what people will have access to in their features. But that means at this point we can’t even access the path binding because we don’t even know if we are a NavigationStackController .

9:32

So, we’d like to try casting ourselves to NavigationStackController to get access to some of the internals: let navStack = self as? NavigationStackController

9:47

…but this doesn’t work due to the Data generic on the controller.

9:54

In order to perform the cast we need to have a protocol that the NavigationStackController conforms to: fileprivate protocol _NavigationStackController { } extension NavigationStackController: _NavigationStackController {}

10:19

And now we are able to cast ourselves to a _NavigationStackController : public func push<Element: Hashable>(value: Element) { let navStack = self as? any _NavigationStackController }

10:31

Further, this protocol needs a primary associated type because we ultimately need to discern the type of underlying data powering the binding so that we can append to it: fileprivate protocol _NavigationStackController<Data> { associatedtype Data }

10:57

And now when we know we have a _NavigationStackController existential, we’d like to upgrade that to a static type so that we can get access to the associated type.

11:05

The way one does this is to define a generic function that uses generics to get at the underlying static type of an existential: public func push<Element: Hashable>(value: Element) { func open<Data>( _ controller: some _NavigationStackController<Data> ) { } guard let navStack = self as? any _NavigationStackController else { // TODO: runtime warn return } open(navStack) } …and if the cast fails we should probably runtime warn since it means the user is trying to use the push(value:) API without using a NavigationStackController .

12:05

Now inside the open function we know precisely what kind of data is driving the navigation in the stack. However, currently Data is fully unconstrained. We don’t know anything about it, not even if it’s a collection or not.

12:13

But we know that our NavigationStackController class requires Data to at least be a Collection , so let’s add that constraint to the protocol: associatedtype Data where Data: Collection

12:24

And now in the open function we know that Data is a collection, but it could be any kind of collection. Its element may not even match the kind of element we are trying to push.

12:40

So we need to make sure that the elements match up: guard Element.self == Data.Element.self else { // TODO: runtime warn return } …and if they don’t we should also runtime warn to let the user know that what they are doing is incorrect. This is very similar to one of the runtime warnings SwiftUI emits when using NavigationLink(value:) incorrectly.

13:15

And if we get past this guard we know that the elements match up, which means we should be able to push to the controller’s path: controller.path.append(value)

13:40

But there are a few things wrong with this.

13:44

First, controller is not a true NavigationStackController but rather just a conformance to the _NavigationStackController protocol. This means we only have access to what the protocol exposes, so we should add some requirements, such as the path: var path: Data { get set }

14:04

Now we have access to the path , but we can’t append to it because Data is only a mere collection. We need it to be a RangeReplaceableCollection in order to append to it: associatedtype Data where Data: Collection, Data: RangeReplaceableCollection

14:17

And we need to add that constraint to NavigationStackController : open class NavigationStackController< Data: RangeReplaceableCollection >: UINavigationController where Data.Element: Hashable { … }

14:21

And that brings up some warnings about NavigationStackController , which is @MainActor and so isolated, satisfying non-isolated protocol requirements. Main actor-isolated property ‘path’ cannot be used to satisfy nonisolated protocol requirement

14:24

Well, since we only expect NavigationStackController to conform to this protocol we mark it as @MainActor : @MainActor protocol _NavigationStackController<Data> { … }

14:28

That fixes the warning, but the append still is not compiling.

14:32

This is happening because although we have checked that two element types match, the compiler does not know this. We can force the compiler to trust us by performing a force cast: controller.path.append(value as! Data.Element)

14:52

And now we have an error about mutating something that is immutable, but we know that controller is really a class, and so if we add that constraint to the _NavigationStackController protocol: protocol _NavigationStackController<Data>: AnyObject { … }

15:08

…we will have fixed all compilation errors.

15:12

And we have now simplified navigation by just reaching out to the underlying navigationController on self and asking it to push one of the Path cases: let counterButton = UIButton( type: .system, primaryAction: UIAction { [weak self] _ in self?.navigationController?.push( value: AppModel.Path.counter(CounterModel()) ) } ) counterButton.setTitle("Counter", for: .normal) let settingsButton = UIButton( type: .system, primaryAction: UIAction { [weak self] _ in self?.navigationController?.push( value: AppModel.Path.settings(SettingsModel()) ) } )

15:22

There’s no need to pass around bindings explicitly or implicitly via the trait system. It’s very simple, and with a bit more work we could even support a type erased version of NavigationPath .

15:26

And we have now created a very powerful tool for modeling stack-based navigation in UIKit, and we can even use the exact same model for SwiftUI as UIKit. And the wonderful thing about having the proper tools for modeling your domains correctly and then driving all navigation from state is that you instantly unlock dead simple deep linking.

15:55

When navigation is fully driven by state all deep linking is is the act of constructing the piece of state that represents where you want to be deep-linked to, handing it off to UIKit or SwiftUI, and letting those frameworks take care of the rest.

16:07

For example, we can launch our SwiftUI app in a very specific state where a counter feature, settings feature, and another counter feature are pushed onto the stack, and a fact sheet is presented: AppView( model: AppModel( path: [ .counter(CounterModel()), .settings(SettingsModel()), .counter( CounterModel( destination: .fact( CounterModel.Fact(value: "0 is a good number") ) ) ) ] ) )

17:27

Now when we launch the app we are immediately brought to a fact sheet, which we can dismiss to see that we are on the counter feature, and then can navigate back to the settings screen, another counter screen, and finally navigate again to the root view.

17:39

And wouldn’t it be amazing if we could do the exact same in our UIKit app? If we pass the same model in: NavigationStackController( model: AppModel( path: [ .settings(SettingsModel()), .counter( CounterModel( destination: .fact(CounterModel.Fact(fact: "0 is a good number")) ) ) ] ) )

18:06

And when we run this, well sadly we are not deep-linked into the sheet. If we check the logs we will see a warning: Attempt to present <ModernUIKit.FactViewController: 0x10700ac50> on <_TtGC11ModernUIKit25NavigationStackControllerGSaOCS_8AppModel4Path__: 0x108021400> (from <ModernUIKit.CounterViewController: 0x107510d80> ) whose view is not in the window hierarchy.

18:30

This is because in the counter view controller’s viewDidLoad we are immediately observing when we should present the fact sheet, and since it is present on launch it tries to present it immediately, before the view is even on screen.

18:57

So we need to delay this presentation slightly, and there is a good place to do that, in the present helper we can spin up a task that will do a thread hop to give the view hierarchy enough time to get into place: Task { self.present(controller, animated: true) }

19:18

And now when we run things we are instantly deep-linked to the UIKit version of our fact sheet, and if we dismiss, we’re in a UIKit counter, which pops to settings, another counter, and finally the root, and there are no warnings in the logs, everything just worked! User dismissal

19:50

We now have a powerful tool for modeling navigation with collections of state, and the API for using this navigation in a UIKit controller looks remarkably similar to the NavigationStack API in SwiftUI.

20:03

But there is one final detail we need to take care of in our NavigationStackController . Currently we are handling the pushing of features onto the stack correctly. You can either do it programmatically, by literally appending a piece of state to the path that is driving navigation, or you can use the push(value:) API, which will go spelunking through the view controller hierarchy to find the binding that is controlling the navigation and append a value to it. Stephen

20:28

However, we have a blind spot when it comes to dismissal. It is possible for the user to manually dismiss a feature from the stack by tapping the “Back” button or swiping from the edge of the screen. We are not currently listening for those kinds of events, which means it will be possible for the state of the app to become inconsistent with respect to what is actually showing on the screen.

20:47

Let’s plug this last hole in NavigationStackController ’s implementation so that we will have an airtight navigation tool.

20:55

First let’s see the problem. If we launch the app, drill down to the counter, go back to the root, and then drill down to the counter, we will see that UIKit thinks we actually have two counter features on the stack. And we can go back and forth a few more times to see that more and more counter features are staying on the stack.

21:23

This is happening because we aren’t cleaning up the state when the user manually dismisses the counter feature. Let’s also see this in the state of the model. We can put a print inside the didSet of the AppModel ’s path : var path: [Path] = [] { didSet { print(path) } } …so that we can see exactly what the path is when it changes.

21:35

If we launch the app and perform some back-and-forths we will see that data is only ever appended to the collection. Data is never removed.

21:51

So clearly this isn’t right, but how can we be notified when the user decides to navigate away from a feature? Well, UINavigationController s have a delegate, and one of the methods on that protocol is specifically for letting you know when a controller is about to be presented or has finished presenting: Responding to a view controller being shown func navigationController( UINavigationController, willShow: UIViewController, animated: Bool ) Notifies the delegate before the navigation controller displays a view controller’s view and navigation item properties. func navigationController( UINavigationController, didShow: UIViewController, animated: Bool ) Notifies the delegate after the navigation controller displays a view controller’s view and navigation item properties.

22:18

And these methods are always called, regardless if it’s a programmatic presentation/dismissal, or if the user dismissed.

22:24

So, we need to tap into this delegate method and analyze the current stack of view controllers versus our collection of state, and determine if additional steps should be taken. First, let’s conform NavigationStackController to the UINavigationControllerDelegate protocol: open class NavigationStackController< Data: RangeReplaceableCollection >: UINavigationController, UINavigationControllerDelegate

22:43

And let’s set up our self to be the delegate: init( path: UIBinding<Data>, root: () -> UIViewController, destination: @escaping (Data.Element) -> UIViewController ) { self._path = path self.root = root() self.destination = destination super.init(nibName: nil, bundle: nil) self.delegate = self }

22:48

This does unfortunately mean that people from the outside can’t set themselves to be the delegate, but there are ways to allow for that with a little bit of indirection. We just don’t need to do that right now.

22:58

And we are now free to implement any of the delegate methods. Which should we choose? There’s the one that notifies us when a controller is about to be presented, and then one that notifies us when the controller has been fully presented.

23:08

We can actually answer this question by looking at what SwiftUI does. If we run the SwiftUI version of the app, perform a drill-down, and then go back, we will see that the state is cleaned up when the child controller is fully popped off the stack. That means we should use the didShow version of the delegate method: public func navigationController( _ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool ) { }

23:41

In here we need to detect if the user performed this navigation event, and if so clean up the state. How can we do that?

23:48

Well, by the time this method is called, if the viewControllers array in the navigation controller has more elements than our path binding, then that means we need to clean up our state: if path.count > viewControllers.count { }

24:01

Specifically we need to make it so that the path has the same number of elements as viewControllers . But which elements do we remove?

24:09

Well here we can make a big simplifying assumption. Navigation controllers in UIKit, and SwiftUI for that matter, only allow the user to pop the top most controllers off the stack. They can pop any number they want, but it’s always at the tail end. It’s not possible for the user to remove the first controller from the stack. Such an operation doesn’t really make sense.

24:26

So the only elements we need to remove are from the end of the path collection: if path.count > viewControllers.count { path.removeLast(path.count - viewControllers.count) }

24:39

But there are 2 things wrong with this.

24:41

First, our path does not even have a removeLast method because it is only a RangeReplaceableCollection . We further need to conform it to the RandomAccessCollection protocol to get access to that functionality: open class NavigationStackController< Data: RangeReplaceableCollection & RandomAccessCollection >: UINavigationController, UINavigationControllerDelegate where Data.Element: Hashable { … }

24:56

Now things are compiling, but it still is not quite right.

25:01

Remember that we have designated a root controller to be the very first element of the viewControllers array. That means we have to do some adjustments with our count computation. We need to subtract 1 from viewControllers.count to account for the fact that the first controller is not represented by the path collection: if path.count > (viewControllers.count - 1) { path.removeLast(path.count - (viewControllers.count - 1)) }

25:21

And probably better to simplify this code a bit: let diff = path.count - (viewControllers.count - 1) if diff > 0 { path.removeLast(diff) }

25:34

And that is all it takes. We are now properly keeping our path binding in sync with the actual view controllers popped off the stack.

25:41

When we run things, everything works perfectly now!

26:07

One interesting thing worth mentioning is that the NavigationStack view in SwiftUI strangely requires that its Data generic be a MutableCollection : @MainActor init( path: Binding<Data>, @ViewBuilder root: () -> Root ) where Data: MutableCollection, Data: RandomAccessCollection, Data: RangeReplaceableCollection, Data.Element: Hashable

26:31

We have seemingly implemented everything that NavigationStack does in UIKit, but never needed the powers of a MutableCollection .

26:37

And in fact it turns out this is actually a mistake in SwiftUI. We have confirmed with someone at Apple that NavigationStack does not actually need MutableCollection and it was a mistake to include it. It’ll probably never be fixed at this point, but it is cool to see just how each of these collection conformances are used under the hood. Next time: UIControl bindings

26:53

Things just keep getting better and better.

26:55

We now have all of the tools necessary for modeling complex domains and navigation patterns in UIKit apps. If a feature has a well-defined, finite set of possible destinations, then we can model that situation with an enum. Or if we need to have a potentially unbounded combination of features on a stack, then we can model that situation with an array. And thanks to the observation tools in Swift we can build navigation APIs for UIKit that look shocking similar to the corresponding tools in SwiftUI. Brandon

27:22

But we are still not done.

27:24

Would you believe that there is still more we can squeeze out of the observation tools when it comes to UIKit? So far we have used the observe function to accomplish two main things: Brandon

27:33

First, we can update our UI components from the data in our observable model in a nice, concise way. We don’t have to explicitly subscribe to state, and instead can just freely access fields on the model and that will automatically subscribe us. And any fields we do not touch will not cause the view to be updated when they are mutated. Stephen

27:52

And second we can create navigation APIs that mimic what we have in SwiftUI. We can drive sheets, covers, popovers and alerts from optional and enum state, and we can drive drill-down navigation from flat arrays of data. Brandon

28:05

But there is another very common task one needs to do in UI applications that we haven’t yet broached, and that is UI components that require 2-way bindings, such as text fields, toggles, sliders and more. Let’s see what it takes to use one of those components in our application, see why it’s a bit of a pain, and then see what we can do to fix it…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 0288-modern-uikit-pt8 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 .