Обрабатывайте несколько вариантов в обобщенном виде

Иногда мы сталкиваемся с интересными проблемами, которыми стоит поделиться. В эти дни я столкнулся с проблемой реализации экрана с несколькими вариантами Да/Нет и главным переключателем, который должен был управлять всеми дочерними элементами.

Мне пришлось реализовать это в SwiftUI. Поначалу казалось, что решить эту проблему несложно: код пользовательского интерфейса несложный, а поведение казалось простым для понимания. Но некоторые тонкости усложнили задачу.

В сегодняшней статье я хочу поделиться с вами процессом внедрения. Мы изучим Toggle API и увидим, как обойти возникшие проблемы. Наконец, мы проведем рефакторинг и обобщим код. Последний шаг является наиболее важным для обеспечения читабельности, расширяемости и сопровождения кодовой базы.

Требования

В качестве первого шага нам нужно понять, что мы должны реализовать. Поведение понятно, но может быть сложно: мастер и дети зависят друг от друга. Нам нужно рассмотреть четыре модели поведения:

  1. Когда пользователь включает главный выключатель, все дочерние элементы должны быть включены.
  2. Когда пользователь выключает главный выключатель, все дочерние элементы должны быть выключены.
  3. Если пользователь выключает один дочерний переключатель, а главный переключатель включен, главный переключатель должен быть выключен.
  4. Если все дочерние элементы, кроме одного, включены, и пользователь включает последний дочерний элемент, главный переключатель должен быть включен.

Следующий gif иллюстрирует поведение, которое мы хотим:

Реализация представления

Давайте начнем с реализации базового представления без поведения.

Код представляет базовую структуру представления. Он содержит четыре переменные состояния, аннотированные оболочкой свойства @State, и четыре переключателя.

Мы пока не реализовали никакой логики. Если мы запустим приложение, мы сможем переключать любой отдельный элемент, но остальные не будут обновляться.

Использование didSet для переменных состояния

Начнем с реализации первого поведения: когда главный переключатель включен или выключен, все остальные переключатели должны измениться соответствующим образом.

Первая идея состоит в том, чтобы прослушивать изменения свойства mainSwitch и соответствующим образом обновлять другие состояния. Теоретически мы можем сделать это с помощью наблюдателя свойств didSet: свойство mainSwitch связано с Toggle, поэтому взаимодействие с переключателем должно обновлять это состояние.

Код будет выглядеть так:

Если мы сейчас протестируем этот подход, включив/выключив главный переключатель, другие переключатели не обновятся. Если мы поместим точку останова в тело didSet, то она вообще не будет прерываться.

Я думаю, что это происходит из-за того, что обёртки свойств меняют тип декорируемых переменных. Но если мы проверим oldValue в обозревателе свойств, его тип будет Bool. Если бы это было так, я ожидал, что oldValue будет типа State<Bool>.

Если мы поищем эту проблему в StackOverflow, то обнаружим множество вопросов и ответов. Предлагаемое решение — использовать модификатор вида onChange.

Используйте onChange ViewModifier

Попробуем предложенное решение. Нам нужно добавить модификатор представления после Toggle. Теперь код выглядит так:

В строке 11 мы добавляем модификатор представления. При изменении главного переключателя обновляются все остальные переключатели. Если мы запустим приложение сейчас, оно будет работать, как задумано. Анимация, однако, внезапна и выглядит некрасиво. Чтобы исправить это, мы добавили модификатор вида .animation (строка 27), который срабатывает при изменении свойства mainSwitch. С этой единственной строкой внешний вид переключателей выглядит намного лучше.

Давайте теперь продолжим и реализуем другое поведение. Мы хотим выключать главный переключатель, когда дочерний переключатель выключается, в то время как основной переключатель все еще включен. Это переводится в следующий код.

Я удалил несколько строк, чтобы мы могли сосредоточиться на дочернем коде. Прежде всего, мы замечаем, что код очень повторяющийся. На данный момент все в порядке: давайте сначала сосредоточимся на поиске рабочего решения.

Каждый переключатель запускает замыкание perform при обновлении его части состояния. Если выключатель выключен, а главный выключатель включен, то главный выключатель должен быть выключен. Логика выглядит хорошо на бумаге, но когда мы запускаем ее, мы замечаем нечто странное: все переключатели двигаются твердо.

