У вас большой UIViewController? У многих да. С одной стороны, в нём работа с данными, с другой — с интерфейсом.

Задачи отделения логики от интерфейса описаны в сотнях статей про архитектуру: MVP, MVVM, VIPER. Они решают проблему потока данных, но не отвечают на вопрос как работать с интерфейсом: в одном месте остается создание элементов, лейаут, настройка, обработка ввода и анимации.

Давайте отделим view от controller и посмотрим чем нам поможет loadView().



Интерфейс приложения для iOS — это иерархия UIView. Задачи каждой view: создать элементы, настроить, разложить по местам, анимировать. Это видно из методов, которые есть в классе UIView: addSubview(), drawRect(), layoutSubviews().

Если посмотреть на методы класса UIViewController, то видно, что он занимается управлением view: загружает, реагирует на загрузку экранов и действия пользователя, показывает новые экраны. Часто код, который должен быть в UIView, мы пишем в подклассах UIViewController, от этого он становится слишком большим. Отделим его.

loadView()


Жизненный цикл UIViewController начинается с loadView(). Упрощённая реализация выглядит так:

// CustomViewController.swift

func loadView() {
   self.view = UIView()
}

Мы можем переопределить метод и указать свой класс.

super.loadView() вызывать не нужно!

// CustomViewController.swift

override func loadView() {
   self.view = CustomView()
}

Реализация CustomView.swift
// CustomView.swift

final class CustomView {

   let square: UIView = UIView()

   init() {
      super.init()
      square.backgroundColor = .red
      addSubview(square)
   }
}


Контроллер загрузит CustomView, добавит его в иерархию, выставит .frame. Свойство .view будет нужного нам класса:

// CustomViewController.swift

print(view) // CustomView

Но пока компилятор не знает о классе и считает, что там обычный UIView. Поправим это функцией с приведением типа:

// CustomViewController.swift

func view() -> CustomView {
   return self.view as! CustomView
}

Теперь можно видеть переменные CustomView:

// CustomViewController.swift

func viewDidLoad() {
   super.viewDidLoad()

   view().square // Работает
}

Упрощаем с помощью associatedtype
Руслан Кавецкий предложил убрать дублирование кода с помощью расширения протокола:

protocol ViewSpecificController {
    associatedtype RootView: UIView
}
extension ViewSpecificController where Self: UIViewController {
    func view() -> RootView {
        return self.view as! RootView
    }
}

Для каждого нового контроллера нужно только указать протокол и подкласс для его UIView через typealias:

// CustomViewController.swift

final class CustomViewController: UIViewController, ViewSpecificController {
   typealias RootView = CustomView

   func viewDidLoad() {
      super.viewDidLoad()

      view().square // Работает
   }
}

Код в подклассе UIView


Создание и настройка контролов


Шрифты, цвета, констрейнты и иерархию можно настроить прямо в конструкторе CustomView:

// CustomView.swift

init() {
   super.init()
      
   backgroundColor = .lightGray
   addSubview(square)
}

layoutSubviews()


Лучшее место для ручного лейаута — метод layoutSubviews(). Он вызывается каждый раз, когда меняется размер view, поэтому можно опираться на размер bounds для правильных расчётов:

// CustomView.swift

override func layoutSubviews() {
   super.layoutSubviews()

   square.frame = CGRect(x: 0, y: 0: width: 200, height: 200)
   square.center = CGPoint(x: bounds.width / 2, y: bounds.height / 2)
}

Приватные контролы, публичные свойства


Если есть время, то я делаю property контролов приватными, но управляю ими через публичные переменные или функции «в области знаний». Проще на примере:

// CustomView.swift

private let square = UIView()

var squarePositionIsValid: Bool {
   didSet {
     square.backgroundColor =  squarePositionIsValid? .green : .red
   }
}

func moveSquare(to newCenter: CGPoint) {
    square.center = newCenter
}

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

Что остаётся во viewDidLoad()?


Если использовать Interface Builder, то часто viewDidLoad() пустой. Если view создавать в коде, то нужно привязать их действия через target-action паттерн, добавить UIGestureRecognizer или связать делегаты.

Настраиваем через Interface Builder


Подкласс для view можно настроить через Interface Builder (далее IB).

Нужно выделить объект view (не контроллер) и задать его класс. Писать собственный loadView() не нужно, контроллер сделает это сам. Но приводить тип UIView всё ещё приходится.



IBOutlet в UIView


Если выбрать контрол внутри view, то Assistant Editor распознает класс UIView и предложит его в качестве второго файла в Automatic режиме. Так можно переносить IBOutlet во view.



Если не работает
Открыть класс CustomView вручную, написать IBOutlet. Теперь можно потащить за маркер и навести на элемент в IB.



