EP 284 · Modern UIKit · Jun 24, 2024 ·Members

Video #284: Modern UIKit: Basics of Navigation

smart_display

Loading stream…

Video #284: Modern UIKit: Basics of Navigation

Episode: Video #284 Date: Jun 24, 2024 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep284-modern-uikit-basics-of-navigation

Episode thumbnail

Description

Now that we have a tool that brings the power of the Observation framework to UIKit, let’s put it through the paces. We will use it to build state-driven navigation tools that can drive alerts, sheets, popovers, drill-downs, and more, and they will look a lot like SwiftUI’s navigation tools.

Video

Cloudflare Stream video ID: f54447d59bd5888bfb5e2542789644ba Local file: video_284_modern-uikit-basics-of-navigation.mp4 *(download with --video 284)*

References

Transcript

0:05

So this is pretty incredible. Not only do we have an amazing observation tool for UIKit, but it also works on basically any version of iOS released in the past 5 years. And it didn’t take much work for us to accomplish this. It’s all thanks to Swift’s observation tools and our back-port of the those tools. Stephen

0:21

But now it’s time to push these tools even harder, because so far our app is very simple. We are just setting up some labels and conditionally showing and hiding certain components. Things start getting really interesting when we think about navigation. And as we’ve all learned from SwiftUI, it is great to drive navigation from state rather than it being a “fire-and-forget” occurrence.

0:41

Driving navigation from state allows your features to inspect the exact state of your app at any moment, makes it easy to deep-link into any screen of your application, and also makes it easy to write tests that show how multiple features integrate together.

0:55

What does it take to perform state-driven navigation in UIKit? Well, not only is possible, but it’s even pretty easy to accomplish, and it’s really incredible to see. We really can build complex navigation flows in a UIKit application in a very similar manner that we do for SwiftUI.

1:12

Let’s take a look. State-driven navigation

1:15

Before showing how to present an alert in UIKit let’s first remind ourselves what it looks like in SwiftUI. Currently we are showing a loaded fact by simply doing an if let right in the view: if let fact = model.fact { Text(fact) } else if model.factIsLoading { ProgressView().id(UUID()) }

1:24

Let’s get rid of that and only have the loading indicator logic: // if let fact = model.fact { // Text(fact) // } else if model.factIsLoading { ProgressView().id(UUID()) }

1:27

