Weekly Swift articles, podcasts and tips by John Sundell.

Creating closure-based UI controls using UIAction

Published on 15 Sep 2020

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! 🚀

Support Swift by Sundell by checking out this sponsor:

Architecting SwiftUI apps with MVC and MVVM
Architecting SwiftUI apps with MVC and MVVM

Architecting SwiftUI apps with MVC and MVVM: Although you can create an app simply by throwing some code together, without best practices and a robust architecture, you’ll soon end up with unmanageable spaghetti code. Learn how to create solid and maintainable apps with fewer bugs using this free guide.