Реализация масштабируемого и гибкого пользовательского экрана с несколькими переключателями на Swift

Поговорим о проблеме реализации экрана с несколькими вариантами да/нет и главным переключателем для управления всеми дочерними элементами.

Реализовывать это мне пришлось в Swift UI. Поначалу все выглядело просто: код пользовательского интерфейса и его поведение понятны, однако по ходу решения возникло несколько усложняющих задачу тонкостей.

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

Требования

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

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

Это gif-изображение иллюстрирует желаемое поведение:

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

Начнем с создания базового View без каких-либо режимов.

struct Switchers: View {
@State var mainSwitch: Bool = false

@State var switch1: Bool = false
@State var switch2: Bool = false
@State var switch3: Bool = false

var body: some View {
Form {
Toggle(isOn: $mainSwitch, label: {
return Text("Add all the extras?").bold()
})
Spacer()
Toggle(isOn: $switch1, label: {
return Text("Extra cheese")
})
Toggle(isOn: $switch2, label: {
return Text("Pepperoni")
})
Toggle(isOn: $switch3, label: {
return Text("Ham")
})
}
}
}

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

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

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

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

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

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

struct Switchers: View {
@State var mainSwitch: Bool = false {
didSet {
self.switch1 = self.mainSwitch
self.switch2 = self.mainSwitch
self.switch3 = self.mainSwitch
}
}

@State var switch1: Bool = false
@State var switch2: Bool = false
@State var switch3: Bool = false

var body: some View {
// the body is unchanged
}
}

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

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

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

Применение onChange ViewModifier

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

struct Switchers: View {
@State var mainSwitch: Bool = false
@State var switch1: Bool = false
@State var switch2: Bool = false
@State var switch3: Bool = false

var body: some View {
Form {
Toggle(isOn: $mainSwitch, label: {
return Text("Add all the extras?").bold()
}).onChange(of: mainSwitch) { _ in
self.switch1 = self.mainSwitch
self.switch2 = self.mainSwitch
self.switch3 = self.mainSwitch
}
Spacer()
Toggle(isOn: $switch1, label: {
return Text("Extra cheese")
})
Toggle(isOn: $switch2, label: {
return Text("Pepperoni")
})
Toggle(isOn: $switch3, label: {
return Text("Ham")
})
}
.animation(.default, value: mainSwitch)
}
}

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

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

struct Switchers: View {
// ... переменные состояния ...

var body: some View {
Form {
// ... главный переключатель ...
Toggle(isOn: $switch1, label: {
return Text("Extra cheese")
}).onChange(of: switch1) { _ in
if !switch1 && mainSwitch {
self.mainSwitch = false
}
}
Toggle(isOn: $switch2, label: {
return Text("Pepperoni")
}).onChange(of: switch2) { _ in
if !switch2 && mainSwitch {
self.mainSwitch = false
}
}
Toggle(isOn: $switch3, label: {
return Text("Ham")
}).onChange(of: switch3) { _ in
if !switch3 && mainSwitch {
self.mainSwitch = false
}
}
}
.animation(.default, value: mainSwitch)
}
}

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

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

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

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

