Контроллер-луковка. Разбиваем экраны на части

в 10:05, , рубрики: dodomobile, iOS, massive view controller, uikit, UIViewController, мобильная разработка, разработка мобильных приложений, разработка под iOS

В дизайне популярен atomic design и дизайн системы: это когда всё состоит из компонентов, от контролов до экранов. Программисту писать отдельные контролы несложно, но что делать с целыми экранами?

Разберём на новогоднем примере:

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

Контроллер-луковка. Разбиваем экраны на части - 1

Всё в кучу

Этот новогодний экран рассказывает об особенном времени работы пиццерий. Он достаточно простой, поэтому не будет преступлением сделать его одним контроллером:

Контроллер-луковка. Разбиваем экраны на части - 2

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

Поэтому разумней разделить его на части и использовать для других экранов. Я выделил три:

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

Выделим каждую часть в собственный UIViewController.

Контейнер-навигация

Самые яркие примеры навигационных контейнеров — это UINavigationController и UITabBarController. Каждый занимает полоску на экране под свои контролы, а оставшееся место оставляет для другого UIViewController.

В нашем случае будет контейнер для всех модальных экранов с одной только кнопкой закрытия.

А смысл?

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

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

Контроллер-луковка. Разбиваем экраны на части - 3

Для разделения контроллеров можно использовать container view: он создаст UIView в родителе и вставит в него UIView дочернего контроллера.

Контроллер-луковка. Разбиваем экраны на части - 4

Растянуть container view нужно до края экрана. Safe area автоматически применится и на дочерний контроллер:

Контроллер-луковка. Разбиваем экраны на части - 5

Шаблон экрана

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

Контроллер-луковка. Разбиваем экраны на части - 6

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

Контроллер-луковка. Разбиваем экраны на части - 7

Без шаблона все контроллеры похожи, но элементы пляшут.

Кнопки на последнем экране другие — зависит от контента. Решить проблему поможет делегирование: контроллер-шаблон будет спрашивать контролы у контента и показывать их в своём UIStackView.

// OnboardingViewController.swift

protocol OnboardingViewControllerDatasource {
    var supportingViews: [UIView] { get }
}

// NewYearContentViewController.swift

extension NewYearContentViewController: OnboardingViewControllerDatasource {
    var supportingViews: [UIView] {
        return [view().doneButton]
    }
}

Почему view()?

О том как специализировать UIView у UIViewController можно прочитать в моей прошлой статье Контроллер, полегче! Выносим код в UIView.

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

Контроллер-луковка. Разбиваем экраны на части - 8

Получить элементы из контента и добавить их в шаблон можно на этапе подготовки UIStoryboardSegue:

// OnboardingViewController.swift

override func prepare(for segue: UIStoryboardSegue,  sender: Any?) {
    if let buttonsDatasource  = segue.destination as? OnboardingViewControllerDatasource {
        view().supportingViews = buttonsDatasource.supportingViews
    }
}

В сеттере мы добавляем контролы в UIStackView:


// OnboardingView.swift

    var supportingViews: [UIView] = [] {
        didSet {
            for view in supportingViews {
                stackView.addArrangedSubview(view)
            }
        }
    }

В итоге, наш контроллер разделился на три части: навигация, шаблон и контент. На картинке все container view изображены серым:

Контроллер-луковка. Разбиваем экраны на части - 9

Динамический размер контроллера

У контроллера-контента есть свой максимальный размер, он ограничен внутренними constraints.

Container view добавляет констрейнты на основе Autoresizing mask, а они конфликтуют с внутренними размерами контента. Проблема решается в коде: в контроллере-контенте нужно указать, что на него не влияют констрейнты из Autoresizing mask:

// NewYearContentViewController.swift

override func loadView() {
    super.loadView()
    view.translatesAutoresizingMaskIntoConstraints = false
}

Контроллер-луковка. Разбиваем экраны на части - 10

Для Interface Builder нужно сделать ещё два шага:

Шаг 1. Указать Intrinsic size для UIView. Реальные значения появятся после запуска, а пока поставим любые подходящие.

Контроллер-луковка. Разбиваем экраны на части - 11

Шаг 2. Для контроллера-контента указать Simulated Size. Он может не совпадать с прошлым размером.

