Поговорим о проблеме реализации экрана с несколькими вариантами да/нет и главным переключателем для управления всеми дочерними элементами.
Реализовывать это мне пришлось в Swift UI. Поначалу все выглядело просто: код пользовательского интерфейса и его поведение понятны, однако по ходу решения возникло несколько усложняющих задачу тонкостей.
Рассмотрим эту реализацию. Мы изучим API переключателя и посмотрим, как обойти возникшие сложности, затем проведем рефакторинг и обобщим реализацию. Последний шаг наиболее важен для того, чтобы кодовая база оставалась читаемой, расширяемой и поддерживаемой.
Требования
Первым делом мы должны определить, что именно реализуем. Поведение понятно, но может оказаться замысловатым: главный и дочерние переключатели зависят друг от друга. Необходимо рассмотреть четыре варианта поведения.
- Когда пользователь включает главный переключатель, все дочерние элементы должны быть включены.
- Когда пользователь выключает главный выключатель, все дочерние должны быть выключены.
- Если пользователь выключает один дочерний переключатель, а главный включен, главный должен также выключиться.
- Если включены все дочерние элементы, кроме одного, и пользователь включает последний элемент, необходимо включить главный переключатель.
Это 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)
}
}
Этот подход применим с двумя оговорками.
onTapGesture
запускается до того, как переключатель фактически изменит свое значение. Это означает, что рассуждать придется от противного. Например, при замыканииmainSwitch
мы устанавливаем дочерние переключатели в положение, противоположное текущему значениюcurrentValue
главного переключателя.- Анимация дочерних процессов переключения становится разрозненной и резкой. Для исправления этого придется добавить модификатор представления
.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): если все они включены, но главный переключатель выключен, то нужно включить и его.
Рефакторинг
Полностью рабочее решение уже найдено. Можно было бы посчитать задачу завершенной и покончить с ней. Однако получился сложный для поддержки код. Вот его основные недостатки.
- Много повторений: замыкания дочерних элементов
perform
используют один и тот же код, который действует только на разные видыswitchN
. - Трудно расширить: если потребуется добавить новый ингредиент, например брокколи, то придется скопировать и вставить много кода.
- Хардкод: мы не можем предоставить другой набор опций.
Решим возникшие проблемы.
Учитываем замыкания
В качестве первого шага рассмотрим замыкания. Можно создать функцию, которая получает на входе что-то, что может быть использовано для извлечения значения из свойства и обновления свойства новым значением. Это 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)
}
}
Этот фрагмент содержит разные важные изменения. Мы снабдили их пронумерованными комментариями, чтобы за ними было легче следить.
- На строке 4 мы заменили отдельные элементы массивом
Option
. - На строке 7 мы создали инициализатор, который принимает список ингредиентов. Это позволяет настраивать список со стороны клиента.
- На строке 18 мы обновили функцию
onChildToggleTapped
. Теперь мы способны проверять неограниченное число переключателей. Мы можем проверить их все с помощью функцииallSatisfy
: эта функция выполняет предикат для всех элементов и возвращает значениеtrue
, если и только если предикат вернул значениеtrue
для всех элементов. - На строке 31 обновлена логика основного переключателя. Здесь меняется состояние всех перечисленных опций путем их перебора и установки значений.
- На строке 37 мы заменили одиночные переключатели на
ForEach
. У нас фиксированное количество ингридиентов, так что воспользуемся инициализаторомinit(_ range:content:)
. Инициализация диапазона (range
) дает нам указатель для извлечения определенного параметра (опции) из массива. СодержимоеForEach
— этоToggle
, который создается на основе даннойOption
: представлениеText
заполняется именемOption
.
Заключение
В сегодняшней статье мы рассмотрели разработку экрана с некоторой сложной логикой. Рассказ о путешествии так же важен, как и конец пути: никто не способен реализовать что-то правильно с первой попытки, если глубоко с этим не знаком.
Мы начали создавать прототип с самой простой идеи и попытались заставить ее работать, шаг за шагом. Когда код чрезмерно усложнился, мы остановились, чтобы осмыслить выбранный путь.
Мы поняли, что это не лучший вариант, и стали искать альтернативы. Один из самых важных навыков для инженера-программиста — не привязываться к коду. Мы всегда должны быть готовы отказаться от него, если он не удовлетворяет нашим требованиям.
Наконец, мы разработали решение, реализующее все требования. Работу можно было бы считать выполненной, но хорошие инженеры-программисты на этом не останавливаются. Они думают о будущем.
На последнем этапе рефакторинга был создан более легкий в поддержке, настраиваемый и повторно используемый код. Мы убрали более десяти строк кода: около 20% от всего компонента.
Читайте также:
- Диспетчеризация методов в Swift
- Реализуем функцию управления взглядом с помощью SwiftUI, ARKit и SceneKit
- Реализация ViewPager в Swift 5
Читайте нас в Telegram, VK и Дзен
Перевод статьи Riccardo Cipolleschi: Implement a Scalable and Configurable Multi Toggle SwiftUI Screen