В дизайне популярен atomic design и дизайн системы: это когда всё состоит из компонентов, от контролов до экранов. Программисту писать отдельные контролы несложно, но что делать с целыми экранами?
Разберём на новогоднем примере:
- налепим всё в кучу;
- разделим на контроллеры: выделим навигацию, шаблон и контент;
- повторно используем код для других экранов.
Всё в кучу
Этот новогодний экран рассказывает об особенном времени работы пиццерий. Он достаточно простой, поэтому не будет преступлением сделать его одним контроллером:
Но. В следующий раз, когда нам понадобится похожий экран, придётся повторять всё заново, а потом одинаковые изменения вносить во все экраны. Ну не бывает же без правок.
Поэтому разумней разделить его на части и использовать для других экранов. Я выделил три:
- навигация,
- шаблон с областью для контента и местом для действий внизу экрана,
- уникальный контент в центре.
Выделим каждую часть в собственный UIViewController
.
Контейнер-навигация
Самые яркие примеры навигационных контейнеров — это UINavigationController
и UITabBarController
. Каждый занимает полоску на экране под свои контролы, а оставшееся место оставляет для другого UIViewController
.
В нашем случае будет контейнер для всех модальных экранов с одной только кнопкой закрытия.
Если мы захотим перенести кнопку направо, то поменять нужно будет только в одном контроллере.
Или, если мы решим показывать все модальные окна с особенной анимацией, а закрываться интерактивно свайпом, как в карточках историй AppStore. Тогда UIViewControllerTransitioningDelegate
нужно будет установить только для этого контроллера.
Для разделения контроллеров можно использовать container view
: он создаст UIView
в родителе и вставит в него UIView
дочернего контроллера.
Растянуть container view
нужно до края экрана. Safe area
автоматически применится и на дочерний контроллер:
Шаблон экрана
На экране очевиден контент: картинка, заголовок, текст. Кнопка кажется его частью, но контент динамичен на разных айфонах, а кнопка зафиксирована. Видно две системы с разными задачами: одна отображает контент, а другая встраивает и выравнивает его. Их стоит поделить на два контроллера.
Первый отвечает за компоновку экрана: контент должен быть выровнен по центру, а кнопка прибита к низу экрана. Второй будет рисовать контент.
Без шаблона все контроллеры похожи, но элементы пляшут.
Кнопки на последнем экране другие — зависит от контента. Решить проблему поможет делегирование: контроллер-шаблон будет спрашивать контролы у контента и показывать их в своём UIStackView
.
// OnboardingViewController.swift
protocol OnboardingViewControllerDatasource {
var supportingViews: [UIView] { get }
}
// NewYearContentViewController.swift
extension NewYearContentViewController: OnboardingViewControllerDatasource {
var supportingViews: [UIView] {
return [view().doneButton]
}
}
О том как специализировать UIView
у UIViewController
можно прочитать в моей прошлой статье Контроллер, полегче! Выносим код в UIView.
Кнопки могут быть привязаны к контроллеру через связанные объекты. Их IBOutlet
и IBAction
хранятся в контент-контроллере, просто элементы не добавлены в иерархию.
Получить элементы из контента и добавить их в шаблон можно на этапе подготовки 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
изображены серым:
Динамический размер контроллера
У контроллера-контента есть свой максимальный размер, он ограничен внутренними constraints
.
Container view
добавляет констрейнты на основе Autoresizing mask
, а они конфликтуют с внутренними размерами контента. Проблема решается в коде: в контроллере-контенте нужно указать, что на него не влияют констрейнты из Autoresizing mask
:
// NewYearContentViewController.swift
override func loadView() {
super.loadView()
view.translatesAutoresizingMaskIntoConstraints = false
}
Для Interface Builder нужно сделать ещё два шага:
Шаг 1. Указать Intrinsic size
для UIView
. Реальные значения появятся после запуска, а пока поставим любые подходящие.
Шаг 2. Для контроллера-контента указать Simulated Size
. Он может не совпадать с прошлым размером.
Ошибки возникают когда AutoLayout
не может понять, как ему разложить элементы в текущем размере.
Чаще всего, проблема уходит после изменения приоритетов констрейнт. Нужно проставить их так, чтобы одна из UIView
могла расширяться/сжиматься больше чем другие.
Разделяем на части и пишем в коде
Мы разделили контроллер на несколько частей, но пока не можем использовать их повторно, интерфейс из UIStoryboard
сложно извлекать по частям. Если нам нужно передать какие-то данные в контент, то нам придётся стучаться к нему через всю иерархию. Надо наоборот: сначала взять контент, настроить его, а уже потом обернуть в нужные контейнеры. Как луковица.
На нашем пути появляются три задачи:
- Отделить каждый контроллер в свой
UIStoryboard
. - Отказаться от
container view
, добавить контроллеры в контейнеры в коде. - Связать это всё обратно.
Разделяем UIStoryboard
Нужно создать два дополнительных UIStoryboard
и копипастой перенести в них контроллер навигации и контроллер-шаблон. Embed segue
разорвутся, но container view
с настроенными констрейнтами перенесётся. Констрейнты надо сохранить, а container view
надо заменить на обычный UIView
.
- открыть
UIStoryboard
в виде кода (контекстное меню файла → Open as… → Source code); поменять тип с
containerView
наview
. Поменять надо и открывающий, и закрывающий теги.
Этим же способом можно поменять, например,
UIView
наUIScrollView
, если нужно. И наоборот.
Ставим контроллеру свойство is initial view controller
, а 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
займёт зелёную область:
Margins. Preserve superview margins
У контроллеров есть стандартные отступы — margins
. Обычно они равны 16 точкам от каждой стороны экрана и только на Plus-размерах они 20 точек.
На основе margins
можно создавать констрейнты, отступы до края будут разными для разных айфонов:
Когда мы помещаем одну UIView
в другую, margins
уменьшаются вдвое: до 8 точек. Чтобы этого не происходило нужно включать Preserve superview margins
. Тогда margins
дочернего UIView
будут равны margins
родительского. Это подходит для полноэкранных контейнеров.
Конец
Контроллеры-контейнеры — сильное средство. Они упрощают код, разделяют задачи и их можно использовать повторно. Писать вложенные контроллеры можно любым способом: в UIStoryboard
, в .xib
или просто в коде. Самое главное — их легко создавать и приятно использовать.
А у вас есть экраны из которых стоило бы сделать шаблон? Поделитесь в комментариях!