Comment remplacer une action cible et déléguer par des fermetures

Apple propose diverses options de traitement des données et des événements dans les applications iOS. Le traitement des événements UIControl se produit via le modèle d'action cible. La documentation d' UIControl indique ce qui suit:
Le mécanisme d'action cible simplifie le code que vous écrivez pour utiliser les contrôles dans votre application
Regardons un exemple de traitement d'un clic de bouton:

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

La configuration et le traitement du clic sur le bouton sont situés séparément les uns des autres dans le code. Par conséquent, vous devez écrire plus de code que vous ne le souhaiteriez. Des problèmes surviennent avec une augmentation du nombre d'événements et de contrôles.

UITextField utilise le modèle délégué pour modifier et valider le texte . Nous ne nous attarderons pas sur les avantages et les inconvénients de ce modèle, en savoir plus ici .

Diverses méthodes de traitement des données dans un projet conduisent souvent au fait que le code devient plus difficile à lire et à comprendre. Cet article explique comment regrouper tout dans un seul style à l'aide de la syntaxe de fermeture pratique.

Pourquoi est-ce nécessaire


Au départ, nous avons proposé des solutions prêtes à l'emploi sur GitHub et avons même utilisé les fermetures pendant un certain temps . Mais au fil du temps, nous avons dû abandonner la solution tierce, car nous y avons découvert des fuites de mémoire. Et certaines de ses fonctionnalités nous ont semblé inconfortables. Ensuite, il a été décidé d'écrire votre propre solution.

Nous serions très heureux du résultat lorsque, à l'aide de fermetures, nous pourrions écrire ceci:

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

Objectifs de base:

  • Dans la fermeture, donnez accès à textField, tout en conservant le type d'origine. Ceci afin de manipuler l'objet source lors de fermetures, par exemple, en cliquant sur un bouton pour afficher un indicateur dessus sans transtypage de type.
  • , . , .touchUpInside onTap { }, shouldChangeCharacters UITextField , .


L'idée principale est que nous aurons un objet observateur qui interceptera tous les messages et provoquera des fermetures.

Tout d'abord, nous devons décider comment garder l'observateur. Swift nous donne le pouvoir de choisir. Par exemple, nous pouvons créer un objet singleton supplémentaire qui stockera un dictionnaire, où la clé est l'identifiant unique des objets observés et la valeur est l'observateur lui-même. Dans ce cas, nous devrons gérer le cycle de vie des objets manuellement, ce qui peut entraîner des fuites de mémoire ou une perte d'informations. Vous pouvez éviter de tels problèmes si vous stockez des objets en tant qu'objets associés.

Créez un protocole ObserverHolder avec une implémentation par défaut afin que chaque classe conforme à ce protocole ait accès à l'observateur:

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

Il suffit maintenant de déclarer la conformité au protocole pour UIControl:

extension UIControl: ObserverHolder { }

UIControl (et tous les descendants, y compris UITextField) a une nouvelle propriété où l'observateur sera stocké.

Exemple UITextFieldDelegate


L'observateur sera le délégué d'UITextField, ce qui signifie qu'il doit se conformer au protocole UITextFieldDelegate. Nous avons besoin d'un type générique T afin de sauvegarder le type d'origine de UITextField. Un exemple d'un tel objet:

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

Chaque méthode de délégué devra être clôturée séparément. À l'intérieur de ces méthodes, nous allons convertir le type en T et provoquer la fermeture correspondante. Nous allons ajouter le code TextFieldObserver, et pour l'exemple, nous ajouterons une seule méthode:

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

Nous sommes prêts à écrire une nouvelle interface avec fermetures:

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

Quelque chose s'est mal passé, le compilateur lance une erreur:
'Self' n'est disponible que dans un protocole ou à la suite d'une méthode dans une classe; vouliez-vous dire 'UITextField'
Un protocole vide nous aidera, dans l'extension duquel nous écrirons une nouvelle interface pour UITextField, tout en restreignant Self:

protocol HandlersKit { }

extension UIControl: HandlersKit { }

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

Le code se compile, il reste à créer un TextFieldObserver et à le désigner comme délégué. De plus, si l'observateur existe déjà, vous devez le mettre à jour afin de ne pas perdre d'autres fermetures:

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

Génial, maintenant ce code fonctionne et est prêt à l'emploi, mais il peut être amélioré. Nous allons créer et mettre à jour TextFieldObserver dans une méthode distincte, seule l'attribution de la fermeture sera différente, que nous passerons sous la forme d'un bloc. Mettez à jour le code existant dans l'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
    }
}

Améliorations supplémentaires


Ajoutez la possibilité d'enchaîner les méthodes. Pour ce faire, chaque méthode doit retourner Self et avoir l'attribut @discardableResult:

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

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

Dans une fermeture, l'accès à UITextField n'est pas toujours nécessaire, et pour que dans de tels endroits vous n'ayez pas à écrire ` _ in` à chaque fois , nous ajoutons une méthode avec le même nom, mais sans le Self requis:

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

Grâce à cette approche, vous pouvez créer des méthodes plus pratiques. Par exemple, changer le texte avec UITextField est parfois plus pratique lorsque le texte final est connu:

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

Terminé! Dans les exemples présentés, nous avons remplacé une méthode UITextFieldDelegate et pour remplacer les méthodes restantes, nous devons ajouter des fermetures à TextFieldObserver et à l'extension du protocole HandlersKit par le même principe.

Remplacement de l'action-cible par la fermeture


Il convient de noter que le stockage d'un observateur pour l'action-cible et le délégué sous cette forme la complique, nous vous recommandons donc d'ajouter un autre objet associé à l'UIControl pour les événements. Nous allons stocker un objet séparé pour chaque événement, un dictionnaire est parfait pour une telle tâche:

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

N'oubliez pas d'ajouter l'implémentation par défaut pour EventsObserverHolder, créez un dictionnaire vide immédiatement dans le getter:

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

L'observateur sera la cible d'un événement:

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

Dans un tel objet, il suffit de stocker une fermeture. À la fin de l'action, comme dans TextFieldObserver, nous présentons le type de l'objet et provoquons une fermeture:

private let handler: (T) -> Void

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

Déclarez la conformité du protocole pour UIControl:

extension UIControl: HandlersKit, EventsObserverHolder { }

Si vous avez déjà remplacé les délégués par des fermetures, vous n'avez pas besoin de faire correspondre à nouveau HandlersKit.
Il reste à écrire une nouvelle interface pour UIControl. Dans la nouvelle méthode, créez un observateur et enregistrez-le dans le dictionnaire eventsObserver à l'aide de la clé 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
    }
}

Vous pouvez compléter l'interface pour les événements fréquemment utilisés:

extension HandlersKit where Self: UIButton {

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

Sommaire


Hourra, nous avons réussi à remplacer l'action-cible et les délégués par des fermetures et à obtenir une interface unique pour les contrôles. Il n'est pas nécessaire de penser aux fuites de mémoire et de capturer les commandes elles-mêmes lors des fermetures, car nous y avons un accès direct.

Code complet ici: HandlersKit . Il existe d'autres exemples dans ce référentiel pour: UIControl, UIBarButtonItem, UIGestureRecognizer, UITextField et UITextView.

Pour un aperçu plus approfondi du sujet, je propose également de lire l'article sur EasyClosure et de regarder la solution au problème de l'autre côté.

Nous apprécions les commentaires dans les commentaires. Jusqu'à!

All Articles