Если создавать интерфейс в коде, то все объекты доступны после init(), но при работе с IB доступ к IBOutlet появляется только после загрузки интерфейса из UIStoryboard в методе awakeFromNib():

// CustomView.swift

func awakeFromNib() {
   super.awakeFromNib() 
   
   square.layer.cornerRadius = 8
}

IBAction в UIViewController


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

  • target-action от контролов
  • реализация delegate в UIViewController
  • реализация блоков
  • реакция на Notification

При этом UIViewController управляет только интерфейсом. Всё что касается бизнес-логики стоит выносить из контроллера, но это уже на выбор: MVP, VIPER и т.д.

Objective-C


В Objective-C можно полноценно заменить тип UIView. Для этого нужно объявить property с нужным классом, переопределить setter и getter, указав класс:

// CustomViewController.m

@interface CustomViewController
@property (nonatomic) CustomView *customView;
@end

@implementation
- (void)setView:(CustomView *)view{
   [super setView:view];
}

- (CustomView *)view {
   return (CustomView *)super.view;
}
@end

Конец


В примере на GitHub можно посмотреть на разделение классов для простой задачи: цвет квадрата зависит от его положения (в зелёной области он зелёный, вне её — красный).

Чем сложнее экран, тем лучше эффект: контроллер уменьшается, код переносится на свои места. Код просто переносится во view, но инкапсуляция упрощает взаимодействие и чтение кода. Иногда view можно переиспользовать с другим контроллером. Например, разные контроллеры для iPhone и iPad по-своему реагируют на появление клавиатуры, но это никак не меняет код view.

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

Комментарии (12)


  1. storoj
    10.12.2018 23:39

    А теперь смотрим, как сделан UITableViewController, что-то я там не видел property типа UITableView *view.


    1. akaDuality Автор
      11.12.2018 10:16
      +1

      Apple меняет подклассы.

      Я поставил брейкпойнт во viewDidLoad и посмотрел типы view (через po view в консоли). Результаты:
      UITableViewController — UITableView
      UICollectionViewController — UICollectionViewControllerWrapperView у которого только один дочерний UICollectionView

      Более того, можно заменить даже класс CALayer для UIView. Для этого есть type property в классе UIView:

      open class var layerClass: AnyClass { get }


      1. storoj
        11.12.2018 12:48

        можно, но никто не перекрывает UIView *view, а добавляют новое типизированное свойство


        1. storoj
          11.12.2018 12:55

          С UICollectionViewController кстати отличный пример: тип self.view никого не должен интересовать, это свойство как правило используется только для создания иерархии childViewController или subviews на этом экране. А для доступа к collectionView есть отдельное свойство. С таким подходом можно как угодно менять иерархию внутри view этого контроллера, а для клиентского кода эти изменения пройдут незамеченными.


      1. Krypt
        11.12.2018 19:03

        Это хвост из Obj-C, по видимому. Там возможно переопределить тип в заголовочном файле таким же способов. Так как objective-c использует селекторы настоящий класс не важен, важно лишь чтобы класс отзывался на нужные.


        1. storoj
          11.12.2018 19:35

          только так, как в статье в последнем параграфе тоже не делают, а используют dynamic


          1. Krypt
            11.12.2018 20:12

            @dynamic нужен только для того, чтобы сказать компилятору, что не нужно генерировать getter/setter. Вы можете изменить свойство у существующего поля. Помнится, я так с datasource делал, так как потомку от класса нужны были дополнительные данные.

            Впрочем, в случае с кастомными контроллерами, я бы поступал по аналогии с системными классами — для единообразия.


            1. storoj
              11.12.2018 20:13

              Правильно, это как раз тот случай, когда ничего не надо генерировать, т.к. UIViewController уже всё что надо сгенерировал.


              1. Krypt
                11.12.2018 20:16

                > не видел property типа UITableView *view
                К слову, раньше view и tableView возвращали один и тот же объект. На данный момент не знаю, не перепроверял.


                1. storoj
                  11.12.2018 20:18

                  Это лишь частный случай. А в UICollectionViewController уже не так.


  1. Rogaven
    11.12.2018 11:35

    На самом деле, MVC так и задумывался. В оригинале модели еще и активные были, а не просто структуры данных.


  1. anioutka
    12.12.2018 16:15

    В принципе вынесение UI в UIView и кода по его обработке аналогично реализации фрагментов в Android и применяется в тех же случаях и целях. Я подобный подход использую уже несколько лет. Только верстку я делаю на xib именно отдельного UIView, а не UIViewController. В случае, когда нужно менять в одном и том же контроллере разные виды, это более корректно.