Cómo reemplazar la acción objetivo y delegar con cierres

Apple ofrece varias opciones para procesar datos y eventos en aplicaciones iOS. El procesamiento de eventos UIControl ocurre a través del patrón de acción de destino. La documentación para UIControl dice lo siguiente:
El mecanismo de acción objetivo simplifica el código que escribes para usar controles en tu aplicación
Veamos un ejemplo de procesamiento de un clic de botón:

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

La configuración y el procesamiento del clic del botón se encuentran por separado en el código. Por lo tanto, debe escribir más código del que desea. Surgen problemas con un aumento en el número de eventos y controles.

UITextField utiliza el patrón delegado para editar y validar texto . No nos detendremos en los pros y los contras de este patrón, lea más aquí .

Varios métodos de procesamiento de datos en un proyecto a menudo conducen al hecho de que el código se vuelve más difícil de leer y comprender. Este artículo descubrirá cómo llevar todo a un estilo único utilizando la conveniente sintaxis de cierre.

Por qué es necesario


Inicialmente, buscamos soluciones listas para usar en GitHub e incluso usamos Closures por algún tiempo . Pero con el tiempo, tuvimos que abandonar la solución de terceros, porque descubrimos pérdidas de memoria allí. Y algunas de sus características nos parecieron incómodas. Luego se decidió escribir su propia solución.

Estaríamos bastante contentos con el resultado cuando, usando cierres, pudiéramos escribir esto:

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

Metas básicas:

  • En el cierre, proporcione acceso a textField, manteniendo el tipo original. Esto es para manipular el objeto de origen en los cierres, por ejemplo, haciendo clic en un botón para mostrar un indicador en él sin tipo de conversión.
  • , . , .touchUpInside onTap { }, shouldChangeCharacters UITextField , .


La idea principal es que tendremos un objeto observador que interceptará todos los mensajes y provocará cierres.

Primero, debemos decidir cómo mantener al observador. Swift nos da el poder de elegir. Por ejemplo, podemos crear un objeto singleton adicional que almacenará un diccionario, donde la clave es la identificación única de los objetos observados, y el valor es el propio observador. En este caso, tendremos que gestionar el ciclo de vida de los objetos manualmente, lo que puede provocar pérdidas de memoria o pérdida de información. Puede evitar tales problemas si almacena objetos como objetos asociados.

Cree un protocolo ObserverHolder con una implementación predeterminada para que cada clase que cumpla con este protocolo tenga acceso al observador:

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
            )
        }
    }
}

Ahora es suficiente declarar el cumplimiento del protocolo para UIControl:

extension UIControl: ObserverHolder { }

UIControl (y todos los descendientes, incluido UITextField) tiene una nueva propiedad donde se almacenará el observador.

Ejemplo UITextFieldDelegate


El observador será el delegado de UITextField, lo que significa que debe cumplir con el protocolo UITextFieldDelegate. Necesitamos un tipo T genérico para guardar el tipo original de UITextField. Un ejemplo de tal objeto:

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

Cada método de delegado necesitará un cierre por separado. Dentro de tales métodos, convertiremos el tipo a T y provocaremos el cierre correspondiente. Agregaremos el código TextFieldObserver y, por ejemplo, agregaremos solo un método:

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)
}

Estamos listos para escribir una nueva interfaz con cierres:

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

Algo salió mal, el compilador arroja un error:
'Self' solo está disponible en un protocolo o como resultado de un método en una clase; quiso decir 'UITextField'
Un protocolo vacío nos ayudará, en cuya extensión escribiremos una nueva interfaz para UITextField, mientras restringimos Self:

protocol HandlersKit { }

extension UIControl: HandlersKit { }

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

El código se compila, queda por crear un TextFieldObserver y designarlo como delegado. Además, si el observador ya existe, debe actualizarlo para no perder otros cierres:

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
    }
}

Genial, ahora este código está funcionando y listo para usar, pero se puede mejorar. Crearemos y actualizaremos el TextFieldObserver en un método separado, solo la asignación del cierre será diferente, que transmitiremos como un bloque. Actualice el código existente en la extensión 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
    }
}

Mejoras adicionales


Agregue la capacidad de encadenar métodos. Para hacer esto, cada método debe devolver Self y tener el atributo @discardableResult:

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

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

En un cierre, el acceso a UITextField no siempre es necesario, y para que en tales lugares no tenga que escribir ` _ in` cada vez , agregamos un método con el mismo nombre, pero sin el Self requerido:

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

Gracias a este enfoque, puede crear métodos más convenientes. Por ejemplo, cambiar el texto con UITextField a veces es más conveniente cuando se conoce el texto final:

@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)
    }
}

¡Hecho! En los ejemplos mostrados, reemplazamos un método UITextFieldDelegate, y para reemplazar los métodos restantes, necesitamos agregar cierres a TextFieldObserver y a la extensión del protocolo HandlersKit por el mismo principio.

Reemplazar acción objetivo con cierre


Vale la pena señalar que almacenar un observador para la acción objetivo y el delegado en este formulario lo complica, por lo que recomendamos agregar otro objeto asociado al UIControl para eventos. Almacenaremos un objeto separado para cada evento, un diccionario es perfecto para tal tarea:

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

No olvide agregar la implementación predeterminada para EventsObserverHolder, cree un diccionario vacío inmediatamente en el getter:

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

El observador será un objetivo para un evento:

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)
    }
}

Es suficiente almacenar un cierre en dicho objeto. Al completar la acción, como en TextFieldObserver, presentamos el tipo del objeto y provocamos un cierre:

private let handler: (T) -> Void

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

Declarar cumplimiento de protocolo para UIControl:

extension UIControl: HandlersKit, EventsObserverHolder { }

Si ya ha reemplazado a los delegados con cierres, entonces no necesita volver a hacer coincidir HandlersKit.
Queda por escribir una nueva interfaz para UIControl. Dentro del nuevo método, cree un observador y guárdelo en el diccionario eventsObserver utilizando la clave 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
    }
}

Puede complementar la interfaz para eventos de uso frecuente:

extension HandlersKit where Self: UIButton {

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

Resumen


Hurray, logramos reemplazar la acción del objetivo y los delegados con cierres y obtener una única interfaz para los controles. No es necesario pensar en las pérdidas de memoria y capturar los controles en los cierres, ya que tenemos acceso directo a ellos.

Código completo aquí: HandlersKit . Hay más ejemplos en este repositorio para: UIControl, UIBarButtonItem, UIGestureRecognizer, UITextField y UITextView.

Para una visión más profunda del tema, también propongo leer el artículo sobre EasyClosure y ver la solución al problema desde el otro lado.

Agradecemos sus comentarios en los comentarios. ¡Hasta!

All Articles