EP 283 · Modern UIKit · Jun 17, 2024 ·Members

Video #283: Modern UIKit: Observation

smart_display

Loading stream…

Video #283: Modern UIKit: Observation

Episode: Video #283 Date: Jun 17, 2024 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep283-modern-uikit-observation

Episode thumbnail

Description

It’s time to build modern tools for UIKit from scratch, heavily inspired by SwiftUI and using the Observation framework. Surprisingly, Swift 5.9’s observation tools can be used in UIKit, and in fact they work great, despite being specifically tuned for SwiftUI.

Video

Cloudflare Stream video ID: 7f6d780b4eda4a6e56459ab9d0825925 Local file: video_283_modern-uikit-observation.mp4 *(download with --video 283)*

References

Transcript

0:05

Well, this is pretty incredible.

0:08

In just a short amount of time we have been able to build a pretty complex UIKit app from scratch that features many of the things that real world apps need to deal with:

0:17

Driving navigation from state, and we dealt with 3 forms of navigation: alerts, sheets and drill-downs.

0:23

Forming 2-way bindings between our models and UI controls.

0:28

Observing state changes in a model for updating UI elements.

0:32

And dealing with complex collection views.

0:35

And we were able to accomplish all of this using precise, modern techniques, all thanks to the observation tools in Swift and our UIKitNavigation library. Stephen

0:46

And we want to remark again how important it was for us to perform our domain modeling exercise first and in complete isolation. We built 3 observable models for 3 of our features without ever once thinking about view specific needs. And instead, when it came time to build the view, we bent the view to the will of the model to make things work. Brandon

1:05

And that means we can reuse those models in a variety of view paradigms and platforms. We could rebuild those UIKit view controllers using SwiftUI, or we could build views for Windows, Linux, WebAssembly, or who knows what else! The possibilities are endless because we kept our domain models focused on just the business logic, and let the view flow freely from it. Stephen

1:25

So, this has been a lot of fun, but now it’s time to dig a lot deeper. We have showed off a lot of cool tools in the past episode, but what does it take to create these seemingly magical tools? Well, with a bit of hard work we can build them from scratch, and along the way we will get some deep insights into how SwiftUI works under the hood, and even get an understanding of why SwiftUI sometimes doesn’t work the way we expect.

1:48

So let’s begin. Observation in UIKit

1:51

We are going to start by showing off the surprising fact that Swift 5.9’s observation tools can be used in UIKit. And in fact, they work great in UIKit. This is a little surprising because it’s very clear from the evolution proposal of observation that those tools, at least in their form today, are specifically tuned for SwiftUI.

2:05

I have a fresh UIKit project open right here, and I have already turned on strict concurrency warnings.

2:09

This will help keep us in check from the very beginning since Swift 6 is on the horizon.

2:14

Now let’s explore Swift’s observation tools and how they interact with SwiftUI by creating a little observable model like this: import SwiftUI @Observable class CounterModel { var count = 0 func incrementButtonTapped() { count += 1 } func decrementButtonTapped() { count -= 1 } }

2:28

…and then use it in a view like this: struct CounterView: View { let model: CounterModel var body: some View { Form { Text("\(model.count)") Button("Decrement") { model.decrementButtonTapped() } Button("Increment") { model.incrementButtonTapped() } } } } #Preview("SwiftUI") { CounterView(model: CounterModel()) }

2:30

The view can see that we accessed model.count in the body of the view, and therefore knows to subscribe to changes to it. And when the view sees a change in the count it invalidates itself so that the body is recomputed and the screen can be updated.

2:49

This model of observation fits SwiftUI like a glove. But can we adapt it also work for UIKit? Well, to see let’s get a simple view controller in place so that we can play around with the observation tools.

3:00

I would love if I could define a new UIViewController subclass: final class CounterViewController: UIViewController { }

0:00

…such that all of its logic and behavior is driven off of the CounterModel we defined above: final class CounterViewController: UIViewController { let model: CounterModel }

3:10

And already this looks similar to the declaration of our CounterView SwiftUI view. We are just holding onto a single object that encapsulates all of the logic and behavior of the view.

