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

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

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


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

_5-_tzispwunqkjevxcprqjnxb0.png


Всё в кучу

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

hwral5o1ajc9znvde9tozjf8tbe.png

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

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


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

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


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

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

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


А смысл?

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

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

uvhyskhr-hvxrbzgwgswlc6ul6c.png

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

xopwhtfz1or0ro86mipsqyfb6y8.png

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

dugcrhatpjzzl1essnh-5j0clrk.png


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

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

scqfcryfeq1ginkhko_ypvzzt6o.jpeg

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

gpdrqi4gjxch0scmu7rh3zzrg8g.jpeg


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

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

// OnboardingViewController.swift

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

// NewYearContentViewController.swift

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

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

7zseub0d7hkkc9laghstrr7u8cw.png

Получить элементы из контента и добавить их в шаблон можно на этапе подготовки 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 изображены серым:

yg71v1ajukht7pki8alekccwrcy.png


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

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

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

// NewYearContentViewController.swift

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

je5qdpshqbjzke7vbli8vrflpnq.png

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

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

zgstlh9ascudcd06z-3c-edpmzi.png

Шаг 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, если нужно. И наоборот.


g8ae6dvzd685hag645eyhc-u__y.png

Ставим контроллеру свойство 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 займёт зелёную область:

v-kogoeqmzsu0gv_x_ugytn5oos.png


Margins. Preserve superview margins

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

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

vn8y0whfciurongx5qmzuhenzq4.png

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


Конец

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

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

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

© Habrahabr.ru