Это происходит потому, что они подключены: при изменении состояния дочернего элемента он обновляет основной переключатель. Таким образом, основной переключатель изменяется, и выполняется закрытие onChange(of: mainSwitch). Это обновляет все дочерние элементы, устанавливая их состояние на состояние mainSwitch. Эти обновления могут вызвать другие обновления. У нас есть цепочка эффектов, которая мешает нам достичь того, что нам нужно.

Первое решение могло бы отслеживать, почему представление меняется: мы можем добавить другие переменные состояния, которые мы включаем, когда нажимаем первый переключатель. Теперь код выглядит так:

Мы добавили еще несколько @State переменных, чтобы отслеживать изменения. Затем мы обновили код замыканий, чтобы тело выполнялось только в том случае, если представление не меняется из-за обновлений других переключателей.

Например, замыкание perform для mainSwitch имеет guard, которое гарантирует выполнение тела тогда и только тогда, когда мы не обрабатываем изменения от других переключателей. Если защита проходит, мы устанавливаем для свойства changeDueMainSwitch значение true, чтобы мы могли обновить другие переключатели.

Проблема в том, что мы не знаем, в каком порядке будут выполняться дочерние замыкания. Первое замыкание, которое выполняется, превратит переменную changeDueMainSwitch в false, что сделает недействительной логику во всех других замыканиях. Текущий код явно неверен, и переменных состояния недостаточно для реализации такого поведения.

Мы можем улучшить решение, отслеживая, какие переключатели уже обновили свое состояние, и ожидая, пока последний из них изменит свойство на false. Но код становится намного сложнее. Чем больше мы продвигаемся по этому пути, тем сложнее будет добавить больше переключателей.

Остановись и подумай

Когда код становится настолько сложным, это значит, что мы что-то упускаем. В этих случаях имеет смысл сделать шаг назад и проанализировать, что происходит.

У нас есть цепочка событий: когда пользователь взаимодействует с переключателем, мы обновляем текущую ситуацию. Это обновление запускает дальнейшие автоматические обновления, которые, в свою очередь, могут запускать другие обновления. Этот процесс продолжается до тех пор, пока система не обретет устойчивость. В худшем случае система нестабильна и мы получаем бесконечную рекурсию, которая вылетает из приложения.

Наша цель — избежать этой ситуации: нам не нужна бесконечная рекурсия изменений. Мы хотим обновлять состояние только тогда, когда пользователь взаимодействует с нашими переключателями. Мы не хотим активировать какие-либо последующие изменения.

Мы можем добиться этого, удалив модификатор представления onChange и используя свойство onTapGesture. Для Toggle жест касания вызывает изменение состояния, но он специфичен для касания Toggle.

Использование жеста OnTapGesture

После этого прорыва мы можем переписать код нашего приложения. Теперь это больше похоже на то, что нам хотелось бы.

Есть только 2 предостережения к этому подходу:

  1. onTapGesture вызывается до того, как переключатель фактически изменит свое значение. Это означает, что нам нужно рассуждать негативно. Например, в замыкании mainSwitch мы устанавливаем дочерние переключатели в положение, противоположное currentValue главного переключателя.
  2. Анимация детей теперь рассеяна и внезапна. Чтобы исправить это, нам пришлось добавить модификатор представления .animation для каждой из переменных switchN. Это препятствует масштабируемости этого решения.

Текущее решение намного лучше. Мы можем рассуждать более аккуратно, и у нас нет защитных операторов, разбросанных по всей кодовой базе.

Прежде чем перейти к последнему поведению, давайте попробуем решить эти две оговорки. В корне они вызваны одной и той же проблемой: onTapGesture выполняется до того, как переключатели изменят свое состояние. Если мы вручную выровняем состояние до конечного состояния, каждая часть окажется в нужном месте.

Код следующий:

Мы принудительно включили переключатели в строках 12, 21, 29 и 37. Это позволило нам удалить лишние модификаторы представления .animation.

Примечание. Переключатели меняют свое состояние, когда пользователь проводит по ним пальцем. Однако SwiftUI еще не предлагает модификатор onSwipeGesture. Чтобы отреагировать на этот жест, мы должны реализовать модификатор вида .gesture с пользовательским DragGesture. Мы опустили это для краткости и потому, что это немного выходит за рамки статьи.

Реализация окончательного поведения