3:20

Now unfortunately we are dealing with older, class-based APIs here, and so we do have to provide some initializers: final class CounterViewController: UIViewController { let model: CounterModel init(model: CounterModel) { self.model = model super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } }

3:43

Next we need to create the view hierarchy for the feature, and this is of course where the UIKit pain really starts to settle in. You could use interface builder to create all the subviews needed, but we will just do it manually in code.

3:54

The most appropriate place to do this is just one single time right in viewDidLoad : override func viewDidLoad() { super.viewDidLoad() }

4:02

And often we can create the subviews directly in here without even adding them as properties to the controller.

4:07

For example, we know we want a label to show the current count, as well as buttons for incrementing and decrementing, so we can create those elements: override func viewDidLoad() { super.viewDidLoad() let countLabel = UILabel() countLabel.textAlignment = .center let decrementButton = UIButton(type: .system) decrementButton.setTitle("Decrement", for: .normal) let incrementButton = UIButton(type: .system) incrementButton.setTitle("Increment", for: .normal) }

4:23

And then we can put all three of those views in a stack and arrange them vertically: let counterStack = UIStackView(arrangedSubviews: [ countLabel, decrementButton, incrementButton ]) counterStack.axis = .vertical counterStack.spacing = 12

4:32

And then we can add that view to the controller’s view, and center it: view.addSubview(counterStack) NSLayoutConstraint.activate([ counterStack.centerXAnchor.constraint( equalTo: view.centerXAnchor ), counterStack.centerYAnchor.constraint( equalTo: view.centerYAnchor ), counterStack.leadingAnchor.constraint( equalTo: view.leadingAnchor ), counterStack.trailingAnchor.constraint( equalTo: view.trailingAnchor ), ])

4:37

And of course you must always remember to tell UIKit that you want to use autolayout rather than autoresizing masks: counterStack .translatesAutoresizingMaskIntoConstraints = false

4:44

That’s the basics of the view, and we can even show it in a preview thanks to the new #Preview macro: #Preview("UIKit") { CounterViewController(model: CounterModel()) }

4:57

It’s not the prettiest view in the world, but it gets the job done.

5:01

And of course it’s completely not functional right now. At the very least we need to implement actions for each button, which can be done using some modern UIKit button APIs for specifying the button action via closures, but you do have to be careful of accidental retain cycles: let decrementButton = UIButton( type: .system, primaryAction: UIAction { [weak self] _ in self?.model.decrementButtonTapped() } ) … let incrementButton = UIButton( type: .system, primaryAction: UIAction { [weak self] _ in self?.model.incrementButtonTapped() } )

5:36

So now when the buttons are tapped we are going to mutate the model. But even with that the view does not update showing us the current count.

5:44

We can easily get the initial count value in the label: countLabel.text = "\(model.count)"

5:51

The preview now shows “0” right away, but still tapping on either button does not cause the label to automatically update.

5:56