struct Switchers: View {
@State var mainSwitch: Bool = false
@State var switch1: Bool = false
@State var switch2: Bool = false
@State var switch3: Bool = false

@State var changesDueToMainSwitch: Bool = false
@State var changesDueToSwitch1: Bool = false
@State var changesDueToSwitch2: Bool = false
@State var changesDueToSwitch3: Bool = false

var body: some View {
Form {
Toggle(isOn: $mainSwitch, label: {
return Text("Add all the extras?").bold()
}).onChange(of: mainSwitch) { _ in
guard !changesDueToSwitch1 && !changesDueToSwitch2 && !changesDueToSwitch3 else {
self.changesDueToSwitch1 = false
self.changesDueToSwitch2 = false
self.changesDueToSwitch3 = false
return
}

self.changesDueToMainSwitch = true
self.switch1 = self.mainSwitch
self.switch2 = self.mainSwitch
self.switch3 = self.mainSwitch
}
Spacer()
Toggle(isOn: $switch1, label: {
return Text("Extra cheese")
}).onChange(of: switch1) { _ in

guard !changesDueToMainSwitch else {
changesDueToMainSwitch = false
return
}

self.changesDueToSwitch1 = true
if !switch1 && mainSwitch {
self.mainSwitch = false
}
}
Toggle(isOn: $switch2, label: {
return Text("Pepperoni")
}).onChange(of: switch2) { _ in
guard !changesDueToMainSwitch else {
changesDueToMainSwitch = false
return
}

self.changesDueToSwitch2 = true

if !switch2 && mainSwitch {
self.mainSwitch = false
}
}
Toggle(isOn: $switch3, label: {
return Text("Ham")
}).onChange(of: switch3) { _ in
guard !changesDueToMainSwitch else {
changesDueToMainSwitch = false
return
}

self.changesDueToSwitch3 = true

if !switch3 && mainSwitch {
self.mainSwitch = false
}
}
}
.animation(.default, value: mainSwitch)
}
}

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

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

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

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

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

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

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

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

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

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

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

struct Switchers: View {
@State var mainSwitch: Bool = false
@State var switch1: Bool = false
@State var switch2: Bool = false
@State var switch3: Bool = false

var body: some View {
Form {
Toggle(isOn: $mainSwitch, label: {
return Text("Add all the extras?").bold()
}).onTapGesture {
self.switch1 = !self.mainSwitch
self.switch2 = !self.mainSwitch
self.switch3 = !self.mainSwitch
}
Spacer()
Toggle(isOn: $switch1, label: {
return Text("Extra cheese")
}).onTapGesture {
if switch1 && mainSwitch {
self.mainSwitch = false
}
}
Toggle(isOn: $switch2, label: {
return Text("Pepperoni")
}).onTapGesture {
if switch2 && mainSwitch {
self.mainSwitch = false
}
}
Toggle(isOn: $switch3, label: {
return Text("Ham")
}).onTapGesture {
if switch3 && mainSwitch {
self.mainSwitch = false
}
}
}
.animation(.default, value: mainSwitch)
.animation(.default, value: switch1)
.animation(.default, value: switch2)
.animation(.default, value: switch3)
}
}

Этот подход применим с двумя оговорками.

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

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

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

Код выглядит следующим образом:

struct Switchers: View {
@State var mainSwitch: Bool = false
@State var switch1: Bool = false
@State var switch2: Bool = false
@State var switch3: Bool = false

var body: some View {
Form {
Toggle(isOn: $mainSwitch, label: {
return Text("Add all the extras?").bold()
}).onTapGesture {
self.mainSwitch.toggle()
self.switch1 = self.mainSwitch
self.switch2 = self.mainSwitch
self.switch3 = self.mainSwitch
}
Spacer()
Toggle(isOn: $switch1, label: {
return Text("Extra cheese")
}).onTapGesture {
switch1.toggle()
if !switch1 && mainSwitch {
self.mainSwitch = false
}
}
Toggle(isOn: $switch2, label: {
return Text("Pepperoni")
}).onTapGesture {
switch2.toggle()
if !switch2 && mainSwitch {
self.mainSwitch = false
}
}
Toggle(isOn: $switch3, label: {
return Text("Ham")
}).onTapGesture {
switch3.toggle()
if !switch3 && mainSwitch {
self.mainSwitch = false
}
}
}
.animation(.default, value: mainSwitch)
}
}

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

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

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

Теперь можно реализовать окончательное поведение. Когда включается последний выключенный дочерний элемент, необходимо включить еще и главный переключатель. Добавляем это к дочерним переключателям:

struct Switchers: View {
// переменные состояния

var body: some View {
Form {
// главный переключатель
Toggle(isOn: $switch1, label: {
return Text("Extra cheese")
}).onTapGesture {
switch1.toggle()
if !switch1 && mainSwitch {
mainSwitch = false
}

if switch1 && switch2 && switch3 && !mainSwitch {
mainSwitch = true
}
}
Toggle(isOn: $switch2, label: {
return Text("Pepperoni")
}).onTapGesture {
switch2.toggle()
if !switch2 && mainSwitch {
self.mainSwitch = false
}
if switch1 && switch2 && switch3 && !mainSwitch {
mainSwitch = true
}
}
Toggle(isOn: $switch3, label: {
return Text("Ham")
}).onTapGesture {
switch3.toggle()
if !switch3 && mainSwitch {
self.mainSwitch = false
}
if switch1 && switch2 && switch3 && !mainSwitch {
mainSwitch = true
}
}
}
.animation(.default, value: mainSwitch)
}
}

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