Появились ошибки лейаута, что делать?

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

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

Разделяем на части и пишем в коде

Мы разделили контроллер на несколько частей, но пока не можем использовать их повторно, интерфейс из UIStoryboard сложно извлекать по частям. Если нам нужно передать какие-то данные в контент, то нам придётся стучаться к нему через всю иерархию. Надо наоборот: сначала взять контент, настроить его, а уже потом обернуть в нужные контейнеры. Как луковица.

На нашем пути появляются три задачи:

  1. Отделить каждый контроллер в свой UIStoryboard.
  2. Отказаться от container view, добавить контроллеры в контейнеры в коде.
  3. Связать это всё обратно.

Разделяем UIStoryboard

Нужно создать два дополнительных UIStoryboard и копипастой перенести в них контроллер навигации и контроллер-шаблон. Embed segue разорвутся, но container view с настроенными констрейнтами перенесётся. Констрейнты надо сохранить, а container view надо заменить на обычный UIView.

Самый простой способ — поменять тип Container view в коде UIStoryboard.

  • открыть UIStoryboard в виде кода (контекстное меню файла → Open as… → Source code);
  • поменять тип с containerView на view. Поменять надо и открывающий, и закрывающий теги.

    Этим же способом можно поменять, например, UIView на UIScrollView, если нужно. И наоборот.

Контроллер-луковка. Разбиваем экраны на части - 12

Ставим контроллеру свойство is initial view controller, а UIStoryboard назовём как и контроллер.

Загружаем контроллер из UIStoryboard.

Если имя контроллера совпадает с именем UIStoryboard, то загрузку можно обернуть в метод, который сам найдёт нужный файл:

protocol Storyboardable { }

extension Storyboardable where Self: UIViewController {
    static func instantiateInitialFromStoryboard() -> Self {
        let controller = storyboard().instantiateInitialViewController()
        return controller! as! Self
    }

    static func storyboard(fileName: String? = nil) -> UIStoryboard {
        let storyboard = UIStoryboard(name: fileName ?? storyboardIdentifier, bundle: nil)
        return storyboard
    }

    static var storyboardIdentifier: String {
        return String(describing: self)
    }

    static var storyboardName: String {
        return storyboardIdentifier
    }
}

Если контроллер описан в .xib, то стандартный конструктор загрузит без таких плясок. Увы, .xib может содержать только один контроллер, часто этого мало: в хорошем случае один экран состоит из нескольких. Поэтому мы используем UIStoryborad, в нём легко разбивать экран на части.

Добавляем контроллер в коде

Для нормальной работы контроллера нам нужны все методы его жизненного цикла: will/did-appear/disappear.

Для правильного отображения нужно вызвать 5 шагов:

    willMove(toParent parent: UIViewController?)   
    addChild(_ childController: UIViewController)
    addSubview(_ subivew: UIView)
    layout 
    didMove(toParent parent: UIViewController?)

Apple предлагает сократить код до 4-х шагов, потому что addChild() сам вызывает willMove(toParent). В итоге:

    addChild(_ childController: UIViewController)  
    addSubview(_ subivew: UIView)
    layout
    didMove(toParent parent: UIViewController?)

Для простоты можно обернуть это всё в extension. Для нашего случая понадобится версия с insertSubview().

extension UIViewController { 
    func insertFullframeChildController(_ childController: UIViewController,
                                               toView: UIView? = nil, index: Int) {

        let containerView: UIView = toView ?? view

        addChild(childController)
        containerView.insertSubview(childController.view, at: index)
        containerView.pinToBounds(childController.view)
        childController.didMove(toParent: self)
    }
}

Для удаления нужны те же шаги, только вместо родительского контроллера нужно ставить nil. Теперь removeFromParent() вызывает didMove(toParent: nil), а лейаут не нужен. Сокращённая версия сильно отличается:

    willMove(toParent: nil) 
    view.removeFromSuperview()
    removeFromParent()

Лейаут

Ставим констрейнты

Чтобы правильно задать размеры контроллера будем использовать AutoLayout. Нам нужно прибить все стороны ко всем сторонам:

