Как заменить target-action и delegate замыканиями
Apple предоставляет различные варианты обработки данных и событий в iOS приложениях. Обработка событий UIControl происходит через паттерн target-action. В документации к UIControl написано следующее:
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: 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 {
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) -> Void) {
if let textFieldObserver = observer as? TextFieldObserver {
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) -> 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: 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 и взглянуть на решение проблемы с другой стороны.
Будем рады обратной связи в комментариях. Пока!