Рефакторинг

Полностью рабочее решение уже найдено. Можно было бы посчитать задачу завершенной и покончить с ней. Однако получился сложный для поддержки код. Вот его основные недостатки.

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

Решим возникшие проблемы.

Учитываем замыкания

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

struct Switchers: View {
@State var mainSwitch: Bool = false
@State var switch1: Bool = false
@State var switch2: Bool = false
@State var switch3: Bool = false

func onChildToggleTapped(_ tappedToggle: Binding<Bool>) {
tappedToggle.wrappedValue.toggle()
if !tappedToggle.wrappedValue && mainSwitch {
self.mainSwitch = false
}
if switch1 && switch2 && switch3 && !mainSwitch {
mainSwitch = true
}
}

var body: some View {
Form {
Toggle(isOn: $mainSwitch, label: {
return Text("Add all the extras?").bold()
}).onTapGesture {
self.mainSwitch.toggle()
self.switch1 = self.mainSwitch
self.switch2 = self.mainSwitch
self.switch3 = self.mainSwitch
}
Spacer()
Toggle(isOn: $switch1, label: {
return Text("Extra cheese")
}).onTapGesture {
self.onChildToggleTapped($switch1)
}
Toggle(isOn: $switch2, label: {
return Text("Pepperoni")
}).onTapGesture {
self.onChildToggleTapped($switch2)
}
Toggle(isOn: $switch3, label: {
return Text("Ham")
}).onTapGesture {
self.onChildToggleTapped($switch3)
}
}
.animation(.default, value: mainSwitch)
}
}

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

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

Обобщенная реализация опций

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

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

struct Option {
let name: String
var value: Bool
}

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

Обновляем код View. Изменяем переменные состояния, заменяя отдельные свойства массивом опций. Преобразуем явное перечисление Toggle в представление ForEach. Код теперь выглядит так:

struct Switchers: View {    
@State var mainSwitch: Bool
// 1. Заменяем отдельные Bool массивом Option
@State var options: [Option]

// 2. Добавляем инициализатор, чтобы сделать его настраиваемым
init(ingredients: [String] = ["Extra cheese", "Pepperoni", "Ham"]) {
self.mainSwitch = false
self.options = ingredients.map { Option(name: $0, value: false) }
}

func onChildToggleTapped(_ tappedToggle: Binding<Bool>) {
tappedToggle.wrappedValue.toggle()
if !tappedToggle.wrappedValue && mainSwitch {
self.mainSwitch = false
}

// 3. Обновляем функцию, чтобы она работала с любым количеством опций
if self.options.allSatisfy({ $0.value }) && !mainSwitch {
mainSwitch = true
}
}

var body: some View {
Form {
Toggle(isOn: $mainSwitch, label: {
return Text("Add all the extras?").bold()
}).onTapGesture {
self.mainSwitch.toggle()
// 4. Обновляем поведение главного переключателя, чтобы он обновлял любое количество опций
for index in 0..<self.options.count {
self.options[index].value = self.mainSwitch
}
}
Spacer()
// 5. Заменим явный вызов переключателей на ForEach
ForEach(0..<self.options.count) { index in
Toggle(isOn: self.$options[index].value, label: {
return Text(self.options[index].name)
}).onTapGesture {
self.onChildToggleTapped(self.$options[index].value)
}
}
}
.animation(.default, value: mainSwitch)
}
}

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

  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.

Заключение

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

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

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

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

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

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Riccardo Cipolleschi: Implement a Scalable and Configurable Multi Toggle SwiftUI Screen

Предыдущая статьяПолное руководство по “this” в JavaScript
Следующая статьяКак дата-аналитику стать дата-сайентистом в 2023 году