Наконец, мы можем реализовать финальное поведение. При включении последнего выключенного дочернего элемента необходимо также включить главный выключатель. Мы можем добавить это поведение к дочерним переключателям:

В этом случае добавляемый код одинаков для всех детей (линзы 15, 26 и 37): если все включены, но mainSwitch выключен, то нам нужно включить его.

Рефакторинг

У нас есть полностью работающее решение. Мы могли бы зафиксировать это и положить этому конец. Но этот код действительно сложно поддерживать. Основными недостатками являются:

  1. Много повторений кода: замыкания perform дочерних элементов имеют один и тот же код, они действуют только на другую переменную switchN.
  2. Трудно расширить: что, если нам нужно добавить новый ингредиент, скажем, Broccoli? Нам нужно скопировать и вставить много кода.
  3. Hardcoded: мы не можем предоставить другой набор опций.

Мы хотим быть хорошими инженерами-программистами, поэтому мы решаем проблемы.

Вычеркните Closures

В качестве первого шага давайте выделим замыкания. Мы можем создать функцию, которая получает на вход что-то, что мы можем использовать для получения значения свойства и обновления свойства новым значением. Это Binding: тип SwiftUI, который позволяет нам получить или установить свойство. Код следующий:

В строке 7 мы реализовали функцию onChildToggleTapped. Он принимает Binding<Bool> в качестве параметра. В теле функции мы скопировали дочерний код onTapGesture и заменили конкретный переключатель привязкой.

Наконец, мы использовали функцию в теле onTapGesture (строки 31, 36 и 41), передав правильную привязку. Обратите внимание, что свойство @State предоставляет Binding с помощью оператора $.

Обобщение параметров

Последний шаг нашего рефакторинга состоит в обобщении опций. Мы попали камнем в двух зайцев: этот шаг позволяет нам добавлять или удалять параметры и настраивать их имена.

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

Обратите внимание, что name — это константа, объявленная с помощью переменной let, а значение — это переменная, которую мы можем обновить, работая с переключателем.

Затем мы обновляем код View. Мы меняем переменные состояния, заменяя отдельные свойства массивом опций; затем мы преобразуем явное перечисление Toggles в представление ForEach. Код выглядит следующим образом:

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

  1. В строке 4 мы заменили отдельные элементы массивом Option
  2. В строке 7 мы создали инициализатор, который принимает список ингредиентов. Это позволяет нам настроить список вызывающего абонента.
  3. В строке 18 мы обновили функцию onChildToggleTapped. Теперь у нас есть неизвестное количество переключателей для проверки. Мы можем проверить их все с помощью функции allSatisfy: эта функция выполняет предикат для всех элементов и возвращает true тогда и только тогда, когда предикат возвращает значение true для всех элементов.
  4. В строке 31 мы обновили логику главного переключателя. Мы должны изменить состояние всех перечисленных опций: мы перебираем их все и устанавливаем их значение.
  5. В строке 37 мы заменили одиночные переключатели на ForEach. У нас есть фиксированное количество ингредиентов, поэтому мы используем инициализатор init(_ range:content:). Инициализация range дает нам индекс для извлечения определенной опции из массива. Содержимое ForEach — это Toggle, которое мы создаем из индексированного Option: представление Text заполняется именем Option.

Заключение

В сегодняшней статье мы рассмотрели процесс реализации экрана с некоторой сложной логикой. Делиться своим путешествием так же важно, как и делиться результатом: никто не сможет реализовать что-то правильно с первой попытки, если это не то, что он знает глубоко.

Мы начали прототипировать его с самой простой идеи, которая у нас была, и мы попытались заставить ее работать, шаг за шагом. Когда код стал казаться слишком сложным, мы сделали паузу, чтобы подумать о выбранном пути.

Мы поняли, что это не тот вариант, и стали искать альтернативы. Один из самых важных навыков инженера-программиста — не привязываться к нашему коду. Мы всегда должны быть готовы отказаться от него, если он не удовлетворяет нашим требованиям.

Наконец, мы разработали решение, реализующее все требования. Работу можно было бы считать выполненной, но хороший программист не останавливается на достигнутом. Они думают о будущем.

На последнем этапе рефакторинга был создан код, более удобный для сопровождения, настраиваемый и пригодный для повторного использования. Это также экономит нам более 10 строк кода (LOC): около 20% всего компонента.