The target-action mechanism simplifies the code that you write to use controls in your app
Посмотрим на пример обработки нажатия на кнопку:
private func setupButton() { let button = UIButton() button.addTarget(self, action: #selector(buttonTapped(_:)), for: .touchUpInside) } // Где-то дальше в классе @objc private func buttonTapped(_ sender: UIButton) { }
Конфигурация и обработка нажатия кнопки расположены в коде отдельно друг от друга. Поэтому приходится писать больше кода, чем хотелось бы. Проблемы возникают при увеличении количества событий и контролов.
Для редактирования и валидации текста у UITextField используется паттерн delegate. Не будем останавливаться на плюсах и минусах этого паттерна, подробнее почитайте здесь.
Различные способы обработки данных в одном проекте зачастую приводят к тому, что код становится труднее читать и понимать. В этой статье разберемся, как привести всё к единому стилю, используя удобный синтаксис замыканий.
Зачем это нужно
Изначально мы прошлись по готовым решениям на GitHub и даже некоторое время использовали Closures. Но со временем нам пришлось отказаться от стороннего решения, потому что обнаружили там утечки памяти. Да и некоторые его особенности нам показались неудобными. Тогда и было решено писать собственное решение.
Нас вполне устроил бы результат, когда при использовании замыканий мы могли бы написать так:
textField.shouldChangeCharacters { textField, range, string in return true }
Основные цели:
- В замыкании обеспечить доступ к textField, сохранив при этом исходный тип. Это для того, чтобы в замыканиях манипулировать исходным объектом, например, по нажатию на кнопку показать на ней активити индикатор без приведения типа.
- Добавить новые, удобные методы. Часто используемые события, например .touchUpInside для кнопки заменить компактным onTap { }, а shouldChangeCharacters для UITextField дополнить методом, в котором есть доступ к финальному тексту.
Как это работает
Основная идея заключается в том, что у нас появится объект-наблюдатель, который будет перехватывать все сообщения и вызывать замыкания.
Для начала мы должны решить, как хранить наблюдателя. Swift предоставляет нам право выбора. Например, мы можем создать дополнительный объект-синглтон, который будет хранить словарь, где ключ — это уникальный id наблюдаемых объектов, а значение — сам наблюдатель. В таком случае нам придется управлять жизненным циклом объектов вручную, что может привести к утечкам памяти или потере информации. Избежать таких проблем можно, если хранить объекты как associated objects.
Создадим протокол ObserverHolder с реализацией по умолчанию, чтобы каждый класс, который соответствуем этому протоколу, имел доступ к наблюдателю:
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 ) } } }
Теперь достаточно объявить соответствие протоколу для UIControl:
extension UIControl: ObserverHolder { }
UIControl (и все наследники, включая UITextField) имеет новое свойство, где будет храниться наблюдатель.
Пример UITextFieldDelegate
Наблюдатель и будет делегатом для UITextField, а это значит, что он должен соответствовать протоколу UITextFieldDelegate. Дженерик тип T понадобится нам для того, чтобы исходный сохранить тип UITextField. Пример такого объекта:
final class TextFieldObserver<T: UITextField>: NSObject, UITextFieldDelegate { init(textField: T) { super.init() textField.delegate = self } }
Для каждого метода делегата понадобится отдельное замыкание. Внутри таких методов будем приводить тип к T и вызвать соответствующее замыкание. Дополним код TextFieldObserver, а для примера добавим лишь один метод:
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) }
Мы готовы к написанию нового интерфейса с замыканиями:
extension UITextField { func shouldChangeCharacters(handler: @escaping (Self, NSRange, String) -> Bool) { } }
Что-то пошло не так, компилятор выдает ошибку:
‘Self’ is only available in a protocol or as the result of a method in a class; did you mean ‘UITextField’
Нам поможет пустой протокол, в extension которого мы и будем писать новый интерфейс к UITextField, ограничив при этом Self:
protocol HandlersKit { } extension UIControl: HandlersKit { } extension HandlersKit where Self: UITextField { func shouldChangeCharacters(handler: @escaping (Self, NSRange, String) -> Bool) { } }
Код компилируется, осталось создать TextFieldObserver и назначить его делегатом. При этом, если наблюдатель уже существует, то нужно обновить его, чтобы не потерять другие замыкания:
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 } }
Отлично, теперь этот код функционирует и готов к использованию, однако его можно улучшить. Создание и обновление TextFieldObserver вынесем в отдельный метод, отличаться будет только присваивание замыкания, которое мы будет передавать в виде блока. Обновим существующий код в 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 } }
Дополнительные улучшения
Добавим возможность выстраивать методы в цепочку. Для этого каждый метод должен возвращать Self и иметь атрибут @discardableResult:
@discardableResult public func shouldChangeCharacters( handler: @escaping (Self, NSRange, String) -> Bool ) -> Self private func updateObserver(_ update: (TextFieldObserver<Self>) -> Void) -> Self { ... return self }
В замыкании не всегда нужен доступ к UITextField, и чтобы в таких местах каждый раз не приходилось писать `_ in`, добавим метод с тем же неймингом, но без обязательного Self:
@discardableResult func shouldChangeCharacters(handler: @escaping (NSRange, String) -> Void) -> Self { shouldChangeCharacters { handler($1, $2) } }
Благодаря такому подходу можно создавать более удобные методы. Например, с изменением текста UITextField иногда удобнее работать, когда известен финальный текст:
@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) } }
Готово! В показанных примерах мы заменили один метод UITextFieldDelegate, а для замены остальных методов нужно добавить замыкания в TextFieldObserver и в extension протокола HandlersKit по тому же принципу.
Замена target-action замыканием
Стоит заметить, что хранение одного наблюдателя для target-action и делегата в таком виде усложняет его, поэтому рекомендуем добавить еще один associated object к UIControl для событий. Под каждое событие будем хранить отдельный объект, для такой задачи отлично подходит словарь:
protocol EventsObserverHolder: AnyObject { var eventsObserver: [UInt: Any] { get set } }
Не забудьте добавить реализацию по умолчанию для EventsObserverHolder, пустой словарь создадим сразу в геттере:
get { objc_getAssociatedObject(self, &observerAssociatedKey) as? [UInt: Any] ?? [:] }
Наблюдатель будет таргетом для одного события:
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) } }
В таком объекте достаточно хранить одно замыкание. При совершении действия, как и в TextFieldObserver, приводим тип объекта и вызываем замыкание:
private let handler: (T) -> Void @objc private func eventHandled(_ sender: UIControl) { if let sender = sender as? T { handler(sender) } }
Объявим соответствие протоколам для UIControl:
extension UIControl: HandlersKit, EventsObserverHolder { }
Если вы уже заменили делегаты на замыкания, то повторно соответствовать HandlersKit не нужно.
Осталось написать новый интерфейс для UIControl. Внутри нового метода создадим наблюдателя и сохраним его в словарь eventsObserver по ключу 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 } }
Можно дополнить интерфейс для часто используемых событий:
extension HandlersKit where Self: UIButton { @discardableResult func onTap(handler: @escaping (Self) -> Void) -> Self { on(.touchUpInside, handler: handler) } }
Итоги
Ура, нам удалось заменить target-action и делегаты на замыкания и получить единый интерфейс для контролов. Не нужно задумываться об утечках памяти и захватывать в замыкания сами контролы, так как у нас есть к ним прямой доступ.
Полный код здесь: HandlersKit. В этом репозитории есть больше примеров для: UIControl, UIBarButtonItem, UIGestureRecognizer, UITextField и UITextView.
Для более глубокого погружения в тему предлагаю также ознакомиться со статьей про EasyClosure и взглянуть на решение проблемы с другой стороны.
Будем рады обратной связи в комментариях. Пока!
ссылка на оригинал статьи https://habr.com/ru/company/hh/blog/494620/
Добавить комментарий