How to replace target-action and delegate with closures

Apple provides various options for processing data and events in iOS applications. UIControl event processing occurs through the target-action pattern. The documentation for UIControl says the following:
The target-action mechanism simplifies the code that you write to use controls in your app
Let's look at an example of processing a button click:

private func setupButton() {
    let button = UIButton()
    button.addTarget(self, action: #selector(buttonTapped(_:)), for: .touchUpInside)
}
// -   
@objc private func buttonTapped(_ sender: UIButton) { }

The configuration and processing of the button click are located separately from each other in the code. Therefore, you have to write more code than you would like. Problems arise with an increase in the number of events and controls.

UITextField uses the delegate pattern to edit and validate text . We will not dwell on the pros and cons of this pattern, read more here .

Various methods of processing data in one project often lead to the fact that the code becomes more difficult to read and understand. This article will figure out how to bring everything to a single style using the convenient closure syntax.

Why is it necessary


Initially, we went about ready-made solutions on GitHub and even used Closures for some time . But over time, we had to abandon the third-party solution, because we discovered memory leaks there. And some of its features seemed uncomfortable to us. Then it was decided to write your own solution.

We would be quite happy with the result when, using closures, we could write this:

textField.shouldChangeCharacters { textField, range, string in
    return true
}

Basic goals:

  • In the closure, provide access to textField, while maintaining the original type. This is in order to manipulate the source object in closures, for example, by clicking on a button to show an indicator on it without type casting.
  • , . , .touchUpInside onTap { }, shouldChangeCharacters UITextField , .


The main idea is that we will have an observer object that will intercept all messages and cause closures.

First, we must decide how to keep the observer. Swift gives us the power to choose. For example, we can create an additional singleton object that will store a dictionary, where the key is the unique id of the observed objects, and the value is the observer himself. In this case, we will have to manage the life cycle of objects manually, which can lead to memory leaks or loss of information. You can avoid such problems if you store objects as associated objects.

Create an ObserverHolder protocol with a default implementation so that each class that conforms to this protocol has access to the observer:

protocol ObserverHolder: AnyObject {
    var observer: Any? { get set }
}

private var observerAssociatedKey: UInt8 = 0

extension ObserverHolder {
    var observer: Any? {
        get {
            objc_getAssociatedObject(self, &observerAssociatedKey)
        }
        set {
            objc_setAssociatedObject(
                self, 
                &observerAssociatedKey, 
                newValue, 
                .OBJC_ASSOCIATION_RETAIN_NONATOMIC
            )
        }
    }
}

Now it’s enough to declare compliance with the protocol for UIControl:

extension UIControl: ObserverHolder { }

UIControl (and all descendants, including UITextField) has a new property where the observer will be stored.

UITextFieldDelegate example


The observer will be the delegate for UITextField, which means that it must comply with the UITextFieldDelegate protocol. We need a generic type T in order to save the original type of UITextField. An example of such an object:

final class TextFieldObserver<T: UITextField>: NSObject, UITextFieldDelegate {
    init(textField: T) {
        super.init()
        textField.delegate = self
    }
}

Each delegate method will need a separate closure. Inside such methods, we will cast the type to T and cause the corresponding closure. We will add the TextFieldObserver code, and for the example we will add only one method:

var shouldChangeCharacters: ((T, _ range: NSRange, _ replacement: String) -> Bool)?

func textField(
    _ textField: UITextField,
    shouldChangeCharactersIn range: NSRange,
    replacementString string: String
) -> Bool {
    guard 
        let textField = textField as? T, 
        let shouldChangeCharacters = shouldChangeCharacters 
    else {
        return true
    }
    return shouldChangeCharacters(textField, range, string)
}

We are ready to write a new interface with closures:

extension UITextField {
    func shouldChangeCharacters(handler: @escaping (Self, NSRange, String) -> Bool) { }
}

Something went wrong, the compiler throws an error:
'Self' is only available in a protocol or as the result of a method in a class; did you mean 'UITextField'
An empty protocol will help us, in the extension of which we will write a new interface to UITextField, while restricting Self:

protocol HandlersKit { }

extension UIControl: HandlersKit { }

extension HandlersKit where Self: UITextField {
    func shouldChangeCharacters(handler: @escaping (Self, NSRange, String) -> Bool) { }
}

The code compiles, it remains to create a TextFieldObserver and designate it as a delegate. Moreover, if the observer already exists, then you need to update it so as not to lose other closures:

func shouldChangeCharacters(handler: @escaping (Self, NSRange, String) -> Bool) {
    if let textFieldObserver = observer as? TextFieldObserver<Self> {
        textFieldObserver.shouldChangeCharacters = handler
    } else {
        let textFieldObserver = TextFieldObserver(textField: self)
        textFieldObserver.shouldChangeCharacters = handler
        observer = textFieldObserver
    }
}

Great, now this code is functioning and ready to use, but it can be improved. We will create and update TextFieldObserver in a separate method, only the assignment of the closure will be different, which we will pass in the form of a block. Update existing code in extension HandlersKit:

func shouldChangeCharacters(handler: @escaping (Self, NSRange, String) -> Bool) {
    updateObserver { $0.shouldChangeCharacters = handler }
}

private func updateObserver(_ update: (TextFieldObserver<Self>) -> Void) {
    if let textFieldObserver = observer as? TextFieldObserver<Self> {
        update(textFieldObserver)
    } else {
        let textFieldObserver = TextFieldObserver(textField: self)
        update(textFieldObserver)
        observer = textFieldObserver
    }
}

Additional improvements


Add the ability to chain methods. To do this, each method must return Self and have the @discardableResult attribute:

@discardableResult
public func shouldChangeCharacters(
    handler: @escaping (Self, NSRange, String) -> Bool
) -> Self

private func updateObserver(_ update: (TextFieldObserver<Self>) -> Void) -> Self {
    ...
    return self
}

In a closure, access to UITextField is not always necessary, and so that in such places you do not have to write ` _ in` each time , we add a method with the same naming, but without the required Self:

@discardableResult
func shouldChangeCharacters(handler: @escaping (NSRange, String) -> Void) -> Self {
    shouldChangeCharacters { handler($1, $2) }
}

Thanks to this approach, you can create more convenient methods. For example, changing text with UITextField is sometimes more convenient when the final text is known:

@discardableResult
public func shouldChangeString(
    handler: @escaping (_ textField: Self, _ from: String, _ to: String) -> Bool
) -> Self {
    shouldChangeCharacters { textField, range, string in
        let text = textField.text ?? ""
        let newText = NSString(string: text)
            .replacingCharacters(in: range, with: string)
        return handler(textField, text, newText)
    }
}

Done! In the examples shown, we replaced one UITextFieldDelegate method, and to replace the remaining methods, we need to add closures to TextFieldObserver and to the extension of the HandlersKit protocol by the same principle.

Replacing target-action with closure


It is worth noting that storing one observer for the target-action and the delegate in this form complicates it, so we recommend adding another associated object to the UIControl for events. We will store a separate object for each event, a dictionary is perfect for such a task:

protocol EventsObserverHolder: AnyObject {
    var eventsObserver: [UInt: Any] { get set }
}

Do not forget to add the default implementation for EventsObserverHolder, create an empty dictionary immediately in the getter:

get {
    objc_getAssociatedObject(self, &observerAssociatedKey) as? [UInt: Any] ?? [:]
}

The observer will be a target for one event:

final class EventObserver<T: UIControl>: NSObject {
    init(control: T, event: UIControl.Event, handler: @escaping (T) -> Void) {
        self.handler = handler
        super.init()
        control.addTarget(self, action: #selector(eventHandled(_:)), for: event)
    }
}

In such an object, it is enough to store one closure. Upon completion of the action, as in TextFieldObserver, we present the type of the object and cause a closure:

private let handler: (T) -> Void

@objc private func eventHandled(_ sender: UIControl) {
    if let sender = sender as? T {
        handler(sender)
    }
}

Declare Protocol Compliance for UIControl:

extension UIControl: HandlersKit, EventsObserverHolder { }

If you have already replaced delegates with closures, then you do not need to match HandlersKit again.
It remains to write a new interface for UIControl. Inside the new method, create an observer and save it in the eventsObserver dictionary using the key event.rawValue:

extension HandlersKit where Self: UIControl {

    @discardableResult
    func on(_ event: UIControl.Event, handler: @escaping (Self) -> Void) -> Self {
        let observer = EventObserver(control: self, event: event, handler: handler)
        eventsObserver[event.rawValue] = observer
        return self
    }
}

You can supplement the interface for frequently used events:

extension HandlersKit where Self: UIButton {

    @discardableResult
    func onTap(handler: @escaping (Self) -> Void) -> Self {
        on(.touchUpInside, handler: handler)
    }
}

Summary


Hurray, we managed to replace target-action and delegates with closures and get a single interface for controls. There is no need to think about memory leaks and capture the controls themselves in closures, since we have direct access to them.

Full code here: HandlersKit . There are more examples in this repository for: UIControl, UIBarButtonItem, UIGestureRecognizer, UITextField and UITextView.

For a deeper insight into the topic, I also propose to read the article about EasyClosure and look at the solution to the problem from the other side.

We welcome feedback in the comments. Till!

All Articles