в защиту «ручного» лэйаута

3 декабря 2018

Я был не раз замечен в скептическом отношении к эппловскому AutoLayout (AL) и даже как-то выступал в Яндексе на встрече мобильных разработчиков на эту тему. То выступление мне самому не очень нравится, потому что я всегда чуствовал, что не до конца оправдал тот провокационный заголовок, который был у меня в презентации, в большей части потому, что основная мысль доклада оказалась скрыта под поверхностными тезисами, ну, и потому, что пример был слегка вырван из контекста и помешал увидеть всю картину целиком.

Это была не единственная причина, по которой я хотел написать этот пост. Ещё одна причина это, пожалуй, продвигаемая Эппл «линия партии» что AL это чуть ли не единственный способ сделать лэйаут, который адаптируется к разным размерам экрана. Моё личное мнение состоит в том, что это не так, и нет ничего супер-сложного в том, чтобы добиться того же самого с помощью ручного лэйаута. Вторая причина — это практически полное отсутствие примеров нормального кода, делающего ручной лэйаут. За последние несколько лет мне много раз попадались вводные статьи по AL, которые всегда строились по принципу «давайте возьмём плохо написанный ручной лэйаут и перепишем всё на AL; видите, насколько лучше всё стало после этого». Я считаю, что если взять изначально плохой код, то естественно, что на его фоне всё будет казаться лучше. В качестве типичных примеров могу привести хардкод размеров экрана, выполнение лэйаута в неподходящих местах, типа конструктора вью или viewDidLoad вью-контроллера или вообще использование autoresizing masks.

Этим постом я как раз хотел «восстановить справедливость» и подчеркнуть некоторые важные моменты. За основу я решил взять пост в блоге Use Your Loaf, на который я подписан и в котором довольно много внимания уделяется вопросам лэйаута UI. Конкретно этот пост был посвящён поддержке Dynamic Type в условиях AL.

Ниже я попробую изложить, как бы я решал ту же самую проблему, используя «свой» подход. Я не планирую заострять внимание на недостатках AL, а, скорее, наоборот на его достоинствах и недостатках своего подхода.

Постановка задачи

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

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

Вью-модель

Как я упоминал в своём выступлении, у термина «вью-модель» есть несколько определений, но здесь я буду использовать то, которое использую уже последние несколько лет:

Вью-модель — это набор данных, однозначно определяющий визуальное представление UI.

Ключевое слово здесь — «однозначно». Другими словами, во вью-модели должна быть вся информация, которая необходима, чтобы полностью восстановить состояние интерфейса. Отсюда же следует то, что все аспекты UI должны контролироваться содержимым вью-модели, и, если мы хотим что-то поменять в UI, это можно (и нужно) делать путём изменения вью-модели. Другими словами, вью-модель — это не просто данные, которые мы хотим показать, а полное описание UI, выраженное в «инертной» структуре данных без какого-либо поведения.

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

У меня получилась вью-модель следующего вида:


private struct CellViewModel {
  enum LayoutDirection {
    case horizontal
    case vertical
  }

  let text: NSAttributedString
  let layoutDirection: LayoutDirection
}

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

Этой вью-модели должно быть достаточно для того, чтобы однозначно задать внешний вид UI, а именно:

Посмотрим, как это реализуется, начиная с ячейки.

Ячейка

Ячейка, которая будет использоваться для отображения нашего UI, это довольно стандартный класс-наследник UITableViewCell. Для отображения надписи мы будем использовать уже существующий UITableViewCell.textLabel, а для переключателя добавим ещё одно свойство:


private let aSwitch = UISwitch(frame: .zero)

Примечательного в этом классе только одно — это свойство, с помощью которого мы будем проставлять вью-модель ячейки:


  var viewModel: CellViewModel! {
    didSet {
      textLabel?.attributedText = viewModel.text
      setNeedsLayout()
    }
  }

