February 17, 2024

54. Делаем превью для UIViewController

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

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

Состояние №1 - "Профиль"
Состояние №2 - "Настройки"

Верстка экрана

final class VCExampleForPreview: UIViewController {
  private let model: Model
   
  /// Стек для всех вьюшек на экране
  private lazy var stackView: UIStackView = {
    let stack = UIStackView(
      arrangedSubviews: [
        imageView,
        firstLabel,
        secondLabel,
        actionButton
      ]
    )
    stack.translatesAutoresizingMaskIntoConstraints = false
    stack.alignment = .center
    stack.axis = .vertical
    stack.setCustomSpacing(30, after: actionButton)
    stack.spacing = 16
    return stack
  }()
   
  private var imageView: UIImageView {
    let view = UIImageView()
    view.image = .init(systemName: model.imageName)
    view.tintColor = .black
    return view
  }
   
  private var firstLabel: UILabel {
    let label = UILabel()
    label.text = model.title
    label.font = .boldSystemFont(ofSize: 20)
    return label
  }
   
  private var secondLabel: UILabel {
    let label = UILabel()
    label.text = model.subtitle
    label.font = .systemFont(ofSize: 18)
    label.numberOfLines = 0
    return label
  }
   
  private lazy var actionButton: UIButton = {
    .init(
      configuration: .borderedProminent(),
      primaryAction: .init(
        title: model.buttonTitle,
        handler: { _ in print("Нажали на кнопку!") }
      )
    )
  }()
   
  init(model: Model) {
    self.model = model
    super.init(nibName: nil, bundle: nil)
  }
   
  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
   
  override func viewDidLoad() {
    super.viewDidLoad()
    view.addSubview(stackView)
    let demoItems = model.listItems.map { text -> UIView in
      let label = UILabel()
      label.text = text
      label.textAlignment = .center
      label.widthAnchor.constraint(equalToConstant: view.bounds.width).isActive = true
      return label
    }
    demoItems.forEach(stackView.addArrangedSubview)
    NSLayoutConstraint.activate([
      stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
      stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
      stackView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 20),
      stackView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -20)
    ])
  }
}

Модель для экрана

extension VCExampleForPreview {
  /// Модель с данными для отображения на экране
  struct Model {
    let title, subtitle, buttonTitle, imageName: String
    let listCount: Int
     
    var listItems: [String] {
      (0..<listCount).map {
        "Элемент # \($0) для экрана \(title) "
      }
    }
  }
}

#Preview (iOS 17)

Если вам повезло (или вы читаете статью из будущего), и ваш проект поддерживает минимальную версию iOS 17, то при поддержке макросов (Xcode 15+) можно сразу сделать превью:

#Preview("Профиль") {
  VCExampleForPreview(
    model: .init(
      title: "Профиль",
      subtitle: "Информация о пользователе",
      buttonTitle: "Загрузить профиль",
      imageName: "person.fill",
      listCount: 10
    )
  )
}

#Preview("Настройки") {
  VCExampleForPreview(
    model: .init(
      title: "Настройки",
      subtitle: "Кастомизация приложения",
      buttonTitle: "Сбросить настройки",
      imageName: "gear",
      listCount: 5
    )
  )
}

#Preview (iOS < 17)

Если ваш проект поддерживает минимальную версию iOS ниже 17, то нужно будет воспользоваться UIViewControllerRepresentable, чтобы завести превью:

struct UIKitPreviewExample: UIViewControllerRepresentable {
  let model: VCExampleForPreview.Model
   
  init(model: VCExampleForPreview.Model) {
    self.model = model
  }
   
  func makeUIViewController(context: Context) -> VCExampleForPreview {
    .init(model: model)
  }
   
  func updateUIViewController(_ uiViewController: VCExampleForPreview, context: Context) {}
}

Пишем код для превью

#Preview("Профиль") {
  UIKitPreviewExample(
    model: .init(
      title: "Профиль",
      subtitle: "Информация о пользователе",
      buttonTitle: "Загрузить профиль",
      imageName: "person.fill",
      listCount: 10
    )
  )
}

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

Ошибка при попытке запустить превью

Решение проблемы

Нужно передать дефолтное значение в инициализатор репрезентабл-вьюхи. Сделать это можно двумя способами:

  1. Просто инициализировать модель
  2. Передать в инициализатор статичный вариант модели

Вариант 1

struct UIKitPreviewExample: UIViewControllerRepresentable {
  let model: VCExampleForPreview.Model
   
  init(
    model: VCExampleForPreview.Model = .init(
      title: "Профиль",
      subtitle: "Информация о пользователе",
      buttonTitle: "Загрузить профиль",
      imageName: "person.fill",
      listCount: 10
    )
  ) {
    self.model = model
  }

  // остальной код
}

Вариант 2

Дорабатываем модель:

extension VCExampleForPreview {
  /// Модель с данными для отображения на экране
  struct Model {
    // остальной код

    /// Статичное свойство для превью "Профиля"
    static let optionA = Self(
      title: "Профиль",
      subtitle: "Информация о пользователе",
      buttonTitle: "Загрузить профиль",
      imageName: "person.fill",
      listCount: 10
    )
     
    /// Статичное свойство для превью "Настроек"
    static let optionB = Self(
      title: "Настройки",
      subtitle: "Кастомизация приложения",
      buttonTitle: "Сбросить настройки",
      imageName: "gear",
      listCount: 5
    )
  }
}

Передаем статичное свойство в превью (такой вариант мне больше нравится):

struct UIKitPreviewExample: UIViewControllerRepresentable {
  let model: VCExampleForPreview.Model
   
  init(model: VCExampleForPreview.Model = .optionA) {
    self.model = model
  }

  // остальной код
}

Итоговый вариант превью

Применим статичные свойства из модели сразу в превью:

#Preview("Профиль") {
  UIKitPreviewExample(model: .optionA)
}

#Preview("Настройки") {
  UIKitPreviewExample(model: .optionB)
}

Теперь превью работает исправно.

Альтернативный вариант превью (Xcode < 15)

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

struct OldPreviewExample: PreviewProvider {
  static var previews: some View {
    Group {
      UIKitPreviewExample(model: .optionA)
        .previewDisplayName("Профиль")
      UIKitPreviewExample(model: .optionB)
        .previewDisplayName("Настройки")
    }
  }
}

В качестве "бонуса" мы получили возможность создать несколько вкладок превью с использованием Group, в то время как при работе с макросом такой финт не прокатит.

Заключение

Даже если вы не можете использовать SwiftUI для верстки в своем проекте, вы можете использовать превью для упрощения работы.

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

На более слабых маках превью могут запускаться дольше. Самый слабый девайс, на котором я запускал превью (и все работало идеально) - MacBook Air M1 (2020).

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

Код для этой статьи можно посмотреть тут, а другие статьи - тут.