Well, ideally we would be able to use Swift 5.9’s observation tools to automatically observe fields in the model so that we can update the UI elements in the controller. The only tool that the Observation framework gives us for this is withObservationTracking : withObservationTracking { <#code#> } onChange: { <#code#> }

6:09

The first closure is synchronously and immediately invoked and whatever observable fields are accessed in that closure will automatically be tracked. Then, when one of those properties changes, the onChange trailing closure will be invoked so that you can react to the changes.

6:23

It’s a crude tool, but it’s all we have at this time.

6:26

In the first trailing closure we can access the fields of the model we need to populate the UI, and then we can even mutate the UI elements right in that closure: withObservationTracking { countLabel.text = "\(model.count)" } onChange: { }

6:36

And I’m not entirely sure what to do in the onChange right now, so let’s leave it blank.

6:40

If we run this in the preview we do see “0” in the view immediately, but still the buttons do not cause the label to update. This is because we aren’t yet doing anything in the onChange handler.

6:49

The onChange handler is called as soon as a mutation is made, and so maybe we need to just perform the mutation again in there: withObservationTracking { countLabel.text = "\(model.count)" } onChange: { countLabel.text = "\(self.model.count)" }

7:05

It’s a little strange, and we even have a warning because we have concurrency warnings maxed right now.

7:10

We want to ignore the warning for now, but actually we can’t because the preview won’t run unless we address the warning. For whatever reason previews seem to run in an even more strict concurrency environment than the rest of the app does. So let’s just quickly silence the warning by telling Swift to trust us that we are on the main actor: withObservationTracking { countLabel.text = "\(model.count)" } onChange: { MainActor.assumeIsolated { countLabel.text = "\(self.model.count)" } }

7:31

If MainActor.assumeIsolated is ever called on a non-main thread it will crash. However, even with that the view is still not updating. The onChange trailing closure of withObservationTracking fires synchronously when the count field of the model is mutated, but it does so in the willSet of the field. This means if we read the count from the model at that moment it will be the previous before, before incrementing.

7:45

And so what we have to do is delay updating the UI for a tick of the runloop so that we can get the current value after the mutation. We can use an unstructured Task to facilitate this, and further it should probably be executed on the @MainActor since UI updates must be done on the main thread, and technically an observable model can be mutated on any thread: withObservationTracking { countLabel.text = "\(model.count)" } onChange: { Task { @MainActor in countLabel.text = "\(self.model.count)" } }

8:04

And this time, when we tap the increment button, the count label is finally updated to 1, but if we increment again, it doesn’t change, and the same is true if we tap the decrement button.

8:11

This is because the onChange is only called a single time, and so if you want to be notified of more changes to the model you need to use withObservationTracking again .

8:19

So what you have to do is set up a little function that uses withObservationTracking , and then when the second trailing closure is invoked it will call onChange again: func onChange() { withObservationTracking { countLabel.text = "\(model.count)" } onChange: { Task { @MainActor in onChange() } } } onChange()

8:38

But because the onChange trailing closure is @Sendable we need to make our onChange sendable: @Sendable func onChange() { withObservationTracking { countLabel.text = "\(model.count)" } onChange: { Task { @MainActor in onChange() } } } onChange()

8:48

This is compiling. We do have another warning about @MainActor isolation when updating the UI state, but again we can just tell Swift to trust us: MainActor.assumeIsolated { countLabel.text = "\(model.count)" }

8:59

With that we now have a preview that works a lot better. Each time we tap the “Increment” or “Decrement” button we see the count go up or down.

9:06

This is actually really impressive already, but it’s hard to see because of how much code we have here for doing something so simple. We would never want to have to write all this code for each view we need to observe state in. Instead it would be far better if there were a reusable function that could be called to automatically set up observation: observe { countLabel.text = "\(model.count)" }

9:32

This function would automatically listen for changes to model.count and invoke the trailing closure whenever the state changes.

9:35

Let’s get the basics of the signature in place: import Observation func observe( apply: @escaping () -> Void ) { }

9:57

We will also need an onChange function to call recursively, and so we will put that right next to observe and kick things off inside the body of observe : func observe( apply: @escaping () -> Void ) { onChange() } func onChange() { }

10:09

Then inside onChange we can do the observation: func observe( apply: @escaping () -> Void ) { onChange() } func onChange() { withObservationTracking { } onChange: { Task { @MainActor in onChange() } } }

10:22

And we need to call the apply closure in withObservationTracking , so we need to thread it through: func observe(apply: @escaping () -> Void) { onChange(apply: apply) } func onChange(apply: @escaping () -> Void) { withObservationTracking { apply() } onChange: { Task { @MainActor in onChange(apply: apply) } } }

10:39

And we are getting a warning about sendability, but that’s just because apply must be sendable: func observe(apply: @escaping @Sendable () -> Void) { onChange(apply: apply) } func onChange(apply: @escaping @Sendable () -> Void) { withObservationTracking { apply() } onChange: { Task { @MainActor in onChange(apply: apply) } } }

10:48

Now if we hop back forward to our counter feature, our theoretical code is not compiling because it was defined at the module level, but an observe method already exists on NSObject : Use of ‘observe’ refers to instance method rather than global function ‘observe(apply:)’ in module ‘ModernUIKit’

10:56

This is annoying, and we can work around this issue by moving the observe function to be defined on an extension of NSObject instead of a free function: extension NSObject { func observe(apply: @escaping @Sendable () -> Void) { ModernUIKit.observe(apply: apply) } }

11:23

And now this code is resolving to our overload: observe { countLabel.text = "\(model.count)" } …but we do have some warnings and even an error: Reference to property ‘model’ in closure requires explicit use of ‘self’ to make capture semantics explicit

11:27

First Swift is letting us know that we have capture semantics that we need to acknowledge. To avoid a retain cycle we should weakify ourselves: observe { [weak self] in guard let self else { return } countLabel.text = "\(model.count)" }

11:40

The warnings are about main actor isolation and sendability again. Main actor-isolated property ‘text’ can not be mutated from a Sendable closure; this is an error in Swift 6 Non-sendable type ‘CounterModel’ in asynchronous access to main actor-isolated property ‘model’ cannot cross actor boundary observe { [weak self] in guard let self else { return } MainActor.assumeIsolated { countLabel.text = "\(model.count)" } }

11:51

Well, as far as making the model sendable goes, we only expect it to be interacted with on the main actor because it’s held in a main actor view controller, so let’s make our model @MainActor : @Observable @MainActor class CounterModel { … }

12:06

And now this complains: Main actor-isolated property ‘count’ can not be referenced from a Sendable closure

12:15

This error is saying that we are accessing a @MainActor isolated property from a non- @MainActor context, which means we technically need to await to interact with the model. But the closure we are in is not async, and so we can’t await .

12:26

So instead we can say that when observing we must provide a @MainActor isolated closure: func observe( apply: @escaping @MainActor @Sendable () -> Void ) { … } func onChange(apply: @escaping @Sendable () -> Void) { … }

12:37

That fixes the previous error, but also causes a new one: Call to main actor-isolated parameter ‘apply’ in a synchronous nonisolated context

12:43

This is a similar error, and we will fix it by making the entire observe function @MainActor : @MainActor func observe( apply: @escaping @MainActor @Sendable () -> Void ) { … } @MainActor func onChange( apply: @escaping @MainActor @Sendable () -> Void ) { … } extension NSObject { @MainActor func observe( apply: @escaping @MainActor @Sendable () -> Void ) { … } }

12:55

And now we have a compiling app with 0 warnings. And instead of this: @Sendable func onChange() { withObservationTracking { countLabel.text = "\(model.count)" } onChange: { Task { @MainActor in onChange() } } } onChange() We simply have this: observe { [weak self] in guard let self else { return } countLabel.text = "\(model.count)" } And in the former we’re not even properly handling how self is captured.

13:20

What we have is looking really impressive! This single, trailing closure provides a powerful render loop where properties accessed from within it will automatically trigger an update when any of them change.

13:33

It is a little bit of a bummer that we had to force @MainActor isolation. It certainly suits our purposes right now, but in the future we may want to observe changes in non- @MainActor isolated places, and in those cases we would need to jump through extra hoops.

13:48

Well, thanks to new isolation tools coming to Swift 6 we will be able to improve this function so that it captures the surrounding isolation context that observe is called, and then pass it down through all the layers, making it possible to call this from any isolated context, not just @MainActor . However those tools aren’t quite ready for primetime yet, so we will have to cover that a bit later. Adding more behavior

14:05

With just a little bit of work we have been able to build a tool that makes updating UIKit views far more expressive and more SwiftUI like. We can simply access any property on our model, update the corresponding UI component, and the tool will automatically track subscriptions and refresh itself when state changes. And it also subscribes to these state changes in the most minimal way possible. Only state accessed in the observe trailing closure will be subscribed to. Brandon

14:31

But also this demo is really, really simple. Let’s add on even more state and behavior to this feature so that we can see how this tool really starts to pay dividends as complexity grows.

14:45

We are going to increase the complexity of the CounterModel a slight amount by adding the ability for us to fetch a fact about the current count value in the model. This is going to mean tracking more state in the model and performing an asynchronous request.

14:59

Let’s start with the state. We will now hold onto an optional string that represents the fact that is loaded, if there is one, as well as a boolean that determines if the fact is currently loading or not: @Observable class CounterModel { var count = 0 var fact: String? var factIsLoading = false }

15:25

Next we will add an endpoint to the model for fetching a fact: func factButtonTapped() async { fact = nil factIsLoading = true defer { factIsLoading = false } do { fact = String( decoding: try await URLSession.shared.data( from: URL(string: "http://numberapi.com/\(count)")! ) .0, as: UTF8.self ) } catch { // TODO: Handle error } }

16:45

This creates a warning with our URLSession , but I think just a preconcurrency import of SwiftUI will take care of that: @preconcurrency import SwiftUI

17:03

And I’m going to add a Task.sleep in there to force the request to take a little bit of time, because the numbers API is typically quite fast, but I want to be able to really see the loading indicator. do { try await Task.sleep(for: .seconds(1)) fact = …

17:21

And finally we need to change the project’s App Transport Security settings, since we’re hitting an insecure API.

17:40

That’s all the work we want to do in the model. Let’s next see what it takes to update the SwiftUI view to make use of all of this new functionality. We will conditionally show the fact when it is present and a progress view when the fact is loading, and we will disable the entire form if the fact is loading: struct CounterView: View { let model: CounterModel var body: some View { Form { Text("\(model.count)") Button("Decrement") { model.count -= 1 } Button("Increment") { model.count += 1 } if let fact = model.fact { Text(fact) } else if model.factIsLoading { ProgressView().id(UUID()) } Button("Get fact") { Task { await model.factButtonTapped() } } } .disabled(model.factIsLoading) } }

18:32

And you may wonder why we’re spinning up this unstructured task in the view when the button is tapped. Well, the alternate place to spin up this task would be in the model, and unfortunately that can make things a lot more difficult, especially when it comes to testing. We prefer to keep the model in the structured world of concurrency as long as possible, with async endpoints, and then push the unstructured task to the very fringe of where the model is interacted with, in the view.

19:16

But now we can run things in the preview and they do work. We can increment and get a fact about the number, and while the request is in flight the activity indicator comes in and the buttons are disabled, and we can increment again, get another fact, it all just works.

19:32

It’s really amazing to see just how easy this is in SwiftUI. Now let’s do UIKit.

19:39

It of course is not going to be as easy, but I think everyone will be pleasantly surprised at just how nice it can be.

19:46

Let’s start by creating some views for the fact label, activity indicator, and fact button: let factLabel = UILabel() factLabel.numberOfLines = 0 let activityIndicator = UIActivityIndicatorView() activityIndicator.startAnimating() let factButton = UIButton( type: .system, primaryAction: UIAction { [weak self] _ in guard let self else { return } Task { await model.factButtonTapped() } } ) factButton.setTitle("Get fact", for: .normal)

20:11

And then we will add these views to the stack: let counterStack = UIStackView(arrangedSubviews: [ countLabel, decrementButton, incrementButton, factLabel, activityIndicator, factButton ])

20:20

And lastly we will update the observe trailing closure to populate the view components with data from the model: observe { [weak self] in guard let self else { return } countLabel.text = "\(model.count)" activityIndicator.isHidden = !model.factIsLoading factLabel.text = model.fact factLabel.isHidden = model.fact == nil decrementButton.isEnabled = !model.factIsLoading incrementButton.isEnabled = !model.factIsLoading factButton.isEnabled = !model.factIsLoading }

22:01

That’s all it takes and now our UIKit view behaves just like the SwiftUI view.

22:20

It’s honestly kind of amazing. We have just one single place to bind all of the state in our model to our view, and the subscription to the model is handled automatically based on what fields are accessed in the model. We don’t have to worry about annotating the fields with @Published to make it explicit we want to observe those fields, and if there are any fields we aren’t touching in observe then their mutations will not cause the observe closure to be invoked at all.

22:54

There is a small bug in our feature right now. If we load a fact, and then increment or decrement, the fact stays there even though it is no longer a fact about the current count. The fix is very easy. We just need to clear out the fact when the increment or decrement buttons are tapped: func decrementButtonTapped() { count -= 1 fact = nil } func incrementButtonTapped() { count += 1 fact = nil }

23:40

With that small change we have fixed the bug in both the SwiftUI and UIKit versions of the feature. As soon as we tap the increment or decrement buttons the currently loaded fact will be immediately cleared out.

23:47

Now there is one thing people may not like about what we have done here. The observe closure is going to be called anytime any field accessed in it is mutated: observe { [weak self] in guard let self else { return } countLabel.text = "\(model.count)" activityIndicator.isHidden = !model.factIsLoading factLabel.text = model.fact factLabel.isHidden = model.fact == nil decrementButton.isEnabled = !model.factIsLoading incrementButton.isEnabled = !model.factIsLoading factButton.isEnabled = !model.factIsLoading }

24:00

This means if we only increment the count we are also going to set the factLabel and activityIndicator ’s isHidden property to true and all the buttons’ isEnabled property to true , even if those values didn’t change.

24:22

However, in practice this isn’t really a problem. The cost of a setting a button’s isEnabled to true when it is already true is not significant. We don’t have to worry about setting any of these properties repeatedly.

24:35

And this is how we treat SwiftUI too. You could have a complex view that uses many properties of a model, and if just a single property is mutated the entire view struct will be recreated. But in practice that is not a problem.

25:05

The only time we need to worry is if we are doing significant work, like reloading a table view’s data source: observe { … // tableView.reloadData() }

25:34

In that situation you would not want to reload the data source unless the items in the data source actually changed. And so in those situations you can start up a new observe closure to isolate that heavy work from the rest of the work. Animation

25:54

This is pretty cool stuff. We are able to write down a simple observable model that can be used in a similar manner in both a SwiftUI view and a UIKit view. This just goes to show that you are able to build your core business logic without a care in the world of how the data is going to be displayed to the user. It doesn’t matter if you are using UIKit, or SwiftUI, or AppKit, or even some theoretical 3rd party UI library that runs on Windows or Linux.

26:18

And that’s great, because ideally you shouldn’t have to think about view level concerns when building your models. The model is arguably the most important part of your application. It’s the thing that needs to interact with outside systems, transform data to present to the user, can be responsible for very important things such as payments or event tracking, or who knows what else! And so it would be a real bummer if we had to further complexify our domain by building it in a unique way depending on how we are constructing the view layer. Stephen

26:48

And we can push these tools even further to become even more powerful, but before doing that there is something that is not quite right about the observe method: and that is that it does not really play nicely with UIKit animations.

27:00

Let’s fix this issue before moving onto more powerful tools.

27:06

The way one animates things in UIKit is to use one of the static animate methods on UIView and then mutate some views on the inside of the trailing closure: UIView.animate(withDuration: 0.3) { factLabel.alpha = 1 }

27:24

And there’s really nothing stopping us from using this tool right in observe : UIView.animate(withDuration: 0.3) { if self.model.fact != nil { factLabel.alpha = 1 } else { factLabel.alpha = 0 } }

27:42

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

27:48

However, it’s also very crude. We are going to always perform this mutation, no matter how the mutation of fact occurred.

27:56

Sometimes we may want to be more precise and decide at the moment of mutating fact if we want to animate it or not.

28:03

And this is something that SwiftUI did a great job with. It gives us a tool that allows us to animate the mutation of our model , and then that trickles out to the view, rather only being able to describe the animation of the view directly. That tool is called withAnimation , can you can use it like so: withAnimation { model.fact = nil }

28:42

And parts of the view that use fact will automatically be animated. It’s pretty magical.

28:52

What if we could do the same in our model, but use UIView.animate instead? UIView.animate(withDuration: 0.3) { self.fact = nil } And then when the fact is loaded we could animate it too: let loadedFact = String( decoding: try await URLSession.shared.data( from: URL(string: "http://numberapi.com/\(count)")! ) .0, as: UTF8.self ) UIView.animate(withDuration: 0.3) { self.fact = loadedFact }

29:03

Unfortunately this does not work due to the thread hop we incur when using observe : } onChange: { Task { @MainActor in onChange(apply: apply) } }

29:16

By the time onChange is called we have exited the lexical scope of UIView.animate , and so any changes we make to UI elements will not be animated.

29:27

And we were forced to introduce this thread hop because unfortunately the observation framework does not yet give us a tool to be notified when a model is fully mutated, using didSet , and instead only notifies us when it is about to be mutated, using willSet .

29:40

So we do need this thread hop, and it means we need to propagate animation into this Task so that we can perform a UIView.animate inside if necessary: } onChange: { Task { @MainActor in UIView.animate(withDuration: 0.3) { onChange(apply: apply) } } } …but clearly it isn’t right for us to animate every change.

29:55

In order to do any kind of propagating of animations we have to take matters into our own hands. UIKit does not give us anyway of inspecting what current animation is being applied to UI mutations.

30:04

We will mimic SwiftUI’s withAnimation by providing a top-level function, and we will call it withUIAnimation : @MainActor func withUIAnimation( ) { }

30:20

It will be main actor since everything in UIKit is main actor.

30:24

This function will take an argument describing the kind of animation to perform, but sadly UIKit does not have a simple type for describing animations. Instead, the only way to describe an animation is by providing arguments to the UIView.animate method.

30:36

SwiftUI does have a proper Animation type, and it’s a simple value type that describes the details of the animation without actually performing the animation. We need something like that here.

30:48

So, let’s define a type called UIAnimation that will hold the details of the animation: struct UIAnimation { }

30:52

For now we will just hold the duration: import UIKit struct UIAnimation { var duration: TimeInterval } …but a full version of this API would also include things like delay, spring damping and more.

30:56

Then we can have withUIAnimation take a UIAnimation as an argument, and we will again mimic SwiftUI by making it optional and providing a default: @MainActor func withUIAnimation( _ animation: UIAnimation? = UIAnimation(duration: 0.3) ) { }

31:14

Next this function needs to be provided a 2nd argument that represents the trailing closure that will be executed inside the context of an animation: @MainActor func withUIAnimation( _ animation: UIAnimation? = UIAnimation(duration: 0.3), body: @escaping () -> Void ) { }

31:25

And the implementation of this function is straightforward: @MainActor func withUIAnimation( _ animation: UIAnimation? = UIAnimation(duration: 0.3), body: @escaping () -> Void ) { guard let animation else { body() return } UIView.animate(withDuration: animation.duration) { body() } }

31:48

And we now have a tool for performing UIKit animations that looks quite similar to SwiftUI.

31:53

However, we still aren’t propagating animations to the task created in observe . But at least we are now in a position to. When this body is executed, it synchronously invokes the onChange trailing closure of withObservationTracking .

32:12

So, if we were to set up a task local while executing body , that local would visible in the onChange trailing closure, and because unstructured tasks inherit task locals, it would even be visible inside the Task .

32:38

So let’s define a @TaskLocal that represents the current animation being applied: struct UIAnimation: Sendable { @TaskLocal fileprivate static var current: Self? var duration: TimeInterval }

32:35

And let’s set the task local when executing the body: UIAnimation.$current.withValue(animation) { body() }

32:51

And then in the Task we will use this animation when invoking apply : Task { @MainActor in if let animation = UIAnimation.current { UIView.animate(withDuration: animation.duration) { onChange(apply: apply) } } else { onChange(apply: apply) } }

33:12

That is all it takes and we can now animate our view by wrapping model mutations in withUIAnimation : withUIAnimation { self.fact = nil } … withUIAnimation { self.fact = loadedFact } And when we run the UIKit preview and fetch a fact, it animates in! Back-porting to iOS 13

33:29

This is looking pretty great. We now have a very versatile tool for easily observing changes in a model and updating the UI from the data in the model, and we can even individually animate certain parts of the view depending on what state changes.

33:43

Now of course this app is very simple, and we will soon get to more advanced topics, such as navigation, but there is still a little bit of foundational work we want to do before moving onto that. Brandon

33:53

First, because we are using Swift’s Observation work to power our observe tool, we are unfortunately limited to iOS 17 and later. And since iOS 17 has been out for only 9 months or so, it still doesn’t have critical adoption. In fact, according to Mixpanel trends, iOS 17 only accounts for 80% of their analytics data, and so there’s a chance that iOS 17 is actually lagging quite a bit. Now we’re not sure if Mixpanel data is trustworthy, but either way we feel that there are a lot of people out there who still want to support iOS 16 at the very least, and maybe even iOS 15.

34:28

So, wouldn’t it be great if we could use this wonderful new observation tool while still deploying to older versions of iOS?

34:36

Well, 5 months ago we released an open source library called Perception , which is a back-port of Swift’s observation tools that work going all the way back to iOS 13. Let’s bring that library into our project and see if can get our demo app working on older versions of iOS.

34:54

Let’s start by dropping the deployment target of our to iOS 16:

35:01

And we could have even gone back to iOS 13, but then we can’t use the fancy Swift-only @main entry point tools and some other SwiftUI fanciness, and so we will target a little bit higher. And further we don’t have any iOS 13 simulators to actually test with, but it should work. We do at least have an iOS 16.4 simulator, and so we will be using that in this episode.

35:22

With that change we of course have a bunch of compiler errors because none of Swift’s observation tools work in iOS 16. Instead we need to use our perception tools.

35:41

So, let import our Perception library: import Perception …and use Xcode’s SPM integration automatically add the library to our project.

35:54

Instead of using withObservationTracking we will use withPerceptionTracking : withPerceptionTracking { … } onChange: { … }

36:03

And instead of using the @Observable macro we will use the @Perceptible macro: @MainActor @Perceptible class CounterModel { … }

36:16

And then apparently the new #Preview macro doesn’t work for UIViewController s unless targeting iOS 17 for some reason, but we can define a little bit of library code that allows us to easily turn any UIViewController into a UIViewControllerRepresentable : struct UIViewControllerRepresenting< UIViewControllerType: UIViewController >: UIViewControllerRepresentable { let base: UIViewControllerType init(_ base: () -> UIViewControllerType) { self.base = base() } func makeUIViewController( context: Context ) -> UIViewControllerType { self.base } func updateUIViewController( _ uiViewController: UIViewControllerType, context: Context ) {} } And now we can do the following: #Preview("UIKit") { UIViewControllerRepresenting { CounterViewController(model: CounterModel()) } } And now the previews are running and they work exactly as before, but also that isn’t too impressive because Xcode previews always run on the newest iOS version.

37:44

To really test this let’s update the entry point to use the CounterViewController : @main struct ModernUIKitApp: App { var body: some Scene { WindowGroup { UIViewControllerRepresenting { CounterViewController(model: CounterModel()) } } } }

37:55

Running this in the iOS 16.4 simulator shows that it works exactly as it does when run in iOS 17. This means we can make use of this fancy new observation tool no matter which version of iOS we are deploying to, and it’s all thanks to our back-port of Swift 5.9’s observation tools.

38:30

In fact, in some sense the UIKit version of the app is actually a little simpler than the SwiftUI one now. Let’s alter the entry point to run the SwiftUI version: @main struct ModernUIKitApp: App { var body: some Scene { WindowGroup { CounterView(model: CounterModel()) // UIViewControllerRepresenting { // CounterViewController(model: CounterModel()) // } } } } …and run the app in the iOS 16.4 simulator. We will see that the feature no longer works, and we are met with some purple runtime warnings: Perceptible state was accessed but is not being tracked. Track changes to state by wrapping your view in a ‘WithPerceptionTracking’ view.

38:56

These warnings are letting us know there is something wrong with our code. In particular, in order to properly set up observation in SwiftUI views in iOS 16 and earlier, we must wrap the content of our view in this WithPerceptionTracking view: struct CounterView: View { let model: CounterModel var body: some View { WithPerceptionTracking { … } } }

39:32

And now when we run the app in the simulator it works just as before. So we have to put in a little bit of extra work for observation to work in the SwiftUI version of the app, but we think it’s well worth it to not have to wait until we can drop iOS 16 support to use the fancy new observation tools. Next time: state-driven navigation Brandon

40:00

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

40:16

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.

40:36

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.

40:49

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.

41:06

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 0283-modern-uikit-pt3 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 .