Тип этого свойства, как нетрудно догадаться, — это тип нашей вью-модели CellViewModel. Я использую implicitly unwrapped optional для того, чтобы максимально быстро отловить случаи, когда кто-то пытается использовать ячейку без простановки вью-модели (что, учитывая наше определение вью-модели не имеет смысла).

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

Последний важный момент, это то, что фактически лэйаут ячейки выполняется только в layoutSubviews:


  override func layoutSubviews() {
    super.layoutSubviews()

    let layout = viewModel.layoutFor(width: contentView.safeBounds.width)
    textLabel?.frame = layout.textFrame
    aSwitch.frame = layout.switchFrame
  }

Мой поинт состоит в том, что, в общем случае, ни в каком другом месте лэйаут выполнять нельзя, потому что лэйаут зависит от размеров контейнера (в нашем случае contentView ячейки), который становится известен только к моменту выполнения layoutSubviews().

Само тело метода тоже следует стандартному паттерну:

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

Перед тем, как переходить непосредственно к реализации лэйаута, посмотрим на вью-контроллер, который собственно управляет содержимым UITableView.

Вью-контроллер

В нашем случае вью-контроллер тоже представляет собой довольно стандартный класс-наследник UIViewController, который создаёт и добавляет UITableView в свой вью, а так же реализует протоколы UITableViewDataSource и UITableViewDelegate. Обычно я предпочитаю выносить реализации этих протоколов в отдельные объекты, чтобы вью-контроллер не был слишком «умным» и бесконтрольно не разрастался, но для нашего примера сойдёт и так.

Как и у ячейки, у вью-контроллера так же есть свойство для вью-модели ячейки:


  private var viewModel: CellViewModel! {
    didSet {
      tableView.reloadRows(at: [IndexPath(row: 0, section: 0)], with: .none)
    }
  }

Здесь используется тот же принцип, что и в ячейке — как только значение вью-модели меняется, нам нужно привести UI в соответствие новой вью-модели. Здесь это реализуется путём перезагрузки соответствующей ячейки, что приводит одновременно и к обновлению содержимого ячейки и, что немаловажно, к пересчёту геометрии UITableView, в том числе — высоты ячейки, путём вызова соотвествующих методов из протоколов UITableViewDataSource и UITableViewDelegate. Посмотрим на реализации этих методов.

В tableView(cellForRowAt:) мы берём очередную переиспользуемую ячейку из table view и проставляем ей вью-модель:


  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: Cell.reuseID, for: indexPath) as! Cell
    cell.viewModel = viewModel
    return cell
  }

Больше делать ничего не нужно, поскольку ячейка сама обновит своё содержимое и лэйаут. В tableView(heightForRowAt:) мы используем тот же объект, представляющий лэйаут ячейки, и возвращаем ту высоту, которую он считает нужным:


  func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
    return viewModel.layoutFor(width: tableView.safeBounds.width).height
  }

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

Я хотел бы ещё заострить внимание на том, какой высокоуровневый flow выстраивается во всех этих компонентах:

Здесь управление всегда передаётся в одном направлении и, как только что-то меняется в начале цепочки, все остальные элементы приводятся в соответствие с новыми значениями. На каждом этапе у нас есть понимание, что от чего зависит и в каком месте нужно стриггерить обновление, если это необходимо. Как я понимаю, это часто называется uni-directional architecture, ну, и некоторые отголоски F(R)P тоже присутствуют. Мне лично это напоминает хорошо сделанную таблицу в Excel или Numbers.

«А где же обещанная поддержка Dynamic Type?» — спросит внимательный читатель. А это как раз самая скучная часть во всём процессе. Как вы уже возможно догадались, принцип остаётся тем же самым: как только пользователь меняет настройки Dynamic Type, мы меняем вью-модель, а дальше всё происходит автоматически:


  override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
    super.traitCollectionDidChange(previousTraitCollection)
    guard previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory else {
      return
    }
    viewModel = CellViewModel(
      text: "Larger Accessibility Sizes".withAttributes([.font : UIFont.preferredFont(forTextStyle: .body)]),
      layoutDirection: traitCollection.preferredContentSizeCategory.isAccessibilityCategory ? .vertical : .horizontal
    )
  }

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

