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

    В дизайне популярен 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]
        }
    }

    Почему view()?

    О том как специализировать 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 сложно извлекать по частям. Если нам нужно передать какие-то данные в контент, то нам придётся стучаться к нему через всю иерархию. Надо наоборот: сначала взять контент, настроить его, а уже потом обернуть в нужные контейнеры. Как луковица.


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


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




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



    Margins. Preserve superview margins


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


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



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


    Конец


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


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


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

    • +16
    • 5,7k
    • 2
    Dodo Pizza Engineering
    295,21
    О том как IT доставляет пиццу
    Поделиться публикацией

    Похожие публикации

    Комментарии 2

    • НЛО прилетело и опубликовало эту надпись здесь
        0
        Часто так и делаем. В статье про то, как декомпозировать ещё сильнее, а потом собрать обратно.

        Это особо не связано с MVC, применять можно в любой архитектуре. Просто разбираемся как проще работать с UI.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое