Creating closure-based UI controls using UIAction
When it was first introduced as part of iOS 13, the UIAction
class was primarily used when constructing system-provided menus, but in iOS 14 it can now also be used to configure UIKit’s various UIControl
-based views — such as UIButton
, UISlider
and UISwitch
.
Ever since the early days of iOS (before it was even called “iOS” to begin with), we’ve been able to observe different UIControl
events using the target/action pattern — which requires us to pass an Objective-C-compatible selector, along with a target object to call that selector on. For example, here’s how we might use that pattern to observe when a “Start” button within an ActivityViewController
was tapped:
class ActivityViewController: UIViewController {
private let activity: Activity
...
override func viewDidLoad() {
let startButton = UIButton(type: .system)
startButton.addTarget(self,
action: #selector(startActivity),
for: .touchUpInside
)
startButton.setImage(UIImage(
systemName: "play.circle.fill"
), for: .normal)
startButton.setTitle("Start", for: .normal)
view.addSubview(startButton)
...
}
@objc private func startActivity() {
activity.start()
}
}
The above approach works perfectly fine, of course, but in iOS 14 we have another option — and that’s to instead perform our event handling using a closure wrapped in a UIAction
instance, which we then pass as our button’s primaryAction
when creating it:
class ActivityViewController: UIViewController {
private let activity: Activity
...
override func viewDidLoad() {
let startButton = UIButton(
primaryAction: UIAction { [activity] _ in
activity.start()
}
)
startButton.setImage(UIImage(
systemName: "play.circle.fill"
), for: .normal)
startButton.setTitle("Start", for: .normal)
view.addSubview(startButton)
...
}
...
}
Above we’re using an underscore to ignore the argument passed into our UIAction
closure, which is a reference to the action itself.
That’s already quite an improvement for cases like the one above, when we don’t need a reference to self
in order to perform our event handling logic. However, UIAction
offers a lot more than just a closure-based alternative to the target/action pattern — since it also lets us configure properties such as what title and image that a given control should have — like this:
class ActivityViewController: UIViewController {
private let activity: Activity
...
override func viewDidLoad() {
let startAction = UIAction(
title: "Start",
image: UIImage(systemName: "play.circle.fill"),
handler: { [activity] _ in
activity.start()
}
)
let startButton = UIButton(primaryAction: startAction)
view.addSubview(startButton)
...
}
...
}
Now, depending on what kind of app that we’re working on, we might repeat some of the above kind of configuration code quite a lot, but now that we have a built-in closure-based API at our disposal, we can build our own lightweight extensions on top of that in order to make it much easier to apply such common configurations.
For example, here’s how we could extend UIButton
with a convenience initializer that takes a simple () -> Void
handler closure, as well as an optional title and image:
extension UIButton {
convenience init(title: String = "",
image: UIImage? = nil,
handler: @escaping () -> Void) {
self.init(primaryAction: UIAction(
title: title,
image: image,
handler: { _ in
handler()
}
))
}
}
Besides reducing boilerplate, a really neat advantage of the above convenience API is that we can now pass our Activity
model’s start
method directly as our button’s handler closure — like this:
let startButton = UIButton(
title: "Start",
image: UIImage(systemName: "play.circle.fill"),
handler: activity.start
)
The above works thanks to the fact that Swift supports first class functions, which lets us pass any function as if it was a closure, given that its input and output types match that of the closure argument that we’re passing it to.
So UIAction
is likely going to become a quite popular way to configure UIButton
event handlers, but the cool thing is that it can also be used with any other UIControl
subclass — such as UISlider
:
let slider = UISlider(
frame: .zero,
primaryAction: UIAction { action in
let slider = action.sender as! UISlider
let value = slider.value
// Handle value
...
}
)
A UIAction
can also be attached to a control after it has been created using the addAction
method.
However, while it’s really nice that we can once again observe one of our controls using a built-in closure-based API, the fact that we have to type-cast the action’s sender
to UISlider
in order to retrieve its value isn’t very elegant (or “swifty”) — so let’s see if we can fix that problem using another convenience initializer, such as this one:
extension UISlider {
typealias Value = Float
convenience init(handler: @escaping (Value) -> Void) {
self.init(
frame: .zero,
primaryAction: UIAction { action in
let slider = action.sender as! Self
handler(slider.value)
}
)
}
}
Note how we’re using a typealias
above to add a bit more contextual meaning to our closure’s input type. To learn more about that approach, check out “The power of type aliases in Swift”.
With the above in place, we can now easily create a closure-based UISlider
like this:
let slider = UISlider { value in
// Handle value
...
}
Really nice! While it’s been quite common for developers to extend UIKit with custom closure-based APIs for years, now having such an API built into all UIControl
subclasses is definitely great news.
However, we might still want to stick to using the target/action pattern in situations in which we need access to self
, or when we need to execute a larger code block that would be better encapsulated as a method — but for simpler event handling code, the above new UIAction
-based suite of APIs should come very much in handy.
Thanks for reading! 🚀