extension UIView {
    func pinToBounds(_ view: UIView) {
        view.translatesAutoresizingMaskIntoConstraints = false

    NSLayoutConstraint.activate([
         view.topAnchor.constraint(equalTo: topAnchor),
         view.bottomAnchor.constraint(equalTo: bottomAnchor),
         view.leadingAnchor.constraint(equalTo: leadingAnchor),
         view.trailingAnchor.constraint(equalTo: trailingAnchor)
     ])
    }
}

Добавляем дочерний контроллер в коде

Теперь всё можно объединить:

// ModalContainerViewController.swift

public func embedController(_ controller: UIViewController) {
    insertFullframeChildController(controller, index: 0)
}

Из-за частоты использования можем всё это обернуть в extension:

// ModalContainerViewController.swift

extension UIViewController {
    func wrapInModalContainer() -> ModalContainerViewController {
    let modalController = ModalContainerViewController.instantiateInitialFromStoryboard()
    modalController.embedController(self)
    return modalController
    }
}

Похожий метод нужен и для контроллера-шаблона. Раньше supportingViews настраивались в prepare(for segue:), а теперь можно привязать в методе встраивания контроллера:

// OnboardingViewController.swift

public func embedController(_ controller: UIViewController, actionsDatasource: OnboardingViewControllerDatasource) {

    insertFullframeChildController(controller, toView: view().contentContainerView, index: 0)
    view().supportingViews = actionsDatasource.supportingViews
}

Создание контроллера выглядит вот так:

// MainViewController.swift

@IBAction func showModalControllerDidPress(_ sender: UIButton) {

   let content = NewYearContentViewController.instantiateInitialFromStoryboard()
   // Здесь можно настроить контроллер 

   let onboarding = OnboardingViewController.instantiateInitialFromStoryboard()
   onboarding.embedController(contentController, actionsDatasource: contentController)

   let modalController = onboarding.wrapInModalContainer()
   present(modalController, animated: true)
}

Подключить новый экран к шаблону просто:

  • убрать то, что не относится к контенту;
  • указать кнопки действий реализовав протокол OnboardingViewControllerDatasource;
  • написать метод, который связывает шаблон и контент.

Ещё про контейнеры

Status bar

Часто нужно, чтобы видимостью status bar управлял контроллер с контентом, а не контейнер. Для этого есть пара property:

// UIView.swift

var childForStatusBarStyle: UIViewController?
var childForStatusBarHidden: UIViewController?

С помощью этих property можно создавать цепочку из контроллеров, последний будет отвечать за отображение status bar.

Safe area

Если кнопки контейнера будут перекрывать контент, то стоит увеличить зону safeArea. Это можно сделать в коде: выставить для дочерних контроллеров additinalSafeAreaInsets. Вызвать его можно из embedController():

private func addSafeArea(to controller: UIViewController) {
    if #available(iOS 11.0, *) {
        let buttonHeight = CGFloat(30)
        let topInset = UIEdgeInsets(top: buttonHeight, left: 0, bottom: 0, right: 0)

        controller.additionalSafeAreaInsets = topInset
    }
}

Если добавить 30 точек сверху, то кнопка перестанет перекрывать контент и safeArea займёт зелёную область:

Контроллер-луковка. Разбиваем экраны на части - 13

Margins. Preserve superview margins

У контроллеров есть стандартные отступы — margins. Обычно они равны 16 точкам от каждой стороны экрана и только на Plus-размерах они 20 точек.

На основе margins можно создавать констрейнты, отступы до края будут разными для разных айфонов:

Контроллер-луковка. Разбиваем экраны на части - 14

Когда мы помещаем одну UIView в другую, margins уменьшаются вдвое: до 8 точек. Чтобы этого не происходило нужно включать Preserve superview margins. Тогда margins дочернего UIView будут равны margins родительского. Это подходит для полноэкранных контейнеров.

Конец

Контроллеры-контейнеры — сильное средство. Они упрощают код, разделяют задачи и их можно использовать повторно. Писать вложенные контроллеры можно любым способом: в UIStoryboard, в .xib или просто в коде. Самое главное — их легко создавать и приятно использовать.

Пример из статьи на GitHub

А у вас есть экраны из которых стоило бы сделать шаблон? Поделитесь в комментариях!

Автор: akaDuality

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js