April 26, 2024

65. Чем опасен if-модификатор

В SwiftUI-комьюнити популярен модификатор if или applyIf, который применяет к вьюхе какие-то эффекты при определенном условии. В этой статье расскажу и покажу, почему лучше не использовать такие модификаторы.

Код для модификатора

extension View {
  @ViewBuilder
  func applyIf<M: View>(condition: Bool, transform: (Self) -> M) -> some View {
    if condition {
      transform(self)
    } else {
      self
    }
  }
}

На первый взгляд ничего опасного в коде нет.

Проблема 1

Сделаем вьюху с тогглом и прямоугольником, где ширина прямоугольника будет меняться в зависимости от состояния тоггла, и применим модификатор appliIf:

struct ConditionalModifierExample: View {
  @State private var isOn = false
   
  var body: some View {
    VStack {
      Toggle("Toggle", isOn: $isOn)
      Rectangle()
        .applyIf(
          condition: isOn,
          transform: { $0.frame(width: 100) }
        )
    }
    .animation(.linear(duration: 1), value: isOn)
    .padding(.horizontal)
    .frame(height: 200)
  }
}

Получилось вот что:

Прямоугольник некрасиво меняет свою ширину

Теперь поправим проблему, заменив applyIf тернарным оператором:

struct ConditionalModifierExample2: View {
  @State private var isOn = false
   
  var body: some View {
    VStack {
      Toggle("Toggle", isOn: $isOn)
      Rectangle()
        .frame(width: isOn ? 100 : nil) // <- тут тернарный оператор
    }
    .animation(.linear(duration: 1), value: isOn)
    .padding(.horizontal)
    .frame(height: 200)
  }
}
Ширина прямоугольника анимируется красиво, как и ожидалось

Пояснение

Чтобы SwiftUI анимировать изменения, он должен сравнить значение вьюхи до начала анимации и после окончания анимации. Затем SwiftUI выполняет переход между двумя значениями.

Вот тип нашего Rectangle().applyIf(…):

_ConditionalContent<ModifiedContent<Rectangle, _FrameLayout>, Rectangle>

Самый верхний тип _ConditionalContent - это enum, который содержит или значение из ветки if, или значение из ветки else.

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

Теперь посмотрим на тип Rectangle().frame(…):

ModifiedContent<Rectangle, _FrameLayout>

Когда мы анимируем изменение свойств фрейма, SwiftUI может сразу сделать переход от старого значения к новому, т.к. тип один и тот же, поэтому анимация выглядит как нужно.

Во втором примере мы использовали тернарный оператор - такой подход поддерживается большинством вьюх в SwiftUI. Например, таким же образом можно анимировать цвет или паддинг (с одной стороны тернарника явное значение, с другой - nil).

Важный нюанс: applyIf ломает анимацию даже если мы делаем все правильно перед ним

struct ConditionalModifierExample3: View {
  @State private var isOn = false
   
  var body: some View {
    VStack {
      Toggle("Toggle", isOn: $isOn)
      Rectangle()
        .frame(width: isOn ? 100 : nil) // <- тернарный оператор
        .applyIf(condition: isOn) { $0.border(Color.red) } // <- ломает анимацию
    }
    .animation(.linear(duration: 1), value: isOn)
    .padding(.horizontal)
    .frame(height: 200)
  }
}
Анимация снова некорректная, хотя фрейм меняется тернарным оператором

В этом примере анимация не происходит ни для фрейма, ни для бордюра, потому что SwiftUI воспринимает ветки if/else как разные вьюхи.

Проблема 2

Допустим, даже поломанная анимация нас устраивает (пользователь может не заметить, да?).

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

Рассмотрим для примера такую ситуацию:

/// Вьюха, имеющая свое @State-свойство
struct StatefulView: View {
  @State private var text = ""
   
  var body: some View {
    TextField("My text", text: $text)
  }
}

/// Контейнер для `StatefulView`
struct StatefulViewExample: View {
  @State private var isRed = false
   
  var body: some View {
    VStack {
      StatefulView().applyIf(condition: isRed) {
        $0.background(Color.red)
      }
      Button("Change color") {
        isRed.toggle()
      }
    }
  }
}
Текст теряется при смене условия внутри applyIf

Пояснение

Когда мы меняем значение в поле isRed, вьюха меняется, как и положение StatefulView() - она перемещается в другую ветку if/else в _ConditionalContent. Это приводит к сбросу значения в State-свойстве, т.к. по мнению SwiftUI старая вьюха была целиком заменена на новую, поэтому текстфилд останется без текста.

Такая же проблема будет и в случае со StateObject.

Заключение

Надеюсь, эта статья поможет тебе лучше разобраться в теме.

Перед использованием красивых модификаторов из интернетов нужно тщательно проверять как они работают 🙂

Код можно посмотреть тут.

Понравилась статья? Подпишись на мой блог!