Video #289: Modern UIKit: UIControl Bindings
Episode: Video #289 Date: Jul 29, 2024 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep289-modern-uikit-uicontrol-bindings

Description
While we rebuilt SwiftUI bindings in UIKit to power state-driven navigation, that’s not all SwiftUI uses them for! Let’s see what it takes to power UIControls from model bindings. And finally, let’s ask “what’s the point?” by comparing the tools we’ve built over many episodes with the alternative.
Video
Cloudflare Stream video ID: 09629943adad1a433862aa955cadf3d8 Local file: video_289_modern-uikit-uicontrol-bindings.mp4 *(download with --video 289)*
References
- Discussions
- SwiftUI Binding Tips
- Scrumdinger
- SwiftUI Navigation
- CasePaths
- Clocks
- 0289-modern-uikit-pt9
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
Things just keep getting better and better.
— 0:07
We now have all of the tools necessary for modeling complex domains and navigation patterns in UIKit apps. If a feature has a well-defined, finite set of possible destinations, then we can model that situation with an enum. Or if we need to have a potentially unbounded combination of features on a stack, then we can model that situation with an array. And thanks to the observation tools in Swift we can build navigation APIs for UIKit that look shocking similar to the corresponding tools in SwiftUI. Brandon
— 0:34
But we are still not done.
— 0:36
Would you believe that there is still more we can squeeze out of the observation tools when it comes to UIKit? So far we have used the observe function to accomplish two main things:
— 0:44
First, we can update our UI components from the data in our observable model in a nice, concise way. We don’t have to explicitly subscribe to state, and instead can just freely access fields on the model and that will automatically subscribe us. And any fields we do not touch will not cause the view to be updated when they are mutated. Stephen
— 1:03
And second we can create navigation APIs that mimic what we have in SwiftUI. We can drive sheets, covers, popovers and alerts from optional and enum state, and we can drive drill-down navigation from flat arrays of data. Brandon
— 1:17
But there is another very common task one needs to do in UI applications that we haven’t yet broached, and that is UI components that require 2-way bindings, such as text fields, toggles, sliders and more. Let’s see what it takes to use one of those components in our application, see why it’s a bit of a pain, and then see what we can do to fix it. UIControl bindings
— 1:39
Currently we have a UILabel and two UIButton s for the counter, but UIKit provides a control right out of the box for dealing with this type of interface, and its called UIStepper . It gives you a basic interface with + and - buttons for incrementing and decrementing a value.
— 2:01
So, instead of two buttons for incrementing and decrementing we will have a single stepper: let counter = UIStepper( frame: .zero, primaryAction: UIAction { _ in } ) // let decrementButton = UIButton( // type: .system, // primaryAction: UIAction { [weak self] _ in // guard let self else { return } // model.decrementButtonTapped() // } // ) // decrementButton.setTitle("Decrement", for: .normal) // let incrementButton = UIButton( // type: .system, // primaryAction: UIAction { [weak self] _ in // guard let self else { return } // model.incrementButtonTapped() // } // ) // incrementButton.setTitle("Increment", for: .normal)
— 2:16
And we’ll put it in the stack: let counterStack = UIStackView(arrangedSubviews: [ countLabel, counter, // decrementButton, // incrementButton, factLabel, activityIndicator, factButton, settingsButton ])
— 2:21
And we will enable or disable the stepper instead of the buttons: // decrementButton.isEnabled = !self.model.factIsLoading // incrementButton.isEnabled = !self.model.factIsLoading counter.isEnabled = !self.model.factIsLoading
— 2:30
This compiles but of course it won’t work. We haven’t yet done anything to connect the stepper to our model so that when we tap the “+” or “-” buttons it mutates the count state.
— 2:38
To hook into the stepper’s action we need to implement the UIAction we are handing to its initializer: let counter = UIStepper( frame: .zero, primaryAction: UIAction { action in } )
— 2:47
The argument handed to the closure is the action invoked, and it has a property called sender that represents the control that sent the action: let counter = UIStepper( frame: .zero, primaryAction: UIAction { action in action.sender } )
— 2:54
It’s an Any? but we know it is the UIStepper we just constructed and so we can force cast it to a stepper: let counter = UIStepper( frame: .zero, primaryAction: UIAction { action in action.sender as! UIStepper } )
— 3:03
And steppers have a value property that is a Double , which we can convert to an Int : let counter = UIStepper( frame: .zero, primaryAction: UIAction { action in Int((action.sender as! UIStepper).value) } )
— 3:08
And then finally we can use that value to update our model: let counter = UIStepper( frame: .zero, primaryAction: UIAction { [weak self] action in self?.model.count = Int((action.sender as! UIStepper).value) } )
— 3:19
That does get the demo working again just as it did before. And actually, it even has some new behavior. If you tap and hold on one of the stepper buttons you will see that the count keeps going up, faster and faster, until it hits 100, which is its default max value, and the plus button is disabled to show we can’t go any higher. We can also tap and hold the minus button and the count will go down, faster and faster, until it hits the default 0 minimum value and the minus button is disabled.
— 3:56
So it’s kind of cool that UIStepper has this extra functionality, but also our code isn’t quite right yet. There’s another side of observation that we’re missing. When the UIStepper changes we update the model’s count, but we are not observing changes to model’s count and playing them back to the stepper.
— 4:18
To see this, we can add a reset button to the screen that updates the model count to zero: let resetButton = UIButton( type: .system, primaryAction: UIAction { [weak self] _ in self?.model.count = 0 } )
— 4:34
And we can add it to the stack: let counterStack = UIStackView(arrangedSubviews: [ countLabel, counter, resetButton, // decrementButton, // incrementButton, factLabel, activityIndicator, factButton, settingsButton ])
— 4:40
And now if we count up to to 8 and reset, the count goes back to zero but the stepper’s minus button is not disabled. And if we press the plus button, we immediately jump to 9. And so the stepper still thought it was at 8 when the reset button was pressed, the model never told it that it should be at zero.
— 5:08
So we have a little more work to do. We need to also observe updates to the count and reconfigure the counter accordingly: let counter = UIStepper( frame: .zero, primaryAction: UIAction { [weak self] action in self?.model.count = Int((action.sender as! UIStepper).value) } ) observe { [weak self] guard let self else { return } counter.value = Double(model.count) }
— 5:32
And finally the preview behaves the way we expect: we can count up, reset, the minus button is disabled, and when we count up again, we count up from zero and not the previous stepper value.
— 5:49
This is nice, but also this is a lot of work for something that should be quite simple. We are going to have to do this for every UIControl subclass that comes with UIKit that uses two-way bindings, such as: UIColorWell UIControl UIDatePicker UIPageControl UISlider UIStepper UISwitch UITextField
— 6:07
…as well as any custom subclasses of UIControl .
— 6:17
What if we could construct these kinds of controls in a syntax that is very similar to SwiftUI’s. For example, in SwiftUI a stepper that is connected to a model via a binding is as simple as this: Stepper("\(model.count)", value: $model.count)
— 7:09
Now SwiftUI’s stepper has deviated from UIKit’s in that it also incorporates the label into the stepper. We aren’t going to recreate that, but wouldn’t it be cool if we could simplify the creation of our count stepper to just this: let counter = UIStepper(frame: .zero, value: $model.count)
— 7:51
That means UIStepper would take a UIBinding as an argument, and internally it would handle observation and writing to the binding. That would be pretty amazing, and it doesn’t take much work to make it a reality.
— 8:09
Let’s create a new file to hold these helpers:
— 8:17
And let’s get in the scaffolding of a new convenience initializer that takes a UIBinding instead of a UIAction : import UIKit extension UIStepper { convenience init( frame: CGRect = .zero, value: UIBinding<Double> ) { self.init(frame: frame) } }
— 9:00
We have two jobs to take care of in here.
— 9:02
First we need to observe any changes to the binding so that we can replay those changes back to the control: observe { [weak self] in guard let self else { return } self.value = value.wrappedValue }
— 9:46
And second we need to listen for changes in the control so that we can replay those changes back to the binding: self.addAction( UIAction { [weak self] _ in guard let self else { return } value.wrappedValue = self.value }, for: .valueChanged )
— 10:13
And now our theoretical syntax is almost compiling: let counter = UIStepper(frame: .zero, value: $model.count) …we just have one small problem: Cannot convert value of type ‘ UIBinding<Int> ’ to expected argument type ‘ UIBinding<Double> ’
— 10:24
Steppers naturally deal with doubles, but the value we are stepping is an integer. We need to somehow transform a UIBinding of integers into a UIBinding of doubles. This situation comes up quite often in SwiftUI’s Binding too, and the standard way of transforming bindings is to define a computed property on the underlying value of the binding to transforms into the value you want.
— 10:54
For example, a simple computed property on Int for transforming into a Double : extension Int { fileprivate var toDouble: Double { get { Double(self) } set { self = Int(newValue) } } }
— 11:30
…instantly gives you the same property on bindings for transforming into a UIBinding of doubles: let counter = UIStepper( frame: .zero, value: $model.count.toDouble )
— 11:50
…all thanks to the magic of dynamic member lookup in Swift.
— 12:08
It’s worth noting that in the SwiftUI world there is an alternative way of performing this Int -to- Double transformation. SwiftUI made the decision to expose an initializer on Binding that takes a get and set closure. This allows you to essentially do whatever you want when constructing bindings.
— 13:14
For example, if you have a Int binding you can immediately convert it to an Double binding by just performing the transformations in the get and set closures: Binding<Double>( get: { Double(model.count) }, set: { model.count = Int($0) } )
— 13:44
This seems nice, but it is also very problematic. Bindings constructed in this way tend to break SwiftUI animations in very subtle ways. There is even an overload of Binding(get:set:) that takes a transaction: Binding.init( get: <#() -> _#>, set: <#(_, Transaction) -> Void#> )
— 14:03
…and this is part of the machinery that powers animations in SwiftUI. But even using this transaction to explicitly propagation the transaction to this ad hoc binding is sometimes not enough to salvage animations.
— 14:20
And it shouldn’t be too surprising that bindings created with the get:set: initializer are bound to cause havoc when used in SwiftUI, especially after what we have learned from building UIBinding from scratch for UIKit. While building UIBinding we went through great lengths to make the type Hashable so that it could be used to identify where the binding came from. We did this by first keeping track of the root object that was used to derive the binding, and then keeping track of the concatenated key path that dives deeper into the object to focus on a small part. Both of those pieces of data are Hashable , and hence bindings became Hashable for free.
— 15:14
However, if we were to allow this get:set: type of initializer in bindings we could completely destroy our ability to have Hashable bindings because closures are not Hashable . You can do absolutely anything in a closure, and there is no way to quantify that logic into a hashable piece of data. SwiftUI has decided to allow this, probably because it is so convenient, but at the cost of it being able to break some of the internal machinery that SwiftUI uses to track transactions and animations.
— 15:55
In general, bindings created with the get:set: initializer should be considered fraught, and should be avoided at all costs. If you have any explicitly constructed bindings like this in your codebase we highly recommend you convert them to a computed property and dynamic member lookup when you get the chance.
— 16:19
If you have complex work happening in the get and set of your binding then you may have trouble converting to a simple computed property. For example, what if you had another piece of state that modifies how one gets and sets state in the binding: var isFlagged = true @Binding var intValue = 0 let doubleValue = Binding<Double> { isFlagged ? Double(intValue) : 0 } set: { intValue = isFlagged ? Int($0) : 0 }
— 16:50
This does not cleanly translate to a computed property because it is not possible to get access to the isFlagged state: extension Int { var toDouble: { get { // No isFlagged state available } set { // No isFlagged state available } } }
— 17:14
However, this is possible to do, but you must use subscripts instead. Subscripts are like computed properties that allow you to take arguments for the get and set : extension Int { subscript(flaggedToDouble isFlagged: Bool) -> Double { get { isFlagged ? Double(intValue) : 0 } set { intValue = isFlagged ? Int($0) : 0 } } }
— 17:59
And the best part is that Swift also generates key paths to subscripts: \Int[flaggedToDouble: true] \Int[flaggedToDouble: false]
— 18:45
And this means that subscripts work with dynamic member lookup too: $model.count[flaggedToDouble: isFlagged]
— 19:40
OK, that was a little diversion into an important topic about bindings that isn’t discussed too often, but with those changes made everything is compiling, and the app should work exactly as it does before. State-driven focus
— 19:56
We can now use UIBinding s with UIControl s, just as one would do in SwiftUI. You can derive a binding from your model, hand that to a UIStepper control, and the stepper can then make whatever changes it wants to the binding and those changes will be immediately reflected in the model. And conversely, if the model makes any changes to the state it will be immediately reflected in the stepper.
— 20:18
And these tricks will work for any kind of UIControl subclass. It works for text fields, switches, sliders, segment controls, date pickers, and more. Stephen
— 20:28
There’s another powerful way bindings are used in SwiftUI and that is to control the focus of text fields. You are able to associate a binding and a value to a text field, and when the binding’s underlying value matches the other value, the text field will be focused. This is an incredibly powerful way to drive focus with state, and so it would be pretty nice if we could do the same in UIKit apps.
— 20:48
And we absolutely can, so let’s take a look.
— 20:52
First let’s theorize a syntax that we would like to use for focusing UITextField s in UIKit, and we will of course take a lot of inspiration from SwiftUI.
— 21:01
First of all we need a UITextField in our feature so that we even have something to focus. We will do this in the simplest way by just introducing some text state to our model: var text = ""
— 21:14
And then what we’d like to do is be able to create a UITextField whose text is tied to this text state in the model, just as we did with the UIStepper . Ideally it would look like this: let textField = UITextField(text: $model.text)
— 21:39
…but we haven’t created this convenience initializer yet like we did for UIStepper . So maybe we could copy and paste it and make a few changes for text fields: extension UITextField { convenience init( text: UIBinding<String> ) { self.init(frame: .zero) observe { [weak self] in guard let self else { return } self.text = text.wrappedValue } self.addAction( UIAction { [weak self] in guard let self else { return } text.wrappedValue = self.text }, for: .editingChanged ) } } Value of optional type ‘String?’ must be unwrapped to a value of type ‘String’
— 22:20
However this does not work because technically UITextField ’s text property is an optional string, not an honest string. We’re not really sure why it’s that way because it seems to treat nil as an empty string as far as we can tell. Perhaps its just a vestige from an Objective-C time. So we can simply coalesce to an empty string: text.wrappedValue = self.text ?? ""
— 22:39
And now our helper is compiling, and even our earlier usage is compiling.
— 22:44
So, this is great, but it would be a pain if we had to do all of this manual work for each kind of UIControl and for our own subclasses of UIControl . Luckily we can provide a helper that makes it much easier to adapt controls to take bindings.
— 23:01
What if we had a single bind helper defined on UIControl that would take care of the 2-way binding logic for us: extension UIControl { func bind( ) { } }
— 23:10
It would observe changes in a binding to play back to the control, and it would listen to changes in the control to play back to the binding.
— 23:17
So, sounds like this bind helper needs to take a UIBinding of some value as an argument: extension UIControl { func bind<Value>( _ binding: UIBinding<Value> ) { } }
— 23:29
And then further we need a way to abstractly set a value inside the control when the binding changes, and so we will model that with a key path to the underlying value in the control: extension UIControl { func bind<Value>( _ binding: UIBinding<Value>, to keyPath: ReferenceWritableKeyPath<Self, Value> ) { } } Covariant ‘Self’ or ‘Self?’ can only appear as the type of a property, subscript or method result; did you mean ‘UIControl’?
— 23:29
This doesn’t build, so maybe we can apply the fix-it: to keyPath: ReferenceWritableKeyPath<UIControl, Value>
— 23:54
And then finally we will need to know what event of the control we are binding on: extension UIControl { func bind<Value>( _ binding: UIBinding<Value>, to keyPath: ReferenceWritableKeyPath<UIControl, Value>, for event: UIControl.Event ) { } }
— 24:08
If we had such a helper then our UIStepper initializer could have been initialized like so: extension UIStepper { convenience init( frame: CGRect = .zero, value: UIBinding<Double> ) { self.init(frame: frame) self.bind(value, to: \.value, for: .valueChanged) } } And so that makes it much easier for us to enhance existing controls to work with UIBinding . Cannot convert value of type ‘ ReferenceWritableKeyPath<UIControl, (String) -> Any?> ’ to expected argument type ‘ ReferenceWritableKeyPath<UIControl, Double> ’
— 24:25
However we cannot write this helper as it is defined now because it is not possible to define key paths with a Self root when the Self is a class. But we can insert a protocol between us and the UIControl and then it is completely fine to form the key path: @MainActor private protocol _UIControl: UIControl {} extension UIControl: _UIControl {} extension _UIControl { … }
— 25:09
And now we can implement this method exactly as we did for the UIStepper , just a little more generically now that we have a key path for mutating the control: func bind<Value>( _ binding: UIBinding<Value>, to keyPath: ReferenceWritableKeyPath<Self, Value>, for event: UIControl.Event ) { observe { [weak self] in guard let self else { return } self[keyPath: keyPath] = binding.wrappedValue } self.addAction( UIAction { [weak self] _ in guard let self else { return } binding.wrappedValue = self[keyPath: keyPath] }, for: event ) }
— 25:59
Now we have a simple helper that can be used to easily add UIBinding support to any UIControl , whether it be the controls that ship with UIKit by default, or any custom UIControl subclass that one creates.
— 26:12
So let’s try it out for text fields: extension UITextField { convenience init( frame: CGRect = .zero, text: UIBinding<String> ) { self.init(frame: frame) self.bind(text, to: \.text, for: .editingChanged) } } Cannot convert value of type ‘ ReferenceWritableKeyPath<Self, String?> ’ to expected argument type ‘ ReferenceWritableKeyPath<Self, String> ’
— 26:26
This doesn’t work because of the optional mismatch we encountered earlier where we simply coalesced to an empty string.
— 26:35
But either way, we’d like to have our binding initializer of UITextField deal with regular strings, and so we need a way to transform the UIBinding<String> into a UIBinding<String?> . We can do this in the same way we converted a UIBinding<Int> into a UIBinding<Double> for the stepper, by first defining a computed property to perform the transformation: extension String { fileprivate var toOptional: String? { get { self } set { self = newValue ?? "" } } }
— 26:58
And then transforming the binding with this property and dynamic member lookup: private func bind(text: UIBinding<String>) { self.bind(text.toOptional, to: \.text, for: .editingChanged) }
— 27:08
Now this compiles, even our initialization of the text field: let textField = UITextField(text: $model.text)
— 27:15
Let’s give it a placeholder and a border: let textField = UITextField(text: $model.text) textField.placeholder = "Some text" textField.borderStyle = .bezel
— 27:23
…and let’s add the text field to the stack: let counterStack = UIStackView(arrangedSubviews: [ countLabel, counter, textField, factLabel, activityIndicator, factButton, ])
— 27:33
However, if we run the app we will see that it actually crashes with a strange message: Swift/KeyPath.swift:2792: Fatal error: could not demangle keypath type from ’So9UIStepperCXD’
— 27:45
This is actually just a Swift compiler bug. For whatever reason a key path cannot be constructed directly in this convenience initializer, but we can introduce a little bit of indirection with a method to work around this bug: extension UIStepper { convenience init( frame: CGRect = .zero, value: UIBinding<Double> ) { self.init(frame: frame) bind(value: value) } func bind(value: UIBinding<Double>) { self.bind(value, to: \.value, for: .valueChanged) } } extension UITextField { convenience init( frame: CGRect = .zero, text: UIBinding<String> ) { self.init(frame: frame) bind(text: text) } func bind(text: UIBinding<Double>) { self.bind(text.toOptional, to: \.text, for: .editingChanged) } }
— 28:46
Now the app runs without crashing.
— 28:54
And we have a textfield in our feature.
— 28:56
…and every change to the text field is automatically played back to the model. The model and UITextField are kept in sync, and it’s quite easy to do thanks to UIBinding .
— 29:05
However, we still have not made any headway towards our goal of state-driven focus. We haven’t even sketched out the theoretical syntax of how we would want it to work yet. Let’s do that now.
— 29:13
What if we had another piece of state in our model that determines if the text field is focused: var isTextFocused = false
— 29:22
And just to have some logic that focuses and unfocuses the text field, let’s have it so that when the count changes we focus the field when its an odd integer and we unfocus on an even integer: var count = 0 { didSet { isTextFocused = !count.isMultiple(of: 2) } }
— 29:44
This is of course a very silly thing to do, but it helps us drive home the point that we can have the state in our model determine how focus is controlled in our features, rather than letting that state be disconnected from our core logic by residing directly inside the UITextField .
— 29:57
And then what if you could invoke a method on UITextField to bind its focus to the value of this boolean: textField.bind(focus: $model.isTextFocused)
— 30:12
This means the text field would listen for changes to the boolean in order to know if it should focus, and similarly when focus changes on the text field, such as the user tapping into the text field or tapping into a different text field, then it should write true or false to the binding.
— 30:24
This is the theoretical syntax we would like to use, and so let’s now make it a reality. We can start by hopping over to the UIControl.swift file and adding a stub for the new method: extension UITextField { func bind(focus: UIBinding<Bool>) { } }
— 30:39
This gets everything compiling, but of course it isn’t accomplishing anything yet. We now need to start listening for state changes and focus changes so that we can keep our model and text field in sync.
— 30:50
Let’s start by listening for changes in the binding so that we can focus or unfocus depending on the wrapped value. This can be done by simply becoming or resigning first responder when the binding changes: func bind(focus: UIBinding<Bool>) { observe { [weak self] in guard let self else { return } if focus.wrappedValue { becomeFirstResponder() } else { resignFirstResponder() } } }
— 31:19
And just with that we already have something that is demo-able.
— 31:23
If we run the preview and count up and down with the stepper, we will see that on odd integers the text field focuses, and on even integers it unfocuses. This is showing that state in the model really can drive the focus state of a text field.
— 31:42
But it’s only half the story. We also need to listen for when the user manually focuses or unfocuses the text field so that we can play that change back to the binding. To do that we will hook into two events of the text field: editingDidBegin and editingDidEnd .
— 32:03
For example, we can add an action on editingDidBegin , which is triggered when the user focuses the text field, and then write true to the binding so that the model is notified of this event: addAction( UIAction { _ in focus.wrappedValue = true }, for: .editingDidBegin )
— 32:14
And similarly for when editing ends: addAction( UIAction { _ in focus.wrappedValue = false }, for: .editingDidEnd )
— 32:18
And amazingly that is all it takes. To see that this works let’s print the value of isTextFocused when it changes: var isTextFocused = false { didSet { print("isTextFocused", isTextFocused) } }
— 32:33
And let’s add another text field to the view so that we have something else to focus on: let counterStack = UIStackView(arrangedSubviews: [ countLabel, counter, textField, UITextField(), factLabel, activityIndicator, factButton, ])
— 32:47
Now when we run in the preview we can see that focusing and unfocusing the first text field causes the current focused state to be printed to the console. And it’s also still the case that counting up and down with the counter causes the focus to change too. This is proving that there is a 2-way binding happening between our model and the focus state of the text field.
— 33:23
Now technically there is a little more work to do to make this helper 100% correct, but that isn’t particularly interesting right now, so we aren’t going to discuss in these episodes. But if you are interested in how the final solution looks we encourage you to check out the code in the official release of these UIKit navigation tools. Comparison to legacy
— 33:38
We have now seemingly attacked every major problem when it comes to binding an observable model to a UIKit view controller:
— 33:45
We have any easy way to observe changes in the model so that we can update various UI elements on the screen.
— 33:51
We have a way to observe changes that drive navigation so that we can invoke one of UIKit’s APIs, such as present for sheets, popovers and alerts, or pushViewController for drill-downs.
— 34:01
We even support the two main flavors of navigation: tree-based navigation for when you want to be super concise with your domain by using enums to represent all possible destinations a feature can navigate to, or stack-based navigation when you need ultimate flexibility for complex navigation flows.
— 34:20
And then just now we even made it easy to incorporate 2-way bindings with controls, such as text fields, steppers, sliders and more. Brandon
— 34:30
We think that already this has made working with UIKit far simpler and more palatable. It allows us to clear the fog when it comes to figuring out how to respond to changes in the model and how to perform navigation, and that frees us to just concentrate on just the purely UIKit aspect for building views, which is probably the hardest part of dealing with UIKit anyway.
— 34:52
And we are very close to done with our series on modern UIKit. We have covered so much ground, and uncovered so many amazing tools along the way, but we may have forgotten what it would have been like to implement UIKit controllers without these tools.
— 35:06
Let’s not take this for granted, and let’s quickly remind ourselves what a non-modern version of our features would look like…
— 35:16
In order to compare our modern UIKit tools to how one would have to build features with vanilla UIKit, let’s bring back the demo app we built many episodes ago. If you remember, when we started this series we first gave a peek at the tools by building a Wi-Fi settings app. That gave us a chance to build something real world and show how the tools really shine.
— 35:38
One feature in particular shows off the 3 main types of tools we have built in this series. The “ConnectToNetworkFeature” is what is responsible for showing a text field for the user to enter their password, then they can submit the password, and if it is invalid an alert will be displayed.
— 36:02
The view controller that accomplishes this is a mere 62 lines of code. Sure, that’s quite a bit more than what it would take in SwiftUI, but we are doing quite a bit in these controllers. We are using a 2-way binding to bind the model to a UITextField : let passwordTextField = UITextField(text: $model.password)
— 36:09
This makes sure that the model and text field component stay in sync.
— 36:13
Then we are using the observe method to automatically observe changes to the model and update the UI: observe { [weak self] in guard let self else { return } passwordTextField.isEnabled = !model.isConnecting joinButton.isEnabled = !model.isConnecting activityIndicator.isHidden = !model.isConnecting } And this is observing the minimal amount of state changes in the model. Only the fields accessed in this trailing closure are observed. If there are other fields in the model, and they change, that will not cause the view to update.
— 36:25
And we are using our new navigation tools to show an alert when there is an error with the password: present( isPresented: $model.incorrectPasswordAlertIsPresented ) { [model] in let controller = UIAlertController( title: "Incorrect password for “\(model.network.name)”", message: nil, preferredStyle: .alert ) controller.addAction(UIAlertAction(title: "OK", style: .default)) return controller }
— 36:52
There is basically no logic whatsoever in our view controller. It is just doing the simplest thing possible to hook up the model to the view.
— 36:57
The real complexity of the feature lies in the model. This is where the state lives for the feature, as well as the side effects and behavior. And we want the logic and behavior in the model because it’s testable there. We can write a full suite of unit tests to make sure that all of its various logic and behavior is executing correctly. And as we’ve hinted a few times in this series, it would even be possible to extract out this model into its own package so that it can be used in a cross platform application.
— 37:25
So, this all looks great, but also we are quite spoiled by all of these fancy modern UIKit tools. What would it have looked if we forgot about these tools, and instead tried to rebuild this feature using what UIKit gives us out of the box?
— 37:40
Let’s create a new file called LegacyConnectToNetworkFeature.swift, and in this file we will forget that we have access to the wonderful Observation framework. Before observation we had access to the ObservableObject protocol and the @Published property wrapper. That made it possible for one to observe changes in our models, but it required a bit more work to set up.
— 38:14
I’m going to quickly copy-and-paste a legacy version of our ConnectToNetworkModel , but this time it is retrofitted to work as an ObservableObject : import Combine import UIKitNavigation import SwiftUI @MainActor class LegacyConnectToNetworkModel: ObservableObject { @Published var incorrectPasswordAlertIsPresented = false @Published var isConnecting = false @Published var onConnect: (Network) -> Void let network: Network @Published var password = "" init(network: Network, onConnect: @escaping (Network) -> Void) { self.onConnect = onConnect self.network = network } func joinButtonTapped() async { isConnecting = true defer { isConnecting = false } try? await Task.sleep(for: .seconds(1)) if password == "blob" { onConnect(network) } else { incorrectPasswordAlertIsPresented = true } } } We have to explicitly mark each field that can change with @Published so that it can be observed.
— 38:58
Next I’m going to copy-and-paste a view controller that holds onto a legacy model and does the minimal to set up the view hierarchy: final class LegacyConnectToNetworkViewController: UIViewController { let model: LegacyConnectToNetworkModel var cancellables: Set<AnyCancellable> = [] init(model: LegacyConnectToNetworkModel) { self.model = model super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBackground navigationItem.title = """ Enter the password for “\(model.network.name)” """ let passwordTextField = UITextField() passwordTextField.borderStyle = .line passwordTextField.isSecureTextEntry = true passwordTextField.becomeFirstResponder() let joinButton = UIButton( type: .system, primaryAction: UIAction { _ in Task { await self.model.joinButtonTapped() } } ) joinButton.setTitle("Join network", for: .normal) let activityIndicator = UIActivityIndicatorView(style: .medium) activityIndicator.startAnimating() let stack = UIStackView(arrangedSubviews: [ passwordTextField, joinButton, activityIndicator, ]) stack.axis = .vertical stack.translatesAutoresizingMaskIntoConstraints = false view.addSubview(stack) NSLayoutConstraint.activate([ stack.centerXAnchor.constraint(equalTo: view.centerXAnchor), stack.centerYAnchor.constraint(equalTo: view.centerYAnchor), stack.widthAnchor.constraint(equalToConstant: 200) ]) } }
— 39:44
All of this code looks identical to what we did in the modern version, but we also haven’t yet hooked up the model to the view. That is where things become much more of a pain.
— 40:02
Let’s start with what should be quite easy: observing changes in the model to update the UI. For example, when the model’s isConnecting boolean changes we want to enable or disable the password text field: passwordTextField.isEnabled = !model.isConnecting
— 40:36
But this line of code is only going to execute a single time. We need it to update each time the isConnecting state changes. We can do this by using the projected value $isConnecting to get access to the underlying publisher, and then sink ing on it to be notified when the state changes: model.$isConnecting .sink { isConnecting in passwordTextField.isEnabled = !isConnecting } Result of call to ‘sink(receiveValue:)’ is unused’
— 41:08
But we also need to keep track of the cancellable returned: var cancellables: Set<AnyCancellable> = [] … model.$isConnecting .sink { isConnecting in passwordTextField.isEnabled = !isConnecting } .store(in: &cancellables)
— 41:24
There is also an alternative way to write this code using an assign publisher operator: model.$isConnecting.map(!) .assign(to: \.isEnabled, on: passwordTextField) .store(in: &cancellables)
— 42:24
…though we are not sure it’s worth it. It requires you to perform a weird map operation to prepare the data, and you still need to worry about the cancellable.
— 42:30
But also this is only updating a single text field. We have more we need to update, such as the join button and activity indicator: model.$isConnecting.map(!) .assign(to: \.isEnabled, on: passwordTextField) .store(in: &cancellables) model.$isConnecting.map(!) .assign(to: \.isEnabled, on: joinButton) .store(in: &cancellables) model.$isConnecting.map(!) .assign(to: \.isHidden, on: activityIndicator) .store(in: &cancellables)
— 42:53
9 cryptic lines doing something that we were doing much more simply in 3 lines, and each of these statements is kind of flipped, instead of “set x ’s y to z ” it reads: “take z and assign it to y on x .”
— 43:23
So clearly this isn’t a great way to observe changes in the model, but it may be the easiest way to get the job done for right now.
— 43:29
Next let’s tackle the 2-way binding for the UITextField . It’s our responsibility to keep the text field in sync with our model, and so the first thing we can do is listen for editing changes to the text field to replay those changes to the model: passwordTextField.addAction( UIAction { [weak self] action in guard let self else { return } model.password = (action.sender as? UITextField)?.text ?? "" }, for: .editingChanged )
— 44:53
But then we also have to listen for changes the only way, where changes in the model need to be played back to the text field: model.$password .map(Optional.some) .assign(to: \.text, on: passwordTextField) .store(in: &cancellables)
— 45:26
And we have to do this strange map(Optional.some) dance since text fields take optional strings for whatever reason.
— 45:43
This is starting to get annoying, but we still aren’t done. We now need to show an alert when the incorrectPasswordAlertIsPresented state flips to true . And to do this the right way requires quite a bit of code: var alert: UIAlertController? model.$incorrectPasswordAlertIsPresented .removeDuplicates() .sink { [weak self] isPresented in guard let self else { return } if isPresented { alert = UIAlertController( title: "Incorrect password for “\(model.network.name)”", message: nil, preferredStyle: .alert ) alert?.addAction( UIAlertAction(title: "OK", style: .default) { [weak self] _ in guard let self else { return } model.incorrectPasswordAlertIsPresented = false } ) present(alert!, animated: true) } else { alert?.dismiss(animated: true) } } .store(in: &cancellables) This takes care of some edge cases when presenting, but also this somewhat simple since we are only presenting an alert. If we were presenting a sheet we would need to do more work to listen for when the sheet is manually dismissed by the user so that we could clean up state.
— 52:20
Our legacy feature is now complete, and is three times as long and much trickier to get right. Conclusion
— 53:29
I think it’s now becoming quite clear why we would want to use the navigation tools we have been building if you are creating UIKit features in your app. It lets you completely wipe away the complexity of state driven navigation, bindings and observation, and allows your controller and view to simply update when the model changes. We feel that it brings one of SwiftUI’s biggest superpowers to UIKit. Stephen
— 53:52
And we are now finally done with this series. In the past 7 episodes we have built many amazing tools for UIKit that brings the framework into the modern era, right alongside SwiftUI. We now have power state management and observation tools, we can drive presentation and controls from state, and we can use a succinct syntax that often looks quite similar to what one does in SwiftUI. Brandon
— 54:13
And along the way we hinted a few times at the fact that the ideas we are exploring here go far beyond UIKit. At a surface level we at the very least see that these techniques apply to both UIKit and SwiftUI equally. And so if you are building an app just for Apple’s platforms then you can build your features’ core domain without a care for view-related concerns, and then decide whether it is appropriate to use SwiftUI or UIKit once you get around to actually making the view. Stephen
— 54:39
But at a deeper level, these techniques also apply if you want to start building cross platform, such as for Windows, Linux, Wasm or something else. Going cross platform puts more responsibility on you to build you features in a way that does not depend on view-specific concepts since the view for iOS is going to be vastly different from the view for Windows.
— 55:00
But that topic will have to wait for another time.
— 55:03
Until next time! References SwiftUI Binding Tips Chris Eidhof • Jan 7, 2024 While SwiftUI provides a Binding(get:set:) initializer, there is a better way to transform bindings, and that is with dynamic member lookup. https://chris.eidhof.nl/post/swiftui-binding-tricks/ 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 0289-modern-uikit-pt9 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 .