Теперь можно переходить к самой интересной части — реализации лэйаута ячейки.

Лэйаут ячейки

Вычисление лэйаута организовано по тому же принципу, что и остальная система:

Когда меняется вью-модель, в принципе, легко догадаться — в сеттере соответствующего свойства. С шириной немного сложнее, но ненамного: в общем случае, когда меняется UIView.bounds, iOS всегда вызывает (viewWill)LayoutSubviews(), что мы и используем в ячейке. В случае с UITableView дополнительно вызывается tableView(heightForRowAt:), где мы также пересчитываем лэйаут, чтобы вернуть новую высоту ячейки.

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


protocol CellLayout {
  init(viewModel: CellViewModel, width: CGFloat)
  var textFrame: CGRect { get }
  var switchFrame: CGRect { get }
  var height: CGFloat { get }
}

Ещё одна деталь: обычно, высоту можно вычислить в общем виде для обоих вариантов лэйаута, поэтому этот код можно вынести в расширение протокола:


extension CellLayout {
  var height: CGFloat {
    return [textFrame.maxY, switchFrame.maxY].max()! + contentInset.bottom
  }
}

Теперь посмотрим на реализации вышеупомянутых двух вариантов лэйаута. Для каждого из элементов ячейки нам нужно вычислить его фрейм, а именно координаты левого верхнего угла (origin) и размер (size). Начнём с переключателя в горизонтальном варианте. Чтобы вычислить координату его левого края, мы отступаем от правого края ячейки общий отступ контента ячейки плюс ширину переключателя:


  private var switchOriginX: CGFloat {
    return width - contentInset.right - switchSize.width
  }

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


  private var switchOriginY: CGFloat {
    if textSize.height > switchSize.height {
      return textFrame.minY + (textFrame.height - switchSize.height) / 2
    } else {
      return contentInset.top
    }
  }

С размером переключателя же всё просто — он фиксирован и равен CGSize(width: 51, height: 31), что, конечно, не очень красиво, ибо хардкод, но что поделать. Теперь посмотрим на надпись. Её левый край всегда находится на фиксированном отступе от левого края ячейки (contentInset.left). С верхним же краем ситуация похожа на переключатель: если надпись получилась выше, то просто отступаем от верхнего края ячейки contentInset.top, иначе выравниваем её вертикально по центру высоты переключателя:


  private var textOriginY: CGFloat {
    if textSize.height > switchSize.height {
      return contentInset.top
    } else {
      return switchOriginY + (switchSize.height - textSize.height) / 2
    }
  }

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


  private var maxTextWidth: CGFloat {
    return switchOriginX - contentInset.left - gapBetweenTextAndSwitch
  }

Здесь gapBetweenTextAndSwitch — некий фиксированный отступ, чтобы всё смотрелось красиво.

Теперь, когда доступная ширина известна, мы можем вычислить высоту надписи для этой ширины, используя NSAttributedString.boundingRect(with:options:context:):


  private var textSize: CGSize {
    let boundingRect = viewModel.text.boundingRect(with: CGSize(width: maxTextWidth, height: .greatestFiniteMagnitude),
                                                   options: .usesLineFragmentOrigin,
                                                   context: nil)
    return boundingRect.integral.size
  }

Здесь стоит обратить внимание на пару деталей. Во-первых, высота надписи ничем не ограничена, поэтому мы передаём фактически бесконечный по высоте (.greatestFiniteMagnitude) ограничивающий прямоугольник. Во-вторых, вычисленный размер часто получается дробным, поэтому я выравниваю его по сетке с помощью CGRect.integral, который возвращает прямоугольник максимального размера с целочисленными значениями, вписанный в данный.

