[Из песочницы] Программирование состояний в UIControl
Исследование проблемы
Как определено в документации UIControl — это класс, реализующий общее поведение для визуальных элементов, которые способны реагировать определенным способом на действия пользователя. А значит, менять визуальное представление, поведение, инициировать процессы и т.д. Что же для этого нужно иметь и как это реализовать? На первый вопрос есть очевидный ответ — состояния, и логика переходов между ними. Со вторым вопросом немного посложнее…
Решение проблемы
Многие простодушные разработчики, заканчивают тем, что создают метод, который обычно называется update () и пишут в нем эпопею в сослагательном наклонении, проще говоря:
if ... {
element1.property = value1
...
} else if ... {
element1.property = value2
...
} ...
Это еще куда не шло, код последовательный и читаемый. Но, если обезьяне попадается какая-то модная граната, все заканчивается еще плачевней:
RAC(element1, hidden) = [RACSignal combineLatest:@[
self.textField1.rac_textSignal
] reduce:^(NSString *password) {
return @((!password.length >= 1));
}];
RAC(element2, hidden) = [RACSignal combineLatest:@[
self.textField1.rac_textSignal
] reduce:^(NSString *password) {
return @(!(password.length >= 2));
}];
RAC(element3, hidden) = [RACSignal combineLatest:@[
self.textField1.rac_textSignal
] reduce:^(NSString *password) {
return @(!(password.length >= 3));
}];
RAC(element4, hidden) = [RACSignal combineLatest:@[
self.textField1.rac_textSignal
] reduce:^(NSString *password) {
if(password.length == PIN_LENGTH) {
[self activateNextField];
return @(NO);
}
else return @(YES);
}];
И чем больше состояний, тем сильнее все это похоже на бесконечные круги ада.
Решение из коробки
UIControl и соответственно его наследники, используют следующий механизм обновления состояния:
Первое, что делает метод обновления — считывает текущее состояние, а конкретнее свойство UIControlState state. Оно является битовой маской из единиц состояний, описанных в enum UIControlState. Важно заметить, и как многие ошибочно делают, что это свойство должно быть рассчитываемым, а не хранимым. Т.е. реальное состояние объекта должно формировать описание этого состояния, а не наоборот.
Далее, из контейнера выдергиваются значения, ассоциированные с полученным состоянием и применяются.
Процесс актуализации состояния инициируется после изменения фактора состояния. Например, в boolean переменной:
open var isEnabled: Bool {
didSet {
if oldValue != isEnabled {
// вызов метода обновления
}
}
}
UIControlState имеет зарезервированную часть битовой маски для создания дополнительных состояний — UIControlStateApplication. Если вы хотите добавить состояние для любого системного control`а, то вы можете выбрать любое значение из этого интервала.
extension UIControlState {
static let custom = UIControlState(rawValue: 1 << 16)
}
let button = UIButton(type: .custom)
let title = "Title for custom state"
button.setTitle(title, for: .custom)
button.title(for: .custom) == title // true
Но почему-то разработчики Apple, предоставив нам возможность создавать свои состояния, не предоставили API, чтобы ими управлять.
Мой случай
Моя задача состояла в том, чтобы реализовать control для ввода пин-кода. Задача достаточно тривиальная, поэтому пытаешься её усложнить.
Учитывая вышеописанную проблему, я и решил написать то, что Apple не задекларировала в публичный интерфейс, и может быть немного больше)
Так я создал класс надстройку над UIControl — QUIckControl. Он предоставляет возможность устанавливать значения для определенного состояния (или множества состояний) для конкретного объекта.
func setValue(_ value: Any?, forTarget: NSObject, forKeyPath: String, for: QUICState)
Как видно из семантики метода, в основе лежит KVC. Проблема валидации ключей в swift 3 уже решена, а в ObjC легко решается добавлением define macros.
Перед установкой значения для пользовательского состояния, это состояние нужно зарегистрировать используя метод:
func register(_ state: UIControlState, forBoolKeyPath keyPath: String, inverted: Bool)
Если ваш control вошел в состояние для которого вы не устанавливали значений, то будет применено дефолтное значение. Дефолтное значение определяется в момент первой установки значения для конкретного ключа. Формально вы можете его переопределить используя состояние .normal в режиме частичного соответствия (cм. ниже), т.к. .normal содержится абсолютно в любом состоянии.
Для того, чтобы упростить настройку состояний и не дублировать значения, была создана структура-описание состояния QUICState. Сейчас она содержит 6 режимов оценки соответствия текущему состоянию:
- режим полного соответствия
- режим частичного соответствия
- режим несоответствия
- режим соответствия хотя бы одной единицы состояния
- режим полного несоответствия
- режим определенный пользователем
Каждый режим имеет свой приоритет, для определения первостепенного значения в случае множественного соответствия.
Так как актуализация состояния происходит сразу после изменения фактора состояния (boolean переменной), создана возможность осуществления множественных переходов, без моментального применения изменений:
func beginTransition() // запуск процесса перехода
func endTransition() // закрытие процесса перехода без применения
func commitTransition() // закрытие процесса перехода с применением
func performTransition(withCommit commit: Bool = default, transition: () -> Void) // блок обертка для методов выше
Вывод
Данное API позволяет достаточно быстро настраивать состояния и создавать зависимости между control`ами и не только:
control.setValue(true, forTarget: otherControl, forKeyPath: "enabled", forAllStatesContained: [.filled, .valid])
Что например, является частым use case`ом для форм ввода.
В результате я получил, то что хотел, но еще с элементами реактивщины. Автоматное программирование достаточно неудобное, но при грамотном подходе достаточно надежное. Не зря этот стиль программирования находит применение от игр и контроллеров до всевозможных анализаторов и ИИ.
→ Реализацию PinCodeControl и весь код можно посмотреть здесь.