Программирование состояний в UIControl

в 13:49, , рубрики: ios development, objective-c, state machine, statefull, swift, UIControl, разработка под iOS, метки:

Основная проблема, с которой сталкивается программист при реализации какого-то управляющего элемента — выстраивание правильной логики работы этого элемента.

Исследование проблемы

Как определено в документации 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 и соответственно его наследники, используют следующий механизм обновления состояния:

Программирование состояний в UIControl - 1

Первое, что делает метод обновления — считывает текущее состояние, а конкретнее свойство 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`ом для форм ввода.

Программирование состояний в UIControl - 2

В результате я получил, то что хотел, но еще с элементами реактивщины. Автоматное программирование достаточно неудобное, но при грамотном подходе достаточно надежное. Не зря этот стиль программирования находит применение от игр и контроллеров до всевозможных анализаторов и ИИ.

→ Реализацию PinCodeControl и весь код можно посмотреть здесь.

Автор: K-o-D-e-N

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js