После этого, фрейм надписи полностью определён:


  var textFrame: CGRect {
    return CGRect(origin: CGPoint(x: contentInset.left, y: textOriginY),
                  size: CGSize(width: maxTextWidth, height: textSize.height))
  }

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

Ацикличный граф вычислений в горизотальном варианте лэйаута

AcyclicComputationGraph textFrame textFrame contentInset contentInset textFrame->contentInset textOriginY textOriginY textFrame->textOriginY textSize textSize textFrame->textSize maxTextWidth maxTextWidth textFrame->maxTextWidth switchFrame switchFrame switchOriginY switchOriginY switchFrame->switchOriginY switchOriginX switchOriginX switchFrame->switchOriginX switchSize switchSize viewModel viewModel width width gapBetweenTextAndSwitch gapBetweenTextAndSwitch textOriginY->contentInset textOriginY->switchSize textOriginY->textSize textOriginY->switchOriginY textSize->viewModel textSize->maxTextWidth switchOriginY->contentInset switchOriginY->switchSize switchOriginY->textSize maxTextWidth->contentInset maxTextWidth->gapBetweenTextAndSwitch maxTextWidth->switchOriginX switchOriginX->contentInset switchOriginX->switchSize switchOriginX->width

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

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


  private var maxTextWidth: CGFloat {
    return width - (contentInset.left + contentInset.right)
  }

Надпись всегда располагается в верхнем левом углу ячейки:


  var textFrame: CGRect {
    return CGRect(origin: CGPoint(x: contentInset.left, y: contentInset.top),
                  size: CGSize(width: maxTextWidth, height: textSize.height))
  }

А переключатель всегда располагается на фиксированном отступе от надписи:


  var switchFrame: CGRect {
    return CGRect(origin: CGPoint(x: contentInset.left, y: textFrame.maxY + gapBetweenTextAndSwitch),
                  size: switchSize)
  }

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


extension CellViewModel {
  func layoutFor(width: CGFloat) -> CellLayout {
    switch layoutDirection {
    case .horizontal:
      return HorizontalCellLayout(viewModel: self, width: width)
    case .vertical:
      return VerticalCellLayout(viewModel: self, width: width)
    }
  }
}

В результате мы имеем лэйаут, который:

Кроме того, в нём сложнее допустить ошибку, а если это всё-таки произошло — то легче её отладить и исправить. Наконец, полученное решение работает без изменений на всех версиях iOS, начиная с 6.0 (где появился NSAttributedString.boundingRect(with:options:context:)), но в более ранних версиях существовали аналогичные API, поэтому это не слишком большая проблема.

Как это решение выглядит на фоне AutoLayout, поговорим в заключении.

Заключение

Разумеется, описанный выше способ реализации лэйаута не лишён недостатков. Попробую описать главные из них.

Во-первых, этот способ предполагает, что к каждому случаю мы подходим индивидуально. Я понимаю, что далеко не всем нравится подобное «ковыряние» в лэйауте, когда каждый фрейм вычисляется явно и вручную. Иногда хочется просто набросать компонентов на форму и чтобы всё в результате более или менее работало. Другая проблема такого «индивидуального» подхода — это то, что он плохо обобщается в том смысле, что из него не получается сходу выделить какую-то переиспользуемую часть в более высокоуровневую абстракцию. Да, конечно, можно вынести вычисление размеров текста, но я говорю, скорее, о расположении нескольких элементов по горизонтали или вертикали с заданными отступами и выравниванием. Думаю, похожие рассуждения в итоге привели к появлению UIStackView.

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

Наконец, в AL встроена поддержка RTL-локалей, baseline alignment и много чего ещё. При желании, всё это можно не слишком сложно воспроизвести вручную, но, возвращаясь к первому пункту, далеко не всем это будет интересно.

Каков же итог? Как всегда, всё зависит от конкретного случая. Если вам пришлись по душе:

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

На полный код проекта можно посмотреть в репозитории.