EP 287 · Modern UIKit · Jul 15, 2024 ·Members

Video #287: Modern UIKit: Stack Navigation, Part 1

smart_display

Loading stream…

Video #287: Modern UIKit: Stack Navigation, Part 1

Episode: Video #287 Date: Jul 15, 2024 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep287-modern-uikit-stack-navigation-part-1

Episode thumbnail

Description

We have now implemented tree-based navigation in UIKit, driven by the Observation framework, but there is another form of navigation to think about: stack-based navigation, where you drive your navigation from a flat collection of states rather than a heavily-nested type. Let’s leverage Observation to build a really nice tool for stack-based navigation.

Video

Cloudflare Stream video ID: b5c6ed2e76f58e20547627c767964e89 Local file: video_287_modern-uikit-stack-navigation-part-1.mp4 *(download with --video 287)*

References

Transcript

0:05

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.

0:25

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

0:45

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.

1:16

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. Stack-based navigation

1:25

First, let’s look at the drill-down navigation we already have in this demo app, and see why it does not constitute “stack-based navigation”. We are currently using a Destination enum with a settings case in order to drive the navigation to a settings screen, and we do this navigation with a drill-down: navigationController? .pushViewController(item: $model.destination.settings) { model in SettingsViewController(model: model) }

1:54

However, even though we are using a navigation controller to perform a drill-down navigation, this is still tree-based navigation because it is driven off of an optional and an enum. When you nest these optionals and enums to represent deeper and deeper forms of navigation they form a tree, and that’s why we like to call this tree-based navigation.

2:12

Stack-based navigation would be where we control the pushing and popping of controllers from a single, flat collection of data. Let’s see what this looks like over in SwiftUI so that we can get some inspiration.

2:24

In SwiftUI stack-based navigation is done via the NavigationStack view: NavigationStack { }

2:38

Any views you construct inside this trailing closure have the ability to push new screens onto the stack by just constructing a NavigationLink : NavigationStack { Form { NavigationLink("SwiftUI counter") { CounterView(model: CounterModel()) } } }

3:06

This shows a button on the screen, and tapping it pushes the CounterView onto the stack.

3:18

However, this form of navigation is fire-and-forget. There is no representation of this navigation event in the state, which means we can never know if something is currently pushed onto the stack and we can’t programmatically push something onto the stack. The only way to push a feature onto the stack is for the user to literally tap a NavigationLink .

4:02

That makes it easy to get started with stack-based navigation, but typically real-world applications need to perform programmatic navigation and perform special logic depending on what is on the stack. And for that reason SwiftUI provides an initializer of NavigationStack that allows you to to specify a binding that controls what is on the stack: NavigationStack(path: <#Binding<_>#>) { }

4:26

This is a binding pointed at some collection of data, and when data is added to the collection the stack will perform a drill-down animation to screens representing that new data, and conversely when data is removed, those screens will be popped off the stack.

4:39

One approach to modeling this binding is to use an array of some enum state that represents all the features that can be pushed onto the stack. For example, currently our little demo app has two features we can go to, the counter feature and the settings feature, and so we can model this like so: import Perception import SwiftUI @Perceptible class AppModel { var path: [Path] = [] enum Path { case counter(CounterModel) case settings(SettingsModel) } }

5:39

And then we can create an AppView that will hold onto a @Bindable app model: struct AppView: View { @Perception.Bindable var model: AppModel }

5:50

And then we would hope we could just derive a binding to the path to hand to NavigationStack : var body: some View { WithPerceptionTracking { NavigationStack(path: $model.path) { } } }

6:07

However this does not work because NavigationStack requires that the collection hold onto Hashable data, and currently Path is not Hashable .

6:15

If we try to make it Hashable : enum Path: Hashable { case counter(CounterModel) case settings(SettingsModel) } Type ‘AppModel.Path’ does not conform to protocol ‘Equatable’ Type ‘AppModel.Path’ does not conform to protocol ‘Hashable’

6:19

…we will find that we also have to make CounterModel and SettingsModel hashable, and really the only reasonable choice for the hashability of these models is to use object identity: func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(self)) } static func == (lhs: SettingsModel, rhs: SettingsModel) -> Bool { lhs === rhs }

6:56

However, our models are @MainActor and these protocol requirements do not have any isolation, and so they cannot satisfy the requirements: Main actor-isolated instance method ‘hash(into:)’ cannot be used to satisfy nonisolated protocol requirement Main actor-isolated operator function ‘==’ cannot be used to satisfy nonisolated protocol requirement

7:06

However, since we are only accessing the object’s identity and not any of its data on the inside, we can mark the function as nonisolated : nonisolated func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(self)) } nonisolated static func == (lhs: SettingsModel, rhs: SettingsModel) -> Bool { lhs === rhs }

7:23