And then we will want to make use of SwiftUI’s alert view modifier: .alert(<#⎋#>)

1:31

The only question is, which one? There are quite a few. In fact, there are 6 non-deprecated overloads of alert . But in reality there are 3 main variations: .alert( <#LocalizedStringKey#>, isPresented: <#Binding<Bool>#>, actions: <#() -> View#> ) .alert( isPresented: <#Binding<Bool>#>, error: <#LocalizedError?#>, actions: <#() -> View#> ) .alert( <#LocalizedStringKey#>, isPresented: <#Binding<Bool>#>, presenting: <#T?#>, actions: <#(T) -> View#> )

1:38

Then there’s one that takes the boolean binding, but also takes an optional error which is used to populate the title of the alert. And then finally there’s one that takes a boolean binding and a piece of optional state that gets unwrapped and passed to other trailing closures for customizing the presentation of the alert.

1:49

This is a very strange set of APIs, and deviates from the simpler navigation APIs such as sheet , popover , fullScreenCover and navigationDestination . There are quite a few problems with these APIs.

1:58

First, they prevent you from modeling your domain correctly. You need to use a boolean piece of state and an optional piece of state to use the API. That leaves you open to the possibility of the boolean being true , meaning the alert should display, yet the optional state can be nil , meaning the alert should not be displayed.

2:15

And while you can customize the message and actions of the alert from the unwrapped state, you are not given that affordance for the title for some reason. And so if your alert needs a dynamic title based on some optional state you have to do extra work.

2:27

We’re not sure why these APIs look the way they do, but they are problematic enough that we provide a fix for the situation in our popular SwiftUINavigation library. It’s a small library that provides a lot of navigation tools for SwiftUI that we hope would be available right out of the box, but sadly are not.

2:42

Let’s import the library: import SwiftUINavigation

2:47

…and add a dependency to the library in our project.

2:54

And now we have access to an alert modifier that takes a single optional binding to drive the presentation of the alert, and then further the title, actions and message are all customizable from the unwrapped item: .alert( item: <#Binding<Item?>#>, title: <#(Item) -> Text#>, actions: <#(Item) -> View#>, message: <#(Item) -> View#> )

3:08

To derive a binding from our model we have to mark our model as being @Bindable : @Bindable var model: CounterModel

3:16

Oh, but @Bindable is an iOS 17 API, and so we need to use the back-ported API: @Perception.Bindable var model: CounterModel

3:25

And we can use this API with our model like this: .alert(item: $model.fact) { fact in Text(model.count.description) } actions: { fact in } message: { fact in Text(fact) }

3:53

Very easy, and now when we run the preview we see it works as we expect.

4:02

So, alerts can be quite easy in SwiftUI if you have the right tools, but now let’s take a look at UIKit.

4:06

Currently in the CounterViewController we are showing a loaded fact by simply if let unwrapping the optional fact from the model, and populating the factLabel UI component: factLabel.text = model.fact factLabel.isHidden = model.fact == nil

4:14

Let’s get rid of the factLabel component: // factLabel.text = model.fact // factLabel.isHidden = model.fact == nil …and instead let’s show the fact in an alert. This is a very, very simple form of navigation, but it will teach us some important lessons that we will be able to apply to sheets, popovers, navigation stacks, and really any kind of navigation imaginable.

4:26

Let’s start very naively. What if whenever the observe trailing closure is called we try to unwrap the fact state, and if it succeeds we will show an alert: if let fact = model.fact { let alertController = UIAlertController( title: model.count.description, message: fact, preferredStyle: .alert ) present(alertController, animated: true) }

4:51

Would be pretty nice if it was that simple, but this is a little bit too simplistic.

4:57

Let’s run the feature in the preview, count up a few times, and then ask for a fact. An alert shows after a moment with our fact, and that’s great, but also we haven’t exposed any buttons for dismissing the alert. This is a pretty stark difference between SwiftUI and UIKit. SwiftUI will never allow you to present an un-dismissible alert, but UIKit does allow this.

5:15

So we need to add some action buttons: alertController.addAction( UIAlertAction(title: "OK", style: .default) { _ in } ) I’m not sure what to do in the action yet, so we will just leave it empty for now.

5:26

When we run the preview again it appears to work. We can count up, ask for a fact, a moment later we get the alert, and then we can tap “OK” to dismiss the alert. However, the fact state was never nil ’d out when the alert was dismissed, and so now our application is in an inconsistent state. The model says that an alert should be showing because the state is non- nil , but clearly no alert is showing. We never want to have our model disagree with what is showing on the screen.

5:47

This is also another stark difference between SwiftUI and UIKit. SwiftUI makes it so that your model always stays in sync with what is visually on the screen. In the case of alerts, when an alert is dismissed, SwiftUI writes nil or false to whatever binding is driving the alert, and that in turn updates your model.

6:01

However, we have no such affordances in UIKit. It is our job to make sure our model represents what is on screen, and so when the user dismisses the alert we need to go clean up our model’s state. So, perhaps we need to clear out the model’s fact field when the user taps the “OK” button in the alert: if let fact = model.fact { let alertController = UIAlertController( title: model.count.description, message: fact, preferredStyle: .alert ) alertController.addAction( UIAlertAction(title: "OK", style: .default) { _ in self.model.fact = nil } ) present(alertController, animated: true) }

6:21

Now things work a lot better. We can get a fact, dismiss the alert, count up to another number, and then get a fact all over again.

6:27

But still this isn’t right.

6:29

If we programmatically dismissed the alert, then there’s nothing in this code to also dismiss the alert. For example, suppose we wanted to give the user 3 seconds to read the alert, and if they haven’t dismissed the alert by then we will dismiss it for them: func factButtonTapped() async { … try? await Task.sleep(for: .seconds(3)) fact = nil }

6:51

If we run the SwiftUI version, it behaves as we expect: the alert shows, and 3 seconds later it is automatically dismissed.

7:00

But sadly over in UIKit it does not work. If we request a fact, we see the alert appear, and then we wait 3 seconds and we will see that nothing happens.

7:06

So again our model and view are out of sync because we still do not have a strong connection between the state in our model and what is presented in the view. And this is also something that SwiftUI excels at. When it sees that state is nil out it does the work of finding out what alert was being presented from that state, and dismissing it.

7:20

To fix this we need to somehow dismiss the alertController when we see that the model’s fact state is nil . So perhaps we just need to implement the else branch and dismiss the alert: if let fact = model.fact { … } else { alertController.dismiss(animated: true) } Cannot find ‘alertController’ in scope

7:38

Well, we don’t have access to the alertController in the else branch since it was defined in the if branch.

7:41

So, we need to hoist the controller out of the if so that it is accessible in both places: var alertController: UIAlertController? if let fact = model.fact { alertController = UIAlertController( title: model.count.description, message: fact, preferredStyle: .alert ) alertController?.addAction( UIAlertAction(title: "OK", style: .default) { _ in self.model.fact = nil } ) present(alertController!, animated: true) } else { alertController?.dismiss(animated: true) }

7:54

However this still does not work. The alertController variable defined only for the scope of a single execution of the observe closure. We need it to exist for the lifetime of the controller so that it represents whether or not an alert is currently showing.

8:10

So let’s hoist it further out to the viewDidLoad scope: var alertController: UIAlertController? observe { … if let fact = model.fact { alertController = UIAlertController( title: model.count.description, message: fact, preferredStyle: .alert ) alertController?.addAction( UIAlertAction(title: "OK", style: .default) { _ in self.model.fact = nil } ) present(alertController!, animated: true) } else { alertController?.dismiss(animated: true) } }

8:17

And now things will work a bit better. We can count up, request a fact, see the alert, and then if we wait a few seconds the alert will disappear.

8:21

So that’s great, but even still it’s not quite right. If during that 3 second period some state changes in the model, causing the observe closure to be invoked again, and then a whole new alert will be be presented because the fact state is still non- nil . We can demonstrate this by mutating the count a second after the fact loads, and then 2 seconds after that we clear out the fact: try? await Task.sleep(for: .seconds(1)) count += 1 try? await Task.sleep(for: .seconds(2)) fact = nil

8:44

If we run the preview and request a fact we will see the alert appear, and then a second later there’s no alert, but we do have some logs telling us that something went wrong: [Presentation] Attempt to present <UIAlertController: 0x111813000> on < TtGC7SwiftUI19UIHostingControllerGVS_15ModifiedContentVS_7AnyViewVS_12RootModifier _: 0x140019600> (from <ObservationDemo.CounterViewController: 0x11fd05fb0>) which is already presenting <UIAlertController: 0x112809e00>.

9:00

This is telling us it is not legitimate to present an alert when one is already presented.

9:09

So clearly that’s not ideal. When unrelated changes are made to the model we are executing the alert logic again, and hence re-presenting the alert. One thing we could do is split out our observation code into two separate observe s: observe { [weak self] in guard let self else { return } … } observe { [weak self] in guard let self else { return } if let fact = model.fact { alertController = UIAlertController( title: model.count.description, message: fact, preferredStyle: .alert ) … } else { … } }

9:44

But even this doesn’t work because in creating the alert we access the count , which is going to trigger another observation when it changes. If we change the alert title to something more inert: title: "Fact", …then it finally works.

9:58

It’s quite subtle to have to be aware of these kinds of details when hooking up our view to the model. It will be very easy to get this wrong, and ideally we would just never think about these kinds of things and instead stick everything in a single observe closure. After all, that’s exactly how SwiftUI works. SwiftUI view bodies are recomputed anytime any piece of state in the model is changed. It doesn’t try to just compute the minimal part of the view hierarchy based on what state was changed.

10:22

So, let’s go back to the single observe , and let’s figure out how to make it work. When trying to decide whether or not to present an alert we are currently only checking if the model ’s fact is non- nil . But we also have access to the currently presented alert controller, if there is one. So, what if we just don’t present if the alert is already being presented? if let fact = model.fact, alertController == nil { … } else { … }

10:45

And we should also have similar logic in the else branch too. Specifically, if the fact state is nil and the alert controller is not nil , then we know it’s time to dismiss controller: if let fact = model.fact, alertController == nil { … } else if model.fact == nil, alertController != nil { alertController?.dismiss(animated: true) }

10:57

And further, now that we are using the alertController in our logic to determine when to present and dismiss, we should probably clean up the state when dismissing: if let fact = model.fact, alertController == nil { … } else if model.fact == nil, alertController != nil { alertController?.dismiss(animated: true) alertController = nil }

11:05

And finally , this is the correct form of the logic. We can run the preview to see that everything works exactly as we expect, and it’s all being driven by a single observe closure. If we request a fact we see the alert, then 2 seconds later the count goes up, but the alert stays presented, and then one second after that the alert is dismissed. Sheets, popovers, and covers

11:17

We now have the basics of state-driven navigation in our app, and honestly it’s pretty amazing. We are able to design our feature’s core domain model without a care in the world for how the view is going to look. We don’t need to know if we’re going to use SwiftUI or UIKit or who knows what. And in that core model we can decide that some child feature is displayed when a piece of state is non- nil , and then it is dismissed when it becomes nil .

11:41

And then we can hook that model up to the view, whether we are using SwiftUI or UIKit. In SwiftUI it was quite easy, of course, but even over in UIKit it wasn’t so bad. We just had to keep track of a little bit of extra state in the view to know whether or not the alert controller was being presented so that we know how to prevent double presentations and so that we can dismiss later. Brandon

12:03

But so far we have only performed the simplest kind of navigation, and that is alerts. There are lots more kinds of presentation to think about on Apple’s platforms, such as sheets, popovers, fullscreen covers, navigation stacks, and even custom forms of navigation that you may want to create for your own apps.

12:19

What does it look like to drive those kinds of navigations from state in UIKit? Well, turns out its quite simple!

12:26

Let’s take a look.

12:29

We are going to present the fact in a sheet rather than showing it in an alert. And let’s first do this in the SwiftUI version of the app to see just how easy it is, and how SwiftUI does a lot of the heavy lifting for us to make sure that our model is kept in sync with the UI.

12:45

We want to use the .sheet modifier so that we can present and dismiss the sheet based on the fact state in the model, but it requires that we provide a Binding of an Identifiable item: .sheet( item: <#Binding<Identifiable?>#>, content: <#(Identifiable) -> View#> )

12:54

So we can’t simply do: .sheet(item: $model.fact) { fact in Text(fact) } …since String is not identifiable. And unfortunately the .sheet modifier doesn’t come with an overload that allows you to specify the id like ForEach does: .sheet(item: $model.fact, id: \.self) { fact in Text(fact) }

13:28

So we really do need to wrap our fact in some kind of Identifiable package.

13:33

We can add a type called Fact that simply wraps a string and makes the string the id : struct Fact: Identifiable { var value: String var id: String { fact } }

13:49

And then we can use this type instead of a bare String in our model: var fact: Fact?

13:54

And when populating this state we have to make sure to go through the Fact type: fact = Fact(value: loadedFact)

13:59

And now we can use the .sheet view modifier with this type: .sheet(item: $model.fact) { fact in Text(fact.value) }

14:16

We can run this in the preview to see that it works as we expect. It even dismisses itself when we programmatically nil out the fact .

14:40

But let’s remove all that test code now and go back to the real behavior we want this feature to have.

15:02

So, this is extremely easy to accomplish in SwiftUI. What does it look like in UIKit? Well, it’s also not so bad. Just requires a bit more work.

15:10

Let’s start by commenting out the alert code in the observe closure since we now want to use a sheet for navigation.

15:20

We will follow a similar pattern that we did for alerts in order to show the sheet. To start, we know we need to track some local state for the controller being presented so that we know when to skip presenting again and so that we can dismiss later. So let’s go ahead and do that: var factController: UIViewController? Note that we can even erase the type down to UIViewController because we won’t need to know any of the details of the controller being presented.

15:51

And we can do the same dance we did with the alert controller. We can quickly sketch out the if – else : if let fact = model.fact, factController == nil { // Present } else if model.fact == nil, factController != nil { factController?.dismiss(animated: true) factController = nil }

16:32

But we do need a controller to actually present. The controller is going to be very simple that just has a single UILabel at the root that fills the whole screen and displays the fact. We aren’t going to build this from scratch, and instead I am going to paste it in: class FactViewController: UIViewController { let fact: String init(fact: String) { self.fact = fact super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .white let factLabel = UILabel() factLabel.text = fact factLabel.numberOfLines = 0 factLabel .translatesAutoresizingMaskIntoConstraints = false view.addSubview(factLabel) NSLayoutConstraint.activate([ factLabel.leadingAnchor.constraint( equalTo: view.safeAreaLayoutGuide.leadingAnchor ), factLabel.topAnchor.constraint( equalTo: view.safeAreaLayoutGuide.topAnchor ), factLabel.bottomAnchor.constraint( equalTo: view.safeAreaLayoutGuide.bottomAnchor ), factLabel.trailingAnchor.constraint( equalTo: view.safeAreaLayoutGuide.trailingAnchor ), ]) } }

17:10

With that defined we can implement the presentation logic: if let fact = model.fact, factController == nil { factController = FactViewController(fact: fact.value) present(factController!, animated: true) } …

17:26

And for the most part that’s it! We can run the preview to see that now the fact is presented in a sheet. And if we bring back that logic that programmatically dismisses the fact after 3 seconds: try? await Task.sleep(for: .seconds(3)) fact = nil …then we will see that even that works correctly. The sheet automatically animates away after 3 seconds.

17:55

However, this isn’t quite correct. There is something you can do with sheets that you can’t do with alerts that will cause the state and UI to become inconsistent. To see this let’s undo the programatic dismissal code again. When we fetch a fact to have the sheet fly up, we have the ability to then swipe down on the sheet to make it go away. But that user action did not update our model at all. So technically the fact state is non- nil in the model, yet the sheet clearly isn’t presented, and thus we are in an inconsistent state.

18:30

SwiftUI does not have this problem because the moment you dismiss the sheet SwiftUI writes nil to its binding, which ultimately nil s out the fact state in the model. That keeps the model and UI in sync, and that’s what we want in UIKit.

18:44

The way to achieve this is to be able to listen for when the controller presented is deallocated. That is definitive proof that the feature has been dismissed, and therefore its time to clean up state. However, how can we do that?

19:01

Well, we could introduce an onDismiss hook in our FactViewController that could be customized from the outside: class FactViewController: UIViewController { let fact: String let onDismiss: () -> Void init(fact: String, onDismiss: @escaping () -> Void) { self.fact = fact self.onDismiss = onDismiss super.init(nibName: nil, bundle: nil) } … }

19:37

And then we could invoke it in deinit : deinit { onDismiss() }

19:41

Now when we create a FactViewController we can provide a trailing closure that is hopefully invoked when the controller is fully dismissed. And so in that closure we will clear out all the important state: factController = FactViewController(fact: fact.value) { self.model.fact = nil } present(factController!, animated: true)

19:54

However we did introduce a warning about sendability: ️> Cannot access property ‘onDismiss’ with a non-sendable type ‘() -> Void’ from non-isolated deinit; this is an error in Swift 6

20:09

This is complaining that onDismiss is not sendable, and so maybe we just need to mark it as such: let onDismiss: @Sendable () -> Void init(fact: String, onDismiss: @escaping @Sendable () -> Void) { … } And that seems to appease the compiler.

20:14

However, this does not compile: Mutation of captured var ‘factController’ in concurrently-executing code Main actor-isolated property ‘fact’ can not be mutated from a Sendable closure

20:16

These are good errors to have and are helping us catch potential problems. Currently the onDismiss closure is merely @Sendable , which means we require it to be safe to call from any thread. And it is definitely not safe to mutate variables from multiple threads. However, all UIViewController s are @MainActor , and so we would be allowed to mutate this local state if the onDismiss closure was also @MainActor .

20:29

In fact, just adding @MainActor to the closure in the initializer: init( fact: String, onDismiss: @escaping @MainActor @Sendable () -> Void ) { … } …fixes that compiler error. It does cause other warnings, but it at least shows that by telling Swift we will only invoke this closure on the main actor it is then OK with us mutating state. To fix the warning that popped up we need to now hold onto a @MainActor closure in the FactViewController : let onDismiss: @MainActor @Sendable () -> Void

20:33

But then that causes an error in the deinit : Call to main actor-isolated function in a synchronous nonisolated context

20:42

The deinit of actors are kind of in a tough spot right now in Swift, especially global actors, and unfortunately it wasn’t resolved before Swift 6. Currently the deinit of actors are not isolated, which means there’s really not much you can do in deinit s. If you access any state inside you will invariably get a warning letting you know you are accessing isolate data from a nonisolated context: _ = self.base.view And so that means we can’t even invoke onDismiss , because it is marked as @MainActor and technically the deinit is not @MainActor even though the rest of the view controller is @MainActor .

21:07

Well, we can do something to fix this, and that is take responsibility for proving isolation rather than letting the compiler figure it out: deinit { MainActor.assumeIsolated { onDismiss() } }

21:22

This is us telling the compiler that we are very sure that this view controller will only ever be deallocated on the main thread. That seems pretty reasonable to us. After all, view controllers are only meant to be interacted with on the main thread, and so shouldn’t they be deallocated on the main thread?

21:36

In fact, it even seems that UIKit is even doing something special to make sure that the controller is always de-initialized on the main thread. Even if we try to force it to de-initialize on a background thread, like if we create and discard the controller in a detached task: Task.detached { let vc = await FactViewController(fact: "", onDismiss: {}) _ = vc } …somehow the deinit is called on the main thread. We even get a purple runtime warning from UIKit letting us know we are doing bad things: -[UIViewController init] must be used from main thread only …yet still magically deinit is called on the main thread.

22:54

So, while you should use MainActor.assumeIsolated very sparingly and only when you feel very, very confident that the code will indeed be called on the main actor, we feel that it is OK to do in this case, and hopefully sometime in the near future Swift will figure out how it wants to deal with isolated deinit s, and at that time we can clean this up.

23:14

But now with that done everything is compiling and without any warnings. So, let’s give it a spin. I’m going to put a break point in the onDismiss closure. …and then run the app in the simulator. I’ll request a fact, see the sheet come up, and then swipe down to dismiss.

23:27

And… well, the breakpoint never hits.

23:30

This is happening because we have a retain cycle. We are capturing self in the onDismiss closure: factController = FactViewController(fact: fact.value) { self.model.fact = nil }

23:37

That means the factController owns self , but in the very next line self also owns factController because it is presenting the fact controller: present(factController!, animated: true)

23:42

This retain cycle is easy enough to break, we just need to capture self weakly in the closure so that the fact controller no longer owns self : factController = FactViewController( fact: fact.value ) { [weak self] in self?.model.fact = nil }

23:51

However, this still does not work. The breakpoint in the onDismiss is still not called.

24:00

Although we have broken the retain cycle, we actually have a reference to the controller that exists even once the fact controller is dismissed. And that is the factController variable defined outside the observe closure, but then captured in the observe closure.

24:09

We need this reference to be weak so that it doesn’t accidentally keep the controller alive even after it has been dismissed. So, let’s mark it as weak : weak var factController: UIViewController?

24:17

This immediately gives us a warning on the line we assign factController : Weak reference will always be nil because the referenced object is deallocated here And this is a good warning to have because it is letting us know we are not accomplishing what we think we are with these lines of code. Because factController is weak, as soon as no one else holds onto a reference to its object it will immediately deallocate the object. And in this case, no one holds onto a reference to FactViewController other than factController , and hence it will be deallocated right away.

24:28

We just need to perform a little dance to make sure that there is another reference to the controller, even if its just locally in the scope of this if branch: let controller = FactViewController( fact: fact.value ) { [weak self] in self?.model.fact = nil } factController = controller present(controller, animated: true)

24:42

Now this compiles with no warnings, and it works too. We can fetch a fact, swipe down to dismiss the sheet, and see that the onDismiss closure is now properly called and state is cleared out.

24:58

So, this is incredible. We can now drive sheets from state.

25:01

It is a little precarious to depend on deallocation of objects to detect dismissing, but it’ll serve our purpose for the episodes, and we will have a more reliable tool in the final library.

25:18

But moving on, what about other forms of navigation, like popovers? Well, popovers used to be treated very different from regular sheets in UIKit, but thankfully in modern UIKit they are basically treated the same, and so there is only two small changes we need to make to turn this sheet into a popover.

25:33

First, we change the modalPresentationStyle of the controller we are presenting to be .popover : controller.modalPresentationStyle = .popover

25:40

And then we tell UIKit where to have the popover pointing at: controller.popoverPresentationController? .sourceView = factButton

25:51

If we run this in an iPad simulator we will see it work as we expect. This is a 100% state-driven popover. If we programmatically clear the state, the popover will go away. And if we tap off the popover, it will be dismissed and the state in the model will be cleared out too.

26:24

Even drill-downs are easy. Instead of using the present method we can use the pushViewController method on the controller’s navigationController : navigationController?.pushViewController( controller, animated: true )

26:44

But in order for this to work we need to be wrapped in a navigation controller, so we can go back to the entry point of the app and wrap everything in a navigation controller: @main struct ModernUIKit: App { var body: some Scene { WindowGroup { UIViewControllerRepresentable { UINavigationController( rootViewController: CounterViewController( model: CounterModel() ) ) } } } }

27:06

And just like that the fact is now showing via a drill-down rather than a sheet or a popover. We have now seen how basically every major form of navigation can be driven off of state. A better presentation API

27:54

OK, now we are cooking with fire!

27:56

We are getting a very clear picture of how state-driven navigation can work in UIKit, and it really isn’t all that different from SwiftUI. First, and foremost, navigation is a domain modeling problem. Whenever a parent feature can navigate to child feature, you model that as the parent feature holding onto a piece of optional state representing the child feature. When that state becomes non- nil you then present the child feature. And when the state becomes nil you dismiss the feature. Easy peasy.

28:23

And this simple idea works for nearly every kind of navigation. Alerts, sheets, popovers, fullscreen covers, drill-downs, and any kind of custom navigation you could possibly imagine for your app. And further, because we’ve seen this work for both UIKit and SwiftUI, two very different paradigms, I hope everyone is capable of closing their eyes and imagining that this would also work for any view paradigm on any platform. This includes Windows, Linux, terminal applications, WebAssembly, and things we can’t even imagine. The idea is very simple. Stephen

28:54

But, while what we have done here is great, the code is also very, very messy. There is a ton of boilerplate we need to fill out in order to perform a single navigation. Ideally there would be some nice, reusable library code we could implement so that navigating to a child view is just a one-liner.

29:12

This is absolutely possible, and so let’s get into it now.

29:16

Currently we have to write all of this code in order to do something as simple as drill-down to a view when state becomes non- nil : if let fact = model.fact, factController == nil { let controller = FactViewController( fact: fact.value ) { [weak self] in self?.model.fact = nil } factController = controller navigationController?.pushViewController( controller, animated: true ) } else if model.fact == nil, factController != nil { factController?.dismiss(animated: true) factController = nil }

29:22

And before that we did the following to show a sheet from a piece of optional state: if let fact = model.fact, factController == nil { let controller = FactViewController( fact: fact.value ) { [weak self] in self?.model.fact = nil } factController = controller present(controller, animated: true) } else if model.fact == nil, factController != nil { factController?.dismiss(animated: true) factController = nil }

29:30

And before that we had to do all of this to show an alert from an optional piece of state: if let fact = model.fact, alertController == nil { alertController = UIAlertController( title: model.count.description, message: fact.value, preferredStyle: .alert ) alertController?.addAction( UIAlertAction(title: "OK", style: .default) { _ in self.model.fact = nil } ) present(alertController!, animated: true) } else if model.fact == nil, alertController != nil { alertController?.dismiss(animated: true) alertController = nil }

29:34

What if we could unify all of these into a single line of code?

29:38

For example, to push a controller onto the stack from some optional state, maybe we could do something like this: pushViewController(item: model.fact) { fact in FactViewController(fact: fact.value) }

30:07

Note that our FactViewController still has a trailing onDismiss closure, and ideally that would all be taken care of by our generic tool rather than forcing our users to think about dismissal and state clean up. So let’s proactively remove that closure from FactViewController : class FactViewController: UIViewController { let fact: String init(fact: String) { self.fact = fact super.init(nibName: nil, bundle: nil) } … }

30:19

Or to present a sheet, ideally we could do something like this: present(item: model.fact) { fact in FactViewController(fact: fact.value) }

30:26

Or to present an alert maybe we could reuse the present helper, but return a UIAlertController instead: present(item: model.fact) { fact in let alert = UIAlertController( title: model.count.description, message: fact.value, preferredStyle: .alert ) alert.addAction( UIAlertAction(title: "OK", style: .default) ) return alert }

30:50

These helpers would take care of all the message work behind the scenes of figuring out when to present and dismiss the controllers, and it would know when the controller is deallocated so that it can clean up state.

31:02

That would be pretty amazing. These APIs even look quite similar to the corresponding APIs in SwiftUI. For example, presenting a sheet looks like this: /* .sheet(item: $model.fact) { fact in FactView(fact: fact.value) } */ present(item: model.fact) { fact in FactViewController(fact: fact.value) }

31:22

And this really does show off that our #1 priority when building our features should be in modeling our domains properly, and then letting the view code flow from that. We should never be thinking of view-specific concerns when building our model. That helps us keep the feature code more isolated and easier to reuse in many places.

31:37

However, the current API we have sketched is not actually possible. We need to make some major tweaks to it, but let’s try implementing it and see what goes wrong. Let’s start with the signature of one of these helpers, say the present one. We will make it an extension on UIViewController : import UIKit extension UIViewController { func present() { } }

32:08

This method takes two arguments, first starting with an optional item: extension UIViewController { func present<Item>( item: Item? ) { } }

0:00

When that state becomes non- nil we should present a controller, and when it flips back to nil we should dismiss.

0:00

The next argument is a closure that transforms an Item into a controller to be presented: extension UIViewController { func present<Item>( item: Item?, content: (Item) -> UIViewController ) { } }

32:39

And now we can try to implementing this method. It’s going to look a lot like the ad hoc code we have already written a few times. We will start by checking if the state is nil or non- nil to know if we need to present: if let item { // Present } else { // Dismiss }

0:00

If we can unwrap the item then we can invoke content with it to get the controller the user wants to present: if let item { let controller = content(item) present(controller, animated: true) } else { // Dismiss }

32:55

However, clearly we are now missing out on the onDismiss behavior here. Previously we crammed that behavior directly in the FactViewController , but now that we are working on library code we can’t force our users to do that work for us. We need to somehow do it under the hood.

33:05

Further, even in the else branch it’s not clear how to dismiss: if let item { … } else { // ??? controller.dismiss() }

33:10

We need to have access to a controller to dismiss, but this else branch gets called long after the controller was presented, and so we have no reference to that controller.

33:16

Let’s fix this problem before the onDismiss problem as it’s a little easier. We need to somehow keep a reference to the currently presented controller so that we can dismiss it later. And technically UIViewController even has a presentedViewController property, but it only tracks what is presented via the present method: Note This object corresponds to the one passed as the first parameter of the present(_:animated:completion:) method. The successful conclusion of the presentation process causes this view controller’s content to be displayed onscreen.

33:33

So this won’t track what is presented when using pushViewController or other APIs for navigating to controllers. We need to track this state ourselves, and we can’t add any stored properties to the controller because we are merely in an extension of UIViewController . Again we could subclass UIViewController to add a property, but that is not a user friendly API because it locks our users into a very rigid structure. They lose the ability to subclass whichever controller they want.

33:57

Luckily Objective-C has some runtime shenanigans we can employ to dynamically add stored data to any NSObject subclass, and it’s called “associated objects”. We can use it right after creating the controller so that we could store it away inside the controller: let controller = content(item) objc_setAssociatedObject( <#object: Any#>, <#key: UnsafeRawPointer#>, <#value: Any?#>, <#policy: objc_AssociationPolicy#> )

34:19

The object argument is the object the associated object is being stored on , and so that will be self , i.e. the controller doing the presenting: objc_setAssociatedObject( self, <#key: UnsafeRawPointer#>, <#value: Any?#>, <#policy: objc_AssociationPolicy#> )

34:24

We need to provide a key that is used to identify the object we are referencing. The key is just any raw pointer, and so we can actually allocate a single byte to get a pointer to some storage: private let presentedKey = malloc(1)! …and use that as the key: objc_setAssociatedObject( self, presentedKey, <#value: Any?#>, <#policy: objc_AssociationPolicy#> )

34:42

The next argument is the value you want to store in the object, and in this case it’s the controller being presented: objc_setAssociatedObject( self, presentedKey, controller, <#policy: objc_AssociationPolicy#> )

34:47

And the last argument is the “policy” for holding onto the value, and this corresponds to the modifiers we used to use in Objective-C days when declaring how an object holds onto another object. There are a few options, such as copy, assign and retain, and some of them can be done atomically or non-atomically.

35:05

Historically nonatomic retain was the most common option to use because typically you want the parent object retaining the child, and you would want the setter to be nonatomic because it’s a little faster since it doesn’t use a lock to provide atomicity. In our case, thread safety doesn’t matter because this code path should only ever be called on the main thread: objc_setAssociatedObject( self, presentedKey, controller, .OBJC_ASSOCIATION_RETAIN_NONATOMIC )

35:20

That’s all it takes to squirrel away the presented controller inside the presenting controller.

35:25

Even better, let’s move this into a little fileprivate property so that it’s even easier to access: fileprivate var presented: UIViewController? { get { } set { objc_setAssociatedObject( self, presentedKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC ) } }

35:42

And in the get we can use objc_getAssociatedObject to retrieve the presented view controller from the presenting view controller: get { objc_getAssociatedObject(self, presentedKey) as? UIViewController }

36:01

And now that we have ambient access to the currently presented view controller we can make use of it to implement our present method: func present<Item>( item: Item?, content: (Item) -> UIViewController ) { if let item = item, presented == nil { let controller = content(item) presented = controller present(controller, animated: true) } else if item == nil, let controller = presented { controller.dismiss(animated: true) presented = nil } }

36:28

That right there gets us really far towards a fully generic and reusable solution for presenting view controllers.

36:05

In fact, we can also go up to our observe closure and replace all that messy presentation code with just this for presenting a sheet: present(item: model.fact) { fact in FactViewController(fact: fact.value) }

36:53

We can run the preview to see that it seems to work correctly. We can even bring back the programmatic dismissal after showing the fact for 3 seconds: …and see that even that works. Next time: Dismissal

37:12

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

37:25

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.

37:45

Let’s give that 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 0284-modern-uikit-pt4 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 .