Although UIAction class was first introduced in iOS 13, it was primarily used when creating system-based menus. With iOS 14, UIAction class can be used to configure UIKit’s various UIControl based elements such as UIButton, UITextField, UISwitch and many more, which ultimately helps us reduce boilerplate code. We’ll show you how this works below.

iOS 14’s UIAction to the rescue

Up until iOS 14, when we were programatically creating UIControl elements, we needed to apply target/action pattern for defining the desired action for selected control. We are required to pass an selector, essentially Objective-C compatible function, along with a target object to call that function on. 

Let me show you how we used that pattern to create, assign and observe action on the “Sign Up” button when it was tapped inside the ViewController class. 

Additionally we define button’s title and image as you can see from the code snippet below, which is not needed for the example as of right now, but later you are going to see how we can replace those methods by defining those properties within UIAction initializer.

class ViewController: UIViewController {
    private let someActivity: SomeActivity

    override func viewDidLoad() {
        let signUpButton = UIButton(type: .system)
        
        signUpButton.addTarget(self,
            action: #selector(signUp),
            for: .touchUpInside)
        
        signUpButton.setImage(UIImage(
            systemName: "sign.up"), for: .normal)
        signUpButton.setTitle("Sign Up", for: .normal)
        view.addSubview(signUpButton)
   
    }
    
    @objc private func signUp() {
        someActivity.startAnimating()
        ///...
    }
}

       

UIAction… in Action!

Even though the example from above is a perfectly valid way of creating actions for your UIControl elements, iOS 14 introduces a new way of doing things with UIAction class, that lets us perform event handling through closure wrapped in a UIAction instance. When an instance of UIAction is created, we pass it to UIButton’s initializer as a primaryAction. Let’s see it in action:


class ViewController: UIViewController {
    private let someActivity: SomeActivity

    override func viewDidLoad() {
        let signUpButton = UIButton(
            primaryAction: UIAction { [someActivity] _ in
                someActivity.startAnimating()
                ///...          
          }
        )
        
        signUpButton.setImage(UIImage(
            systemName: "sign.up"), for: .normal)

        signUpButton.setTitle("Sign Up", for: .normal)
        view.addSubview(signUpButton)
    }
}

Argument is being passed to our UIAction closure, which is essentially a reference to the action itself, but here we are using underscore to ignore it, as we dont need it right now for the purpose of this example.

UIAction Inits

UIAction class comes with two initializers, first is being used in our example as you can see in the code snippet above, and it takes a closure as its argument. Except offering an alternative way to target/action pattern, UIAction class lets us configure properties of our selected UIControl within our UIAction initializer, such as setting its title and image. Check this out:

class ViewController: UIViewController {
    private let someActivity: SomeActivity
   
    override func viewDidLoad() {
        let signUpAction = UIAction(
            title: "Sign Up",
            image: UIImage(systemName: "sign.up"),
            handler: { [someActivity] _ in
                someActivity.startAnimating()
            }
        )

        let signUpButton = UIButton(primaryAction: signUpAction)
        view.addSubview(signUpButton)
    }
}

Additionally, you can set your newly created UIAction instance on your UIControl element with addAction method. Depending on the circumstances we might find this code all over our codebase for configuring desired UIControl’s actions. To reduce boilerplate code and to avoid code duplication, we can define our own convenience initializers for UIControl elements through extensions that will help us create these configurations very easily. Let’s take a look at this UIButton extension:


typealias EmptyCallback = () -> Void

extension UIButton {
	convenience init(title: String = "",
                      image: UIImage? = nil,
                      handler: @escaping EmptyCallback) {
    	self.init(primaryAction: UIAction.init(title: title,
                                            image: image,
                                            handler: { _ in                     
                                               handler() 
          }
      ))
   }
}

We extended UIButton with a custom convenience initializer that accepts EmptyCallback closure, as well as optional title and image.


let signUpButton = UIButton(
    title: "Sign Up",
    image: UIImage(systemName: "sign.up"),
    handler: someActivity.startAnimating
)

Another advantage of our convenience API is that we can simply pass our someActivity model’s startAnimating function into our buttons handler closure. This is possible because Swift supports first class functions, which means we can pass any function as it was a closure, as long as its input and output type is the same as the closure that we are passing it to.

The power of UIAction

We saw how we can use the new iOS 14 UIAction initializers for creating actions for our UIButton instance. I showed you only how to use it with an instance of only one type, but don’t be mistaken, it can be used with any other UIControl subclass. For example with UISwitch:

let switcher = UISwitch.init(frame: .zero, primaryAction: UIAction.init(title: "Switch me!", image: nil, handler: { action in let switcher = action.sender as! UISwitch
///...
}))

Only “downside” of this approach is that we have to type-cast our action’s sender to UISwitch or any other UIControl subclass we are dealing with, so that we can use its value/properties which is not really “swifty”. But no worries, we can fix this by introducing another convenience initializer in extension of UISwitch class.


extension UISwitch {
   convenience init(handler: @escaping (Bool) -> Void) {
    	self.init(frame: .zero, primaryAction: UIAction { act in
        	let switcher = act.sender as! Self
        	handler(switcher.isOn)
    	})
   }
}

After we extended the UISwitch class with our own convenience initializer, we can easily create our UISwitch instance with the returned value in its closure block. Everytime its value changes, our block is being executed.

let switcher = UISwitch.init { value in
    print(value)
}

Here you go! Easy and simple! But beware, we need to be careful while implementing UIAction handlers to not introduce retain cycles that can originate from closures. 

All in all, the new way of creating actions for UIControl is really cool and will be used in the future by developers  for sure, potentially even replacing the old target/action pattern in cases where we don’t need to access self to perform our event handling logic.

I hope you enjoyed the article and that you learned something new. Thanks for reading, and stay tuned for more stuff like this!