This is compiling, but before we move onto our other model, our SwiftUINavigation library actually provides a tool that does this work for us because this is a super common pattern when interacting with SwiftUI’s navigation APIs that require hashability. It’s a protocol called HashableObject , and it gives a class a Hashable conformance with these implementations as defaults. class CounterModel: HashableObject { … // nonisolated func hash(into hasher: inout Hasher) { // hasher.combine(ObjectIdentifier(self)) // } // nonisolated static func == (lhs: SettingsModel, rhs: SettingsModel) -> Bool { // lhs === rhs // } } … class SettingsModel: HashableObject { … }

8:25

Now everything is compiling, and our NavigationStack is now driven by an array of models.

8:28

We can add some buttons to the root view for push data onto the stack: Form { Button("Counter") { model.path.append(.counter(CounterModel())) } Button("Settings") { model.path.append(.settings(SettingsModel())) } }

9:18

That informs the NavigationStack that data was added, but there’s nothing telling the NavigationStack what to do with the data when it is added. That’s the job of navigationDestination : .navigationDestination(for: AppModel.Path.self) { path in }

9:53

This closure is invoked when a new piece of state is pushed to the collection, and it is our job to transform that state into a view that will then visually be pushed onto the screen. We can simply switch over the path to extract out the payload for each case, and hand it off to the corresponding view: .navigationDestination(for: AppModel.Path.self) { path in switch path { case let .counter(model): CounterView(model: model) case let .settings(model): SettingsView(model: model) } } That’s all it takes… Stack-based UIKit navigation

10:44

…and we have the very basics of stack-based navigation, at least for SwiftUI. We just need to model an enum to represent all the places we can navigate to, hold onto an array of that enum, pass a binding of that array to NavigationStack , and finally describe how to construct a view from each case of the enum. Stephen

11:02

But now let’s see how we can create a similar API, but for UIKit. Ideally it would look quite similar to the SwiftUI version, where we provide some kind of binding to a collection of states, as well as a description of how to map states to controllers so that when a new piece of state is pushed to the collection we can push the corresponding controller to the stack.

11:21

Let’s dig in.

11:24

Let’s start by theorizing an ideal syntax for this new kind of navigation, and then we will make that syntax a reality. Recall that currently the NavigationStack looks like this: NavigationStack(path: $model.path) { Form { Button("Counter") { model.path.append(.counter(CounterModel())) } Button("Settings") { model.path.append(.settings(SettingsModel())) } } .navigationDestination(for: AppModel.Path.self) { path in switch path { case let .counter(model): CounterView(model: model) case let .settings(model): SettingsView(model: model) } } }

11:33

It takes a binding, then a trailing closure for the view that is at the root of the navigation, and then inside that trailing closure we use navigationDestination to describe the views that are used for each piece of state pushed to the stack.

11:45

And so maybe we can create a NavigationStackController class that takes a path binding, a trailing closure for the root view controller, as well as a trailing closure for transforming state into a view controller: let stack = NavigationStackController(path: $model.path) { RootViewController() } stack.navigationDestination(for: AppModel.Path.self) { path in switch path { // Transform path into a UIViewController } }

12:23

This two-step process from SwiftUI is the most flexible in decoupling routing a destination from the creation of the navigation stack, and so it would allow us to support something like a UINavigationPath . However, since we’re dealing with a single, concrete Path type, let’s collapse this work down into a single step to keep things simple: let stack = NavigationStackController(path: $model.path) { RootViewController() } destination: { path in switch path { // Transform path into a UIViewController } }

12:48

It is worth mentioning that this syntax is a bit of a departure from UIKit’s UINavigationController . In that API there is just a single viewControllers property that represents the full stack of screens. There is no separately called out “root” controller. You can even provide an empty array if you want. It’ll just be a blank screen, and we’re not sure why you would ever want to do that, but it is technically possible.

13:09

However, that API is a bit confusing, and since we are already taking a lot of inspiration from SwiftUI we think it’d be better to stay true to that API, where the root controller is separately called out.

13:18

Let’s get the basic scaffolding of such a controller into place: import UIKit open class NavigationStackController: UINavigationController { }

13:35

Note that we are making this an open class because often people do need to subclass their navigation controllers, and so we would want to support that too.

13:46

We will need to hold onto a binding in this class, but the question is, a binding of what? open class NavigationStackController: UINavigationController { @UIBinding var path: <#???#> }

13:54

In NavigationStack they use a generic to represent the underlying data: @MainActor public struct NavigationStack<Data, Root: View>: View { … }

14:08

…and it is fully generic. There are no constraints whatsoever.

14:11

They do this because they have two major use cases to support: a statically typed collection of values, and a type erased NavigationPath .

14:18

We aren’t going to worry about the type erased NavigationPath for now, and so we will introduce a generic that is upfront constrained to the Collection protocol: open class NavigationStackController< Data: Collection >: UINavigationController { @UIBinding var path: Data init(path: UIBinding<Data>) { self._path = path super.init(nibName: nil, bundle: nil) } public required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } }

14:47

We will need even more constraints, but let’s see how far we can go with just the Collection protocol.

14:54

We also need to provide this controller a closure for the root controller, as well as a closure for transforming data added to the collection into controllers that are pushed onto the stack: let root: UIViewController let destination: (Data.Element) -> UIViewController 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) }

15:35

And that is basically everything we need for the public interface of this controller. Now it’s time to start implementing some of its internals. We need to observe changes in the binding so that we can figure out when it is appropriate to push or pop controllers off the stack. And the most appropriate place to do this is in the viewDidLoad so that we can subscribe to observations just a single time: open override func viewDidLoad() { super.viewDidLoad() }

15:54

Then we can use our observe helper to start observing changes in the binding: observe { [weak self] in guard let self else { return } _ = path }

16:07

Just with that we will be immediately notified when data is pushed or popped from the collection. We need to analyze that change to figure out if we need to push or pop controllers from the stack.

16:16

That seems to imply that maybe we need to keep around the previously seen path so that we can compare it to the current path and figure out what changed: var previousPath = path observe { defer { previousPath = path } _ = path // Analyze path and previousPath to see what state was // added/removed/reordered }

16:22

But we can actually accomplish this in a much simpler manner.

16:25

The path binding is the “source of truth” of the path, and we just want to make sure to keep the viewControllers inside the navigation controller in sync with it. So the viewControllers property already represents the “previous” path, and so we can just compare with it directly.

16:38

We can start by iterating over the elements in the path right now: for element in path { }

16:43

And then we can hand this path element to the destination closure to construct the view controller that should be pushed onto the stack: for element in path { let controller = destination(element) }

16:52

However, it is not going to always be appropriate to push a new controller onto the stack. Instead we need to figure out, somehow, if there is already a controller on the stack corresponding to this element, and if so do not recreate it. But how can we know what view controller corresponds to what piece of data?

17:07

Well, this is a another good use case for associated objects from Objective-C. We can save a little piece of identifying data on controllers when we push them on the stack, and then use that to figure out which elements of the path correspond to which elements in the navigation stack.

17:20

So, let’s create a new private property on all view controllers that holds onto some Hashable data that can be used to identify the controller: extension UIViewController { fileprivate var navigationID: AnyHashable? { get { objc_getAssociatedObject(self, navigationIDKey) as? AnyHashable } set { objc_setAssociatedObject( self, navigationIDKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC ) } } } private let navigationIDKey = malloc(1)!

17:34

And then after constructing a new view controller to push onto the stack we will give it its navigationID , and the element serves as the best ID: let controller = self.destination(element) controller.navigationID = AnyHashable(element) Cannot assign value of type ‘Data.Element’ to type ‘AnyHashable?’

17:43

However, in order for this to work the element must be Hashable , and so we will constrain the Element associated type of the Data generic to be Hashable : open class NavigationStackController< Data: Collection >: UINavigationController where Data.Element: Hashable { … }

17:49

And this is the exact same kind of constrain NavigationStack has too, so this isn’t too surprising.

17:55

This is now compiling, and it also means that we can now search the current set of view controllers to see if there is already one in the stack corresponding to the element: let existingController = viewControllers.first( where: { $0.navigationID == AnyHashable(element) } )

18:21

We now all have the pieces of the puzzle, we just need to fit them together properly.

18:24

The way we are going to update the set of controllers in the stack is by using the setViewControllers method: setViewControllers( <#[UIViewController]#>, animated: <#Bool#> ) This allows you to completely update the stack, and the navigation controller will even take care of the hard work of figuring out what controllers are added and removed from the stack so that it can perform an animation as needed.

18:33

And the for loop we have is a great start to figuring out the new set of controllers, but we can streamline it by turning it into a map: setViewControllers( path.map { element in }, animated: true )

18:39

And then we can early out when we find an existing view controller, and otherwise we can construct a new one and assign its navigation ID: setViewControllers( path.map { element in guard let existingController = self.viewControllers.first( where: { $0.navigationID == AnyHashable(element) } ) else { let controller = self.destination(element) controller.navigationID = AnyHashable(element) return controller } return existingController }, animated: true )

19:01

This is close, but we need to pay careful attention to the fact that the first element of the navigation stack is the root controller specified in the initializer: setViewControllers( [root] + path.map { element in … } )

19:15

Believe it or not, that is basically all there is to it. Using NavigationStackController

19:18

We now have the basics of a UIKit-friendly NavigationStackController . It is 100% state driven via a binding, and it listens for changes to the state in the binding in order to figure out when to push or pop controllers off the stack. Brandon

19:30

But we aren’t yet actually using this new controller. Let’s give it a spin by allowing ourselves to push both counter and settings features onto the stack.

19:40

Currently at the entry point of our application is the SwiftUI AppView : WindowGroup { AppView(model: AppModel()) }

19:45

SwiftUI makes it very convenient to describe, in this single package: the navigation stack, its root controller, and all the places you can navigate to.

19:57

This is a little more awkward to do in UIKit, but we can make it as nice as possible. It’d be nice if we could simply construct a navigation stack controller: WindowGroup { UIViewControllerRepresenting { NavigationStackController( path: <#UIBinding<_>#>, root: <#() -> UIViewController#>, destination: <#(_) -> UIViewController#> ) } }

20:18

It was nice in SwiftUI how we could hide away this data that comes from the model, so maybe we can do similarly with a custom initializer that takes a model: WindowGroup { UIViewControllerRepresenting { NavigationStackController( model: AppModel() ) } }

20:36

So we’ll extend the controller: extension NavigationStackController where Data == [AppModel.Path] { convenience init(model: AppModel) { } }

20:55

And then we’ll call out to the base initializer to provide all the details it cares about: extension NavigationStackController where Data == [AppModel.Path] { convenience init(model: AppModel) { self.init( path: <#UIBinding<_>#>, root: <#() -> UIViewController#>, destination: <#(_) -> UIViewController#> ) } }

21:06

To derive a UIBinding we’ll need to make a bindable version of our model, and then we can pass a binding to its path along: convenience init(model: AppModel) { @Bindable var model = model self.init(path: $model.path) { destination: { path in } }

21:16

For the first trailing closure we need to provide a controller. We unfortunately can’t just do this right in line like we could for SwiftUI. Instead we need to define a whole new view controller with a couple of buttons for navigating: final class RootViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() let counterButton = UIButton( type: .system, primaryAction: UIAction { _ in } ) counterButton.setTitle("Counter", for: .normal) let settingsButton = UIButton( type: .system, primaryAction: UIAction { _ in } ) settingsButton.setTitle("Settings", for: .normal) let stack = UIStackView(arrangedSubviews: [ counterButton, settingsButton, ]) stack.axis = .vertical stack.translatesAutoresizingMaskIntoConstraints = false view.addSubview(stack) NSLayoutConstraint.activate([ stack.centerXAnchor.constraint(equalTo: view.centerXAnchor), stack.centerYAnchor.constraint(equalTo: view.centerYAnchor), ]) } }

21:48

And we can construct a RootViewController in the navigation stack controller: RootViewController()

21:54

We can easily provide the destination closure by switching over the path to grab the model in each case, and then constructing the corresponding view controller: } destination: { path in switch path { case let .counter(model): CounterViewController(model: model) case let .settings(model): SettingsViewController(model: model) } }

22:45

And amazing, this is compiling and the syntax is looking very similar to SwiftUI.

23:04

But now it’s not very clear what to do in the root view controller’s button closures. We somehow need to append new data to the path collection that exists all the way back in the AppModel , but we don’t have access to the AppModel in this controller.

23:20

Well, we can approach this problem exactly as we would in SwiftUI. In SwiftUI when one wants to facilitate communication between two features using state mutation, one turns to bindings. This allows a parent feature and child feature to share a piece of state so that when one makes a mutation to the state the other immediate sees those changes.

23:39

And amazingly our UIBinding type allows us to bring this pattern to UIKit. We can add a @UIBinding to the RootViewController that holds onto the current path pushed onto the stack: final class RootViewController: UIViewController { @UIBinding var path: [AppModel.Path] init(path: UIBinding<[AppModel.Path]>) { self._path = path super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } … }

23:55

And then in the button actions we can simply append to this path: let counterButton = UIButton( type: .system, primaryAction: UIAction { [weak self] _ in self?.path.append(.counter(CounterModel())) } ) … let settingsButton = UIButton( type: .system, primaryAction: UIAction { [weak self] _ in self?.path.append(.settings(SettingsModel())) } )

24:39

And now we just need to pass a path binding along when constructing the view controller. RootViewController(path: $model.path)

25:10

And amazing, this works perfectly. We can now facilitate state sharing and communication between UIKit features using @UIBinding , just as one would do with Binding in SwiftUI.

26:09

One of the amazing superpowers of navigation stacks in SwiftUI is just how easy it is to deep link, and we have those superpowers here. We just need to provide a default path to the app model: NavigationStackController( model: AppModel( path: [ .counter(CounterModel()), .settings(SettingsModel()), .counter(CounterModel()), ] ) )

26:50

And in the simulator we are instantly drilled into the counter feature, and we can pop to settings, pop to another counter, before finally popping to the root. Next time: Trait system navigation

27:17

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.

27:31

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

27:50

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.

27:58

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

28:02

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.

28:20

Let’s take a look…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 0287-modern-uikit-pt7 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 .