Новогодние праздники прошли, а мое стремление писать полезные и не очень статьи — нет! Сегодня поговорим о UITableView, работе с UITableViewDataSource и переиспользовании ячеек. Затронем как установить рут контроллер без сториборда, ошибки при работе с таблицей, лейаут и большой заголовок для UINavigationBar.

Для тех, кому нравятся несмешные шутки, я записал ролик на YouTube. Ну а здесь всё будет серьезно. Давайте начнём.

Создадим пустой проект, назовем как душе угодно и перейдем в контроллер. В UIKit есть класс UITableViewController. Вы можете нагуглить много туториалов, где таблицу показывают именно в контексте этого класса. Но для большего понимания все сделаем в базовом UIViewController.

Чаще всего, когда нужна таблица, используется UINavigationController:



Давайте добавим его. В файл AppDelegate, функцию didFinishLaunchingWithOptions вставим следующий код:

let navigationController = UINavigationController.init(rootViewController: ViewController())
self.window = UIWindow.init(frame: UIScreen.main.bounds)
self.window?.rootViewController = navigationController
self.window?.makeKeyAndVisible()

Короткий ликбез зачем: cториборды и констрейнты — добро, но в этом туториале постараемся обойтись без них. Этот код позволит игнорировать сториборд (его можно удалить) и обернёт ViewController в UINavigationController.

Хорошо бы установить заголовок для UINavigationBar. Для этого мы перейдем в класс ViewController (всю дальнейшую работу мы будем делать тут) и в метод viewDidLoad добавим следующий код:

self.view.backgroundColor = UIColor.white
self.navigationItem.title = "Table"
self.navigationController?.navigationBar.prefersLargeTitles = true

Теперь заголовок станет большим и модным. Запустив проект, увидим следующее:



Делаем TableView


Подготовительные действия закончены, можем переходить к главному. В контроллере создадим проперти UITableView. Выберем инициализатор, который имеет параметр Style. Фрейм выставим любой, к нему мы вернемся потом. А для стиля установите Grouped.

let tableView = UITableView.init(frame: .zero, style: UITableView.Style.grouped)

Почему такое название стиля — понятия не имею, но он позволит нам сделать нативную таблицу, как в приложении Настройки. Если вам нужна не стилизованная таблица — используйте инициализатор только с параметром frame.



Layout


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

private func updateLayout(with size: CGSize) {
   self.tableView.frame = CGRect.init(origin: .zero, size: size)
}

Вызвать функцию нужно в двух местах — в методе viewDidLoad и в viewWillTransition:

override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
   super.viewWillTransition(to: size, with: coordinator)
   coordinator.animate(alongsideTransition: { (contex) in
      self.updateLayout(with: size)
   }, completion: nil)
}

Теперь таблица при любой ориентации будет размещена на весь экран. В метод updateLayout можно добавлять другие обработки.

Делаем UITableViewCell


Так как туториал не о ячейках, а таблице, то подробно на кастомизации останавливаться не будем. Сделаем класс ячейки, наследуемая от базового:

class TableViewCell: UITableViewCell {

}

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

self.tableView.register(TableViewCell.self, forCellReuseIdentifier: "TableViewCell")

Если с классом понятно, то идентификатор заслуживает внимания. В 99% случаев сработает правило:

— "Ячейки одного класса должны иметь один идентификатор"

Ситуаций, когда для ячеек одного класса нужны разные идентификаторы, крайне мало, и в основном они связаны с отрисовкой кодом и анимациями.

DataSource


Это проперти, которое указывает на объект, который будет наполнять таблицу. Т.е. реализовывать протокол UITableViewDataSource. Обязательными являются два метода:

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {}
    
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {}

Первый отвечает за количество ячеек в секции. Cекция у нас пока одна, многосекцие (такое слово есть?) рассматривать не будем. Второй метод за получение объекта ячейки. Работает он хитро, не думайте что вы раскусили бойца!

Но сначала давайте добавим массив строк, которым будем наполнять таблицу.



Теперь перейдем к реализации первого метода:

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
   switch tableView {
   case self.tableView:
      return self.data.count
    default:
      return 0
   }
}

Мы говорим таблице что в 0-ой секции будет ячеек столько, сколько элементов в массиве data. Второй метод чуть сложнее. Просто проинициализировать ячейку не получится, и всё из-за системы переиспользования ячеек. Но не нужно ругать Apple, на самом деле это хорошо! Чтобы получит объект, нужно вызывать следующий код:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
   let cell = self.tableView.dequeueReusableCell(withIdentifier: "TableViewCell", for: indexPath) as! TableViewCell
   cell.textLabel?.text = self.data[indexPath.row]
   return cell
}

Метод dequeueReusableCell получит объект ячейки по идентификатору, а мы сделаем приведение типа при помощи as до класса TableViewCell, которым и должна быть ячейка. textLabel это проперти базового класса, никакой дополнительной настройки не требуется.

Остается указать dataSource для таблицы и метод viewDidLoad теперь должен выглядеть так:

override func viewDidLoad() {
   super.viewDidLoad()
   self.view.backgroundColor = UIColor.white
   self.navigationItem.title = "Table"
   self.navigationController?.navigationBar.prefersLargeTitles = true
        
   self.view.addSubview(self.tableView)
   self.tableView.register(TableViewCell.self, forCellReuseIdentifier: "TableViewCell")
   self.tableView.dataSource = self
        
   self.updateLayout(with: self.view.frame.size)
}

Если мы запустим проект, то увидим таблицу, наполненную контентом:



Переиспользование


Пока кажется что всё в порядке. Давайте добавим для одной из ячеек disclosureIndicator. Это аксессуар, который вы точно встречали в iOS:



Допустим стоит задача установить индикатор только для первой ячейки. Первое что придет на ум — добавить простой if в метод cellForRowAt:

if indexPath.row == 0 {
   cell.accessoryType = .disclosureIndicator
}

Но враг прячется! Давайте запустим проект, и проскролим таблицу за пределы экрана:



Индикатор появился для первой, но бессистемно появляется и для других ячеек! Это и есть переиспользование — берётся ячейка, которая удобнее лежит в памяти (отличное объяснение для новичков, правда?) и конфигурируется согласно методу. Иногда случается что вытягивается ячейка с индикатором. Как итог — имеем такой баг. Что делать?

Всё просто. Есть два варианта решения. Первый заключается в блоке else и по сути подразумевает под собой однозначное конфигурирование любой ячейки. Метод будет выглядеть так:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
   let cell = self.tableView.dequeueReusableCell(withIdentifier: "TableViewCell", for: indexPath) as! TableViewCell
   cell.textLabel?.text = self.data[indexPath.row]
   if indexPath.row == 0 {
      cell.accessoryType = .disclosureIndicator
   } else {
      cell.accessoryType = .none
   }
   return cell
}

Есть другой способ — реализовать метод prepareForReuse у ячейки. Как видно из названия — метод вызывается перед переиспользованием. Все что нужно — сбросить ячейку до дефолта. Код выглядит так:

class TableViewCell: UITableViewCell {
    
    override func prepareForReuse() {
        super.prepareForReuse()
        self.accessoryType = .none
    }
}

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

Для ищущих


Я стараюсь регулярно записывать туториал на своем канале. Можно найти ролики как рисовать кодом или как сделать контроллер плеера Apple Music, а так же ролик по этой статье:

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


  1. bonyadmitr
    17.01.2019 18:46

    Вызвать функцию нужно в двух местах — в методе viewDidLoad и в viewWillTransition

    Во viewDidLoad неправильно. Размеры не выставлены еще.
    Вроде оно может зависеть от iOS и симулятора.


    Сам использую viewWillLayoutSubviews для данной задачи.


    1. IvanVorobei Автор
      17.01.2019 18:50

      Этот метод будет лейаутить без анимации. Правильнее через координатор.


      Во viewDidLoad вполне можно, попробуй. SafeArea не выставлены, но размеры view контроллера уже корректные.


      1. storoj
        18.01.2019 03:32

        Корректные это какие? Такие же, как UIScreen.bounds? Но это лишь частный полноэкранный случай. Который также нарушается при реализации loadView() типа self.view = UIView(frame: .zero)


        1. IvanVorobei Автор
          18.01.2019 05:23

          В этом случае сработает метод viewWillTransition. Не понимаю причин спора. Вы можете придумать случай когда рут-контроллер не на весь экран?


  1. alexwillrock
    17.01.2019 19:23

    в чем профит использовать frame а не autolayout для таблицы?


    1. IvanVorobei Автор
      17.01.2019 19:26

      Конкретно для одной таблицы — смысла нет, даже плохо (об этом есть и в видео, и в тексте).


      Показал в ознакомительных целях


  1. storoj
    18.01.2019 03:43

    Предлагаю попробовать такой код:


    let vc = ViewController()
    vc.view.backroundColor = .red
    let navigationController = UINavigationController.init(rootViewController: vc)
    self.window = UIWindow.init(frame: UIScreen.main.bounds)
    self.window?.rootViewController = navigationController
    self.window?.makeKeyAndVisible()

    и пересмотреть своё отношение к viewDidLoad и настройке navigationBar из чилда navigation контроллера


    1. IvanVorobei Автор
      18.01.2019 05:21

      Если не сложно, поясните.


      Настройка бара мне кажется вполне логичной. Особенно учитывая что бар подразумевает разные состояния и заголовки в зависимости от этого чилда.


      1. storoj
        19.01.2019 02:24

        Плохо, когда дочерний элемент управляет парентом, "хвост виляет собакой". Чилдов у UINavigationController может быть много, и если они наперебой будут что-то трогать в своём паренте, его состояние быстро станет неконсистентным. В данном случае стиль заголовков мог бы быть задан или в appDelegate после создания navigationController, или в каком-нибудь viewDidLoad наследника UINavigationController.


        Хорошим примером обратной связи с navigationController является navigationItem. Заголовок экрана, заголовок backButton и всё остальное наполнение navigationBar прочитывается парентом-навконтроллером, и он же централизованно занимается лейаутом и наполнением своего navigationBar. А ведь всегда есть такой соблазн брать и вставлять разные данные в навбар где-то в viewWillAppear чилда, что является большой ошибкой.


        1. IvanVorobei Автор
          19.01.2019 05:21

          Я все равно из ответа не понял как правильно установить разные стили для навигейшн бара православно)


          Давайте возьмём ситуацию. У меня один навигейшн контроллер, и через него я показываю… пускай 15 контроллеров. У 7-ми из них большой и модный новый заголовок, у 8-ми — классический маленький.


          Как стоит правильно сделать это?


          1. storoj
            19.01.2019 10:32

            Например сделать это дополнительным свойством у navigationItem, и обрабатывать его или в UINavigationDelegate, или в сабклассе UINavigationController.


            1. IvanVorobei Автор
              20.01.2019 10:52

              Попробую привести аргументы.

              • Любая настройка стиля, вынесенная за пределы контролера (будь то большой / маленький навигейшн, заголовок и т.д.) обязательно должна вылиться в Switch-Сase или тому подобное. Не важно где, но Вы предлагаете проверять какой класс сейчас покажется и в зависимости от этого менять прааметры. Я вижу это неудобным — эти параметры связаны с самим контроллером.
              • Само наличие проперти навигейшн бара. К слову, опциональное. Нет никакой проблемы обратиться к опциональному проперти.
              • В примерах от Apple найдете работу именно через чилда.
              • Ваша метаформа с хвостом собаки некорректная, и сбивает. Во первых, она не учитывает опциональность. Второе и важное — чилдов много. У собаки много хвостов не бывает)


              Как я понял, единственной мотивацией вынести найстройку навигейшн бара для конкретного контроллера из этого контроллера — Ваша метафора. Подумайте о следующих моментах:

              • У вас появляется отдельное место для настройки бара. Зачем? Макконел не одобрит.
              • Вы напишите больше кода
              • Работать будет не лучше. Читать код будет сложнее в силу его разрозненности


              И это все ради метафоры, которая вообще не подходит к этому случаю.


              1. storoj
                20.01.2019 13:44

                Смысл собаки и хвоста в том, что navigationBar на самом деле никакого отношения к ViewController не имеет. Не может child управлять отображением парента, потому что:

                * само наличие этого парента не гарантировано в любой момент времени. В данном примере есть код, который меняет стиль заголовков navigationBar во viewDidLoad чилда, хотя я привёл пример, когда viewDidLoad может вызваться и до того, как ViewController встроится в иерархию UINavigationController. Т.е. код в примере «надеется» на то, что viewDidLoad вызовется в ожидаемый момент времени, что часто нарушается на практике.

                * ожидается, что `navigationController` – это первый и непосредственный парент ViewController. Т.е. есть уверенность в том, что этот NavigationController будет использовать navigationItem этого контроллера для настройки своего navigationBar. Этот факт тоже может сломаться, если, например, встроить похожий ViewController2 в ViewController. Если ViewController2 так же как и ViewController будет пытаться трогать navigationBar – они начнут друг с другом конкурировать за установку свойств. Потому что и у ViewController, и у ViewController2 будет одна и та же ссылка на navigationController. Получится так, что navigationController собирается отображать своим контентом ViewController, прочитает из его navigationItem нужные данные, настроит на их основе navigationBar и всё остальное, но какой-нибудь цвет фона navigationBar-у задаст child второго уровня (ViewController2).

                Надеюсь, уже в этом случае нет сомнений в том, что ViewController2 не должен иметь совершенно никакого отношения к navigationBar.

                * наличие проперти navigationController в UIViewController – это не аргумент. Ну вот так вот сделали, ещё во времена, когда не было контроллеров-контейнеров. Я склоняюсь к тому, что оно так сделано только лишь для простой возможности сделать «push», иначе разрабатывать стало бы на порядок сложнее (хоть и «правильнее»). Делая push из своего ViewController, приходится делать предположение, что он лежит в UINavigationController, что в любой момент времени может перестать быть правдой. У Apple была попытка добавить UIViewController.show(), но он тоже не очень-то помог.

                * Макконел как раз-таки одобрит. Мой поинт в том, что нехорошо пытаться трогать чужую view, да ещё и родительскую. Допустим, в приложении нужно уметь задавать разные цвета фонов для navigationBar при переходах между экранами. Казалось бы, очевидное решение – делать это в каждом viewController где-нибудь в viewWillAppear.

                Логика железная: зачем я буду эти цвета куда-то выносить, если они относятся только к этому контроллеру? Но нет, это не логика каждого чилда – это логика NavigationController! Это он должен в одной точке принимать решение какой цвет откуда взять и как выставить на _свой_ navigationBar, так как именно NavigationController владеет этой вьюхой.

                Более того, в сетапе с viewWillAppear мы будем обязаны гарантировать, что обязательно каждый контроллер будет выставлять свой цвет фона бара, что невозможно сделать. Представим, у нас есть два пользовательских контроллера BlueBarViewController, GreenTitleViewController и один системный, например QLPreviewController.

                * BlueBar – задаёт синий barTintColor у navigationBar
                * GreenTitle – задаёт зелёный tintColor у navigationBar
                * QLPreviewController – ничего никому не задаёт, живёт обычной жизнью, встраивается куда встраивают и просто отображает свой контент. И мы, слава богу, никак на это не можем повлиять.

                Первым в иерархии появляется BlueBarController, меняет цвет фона на синий, пока всё хорошо.
                Пушим GreenTitle, получаем синий цвет фона и зелёные тексты, уже наполовину хорошо.
                Пушим QLPreviewController, и он тоже получает все эти спецэффекты.
                Делаем pop на BlueBar – он из изначально «хорошего» своего состояния тоже оказывается «сломанным».

                Всё потому, что точка управления navigationBar размазалась по всему приложению.

                * и, наконец, переходим к «любая настройка стиля приводит к switch-case, что неудобно». На мой взгляд, это чуть ли не единственно верный путь. Во-первых, появляется намерение хотя бы явно сформулировать эти стили. Во-вторых, в итоге управление ими сконцентрируется в одном месте, и на более логичном уровне – уровне UINavigationController (т.к. именно он владеет navigationBar-ом, значит и управлять им должен тоже он). На мой взгляд, нужные настройки стилизации неплохо бы добавить в UINavigationItem (но это сложновато хакнуть), или же каждый ViewController может являться таким dataSource для NavigationController.

                * финальная мысль: UIViewController внутри UINavigationController – это всего лишь контент. Его не должно заботить ни то, в каком он находится контейнере, ни какие у него размеры, ни как выглядят соседние с ним вьюхи. Общая стилизация (например цвет в navigationBar в зависимости от текущего viewController) может быть выполнена или на уровне UINavigationController (потому что он одновременно владеет своим баром, и знает про текущих чилдов), или же вообще где-то сбоку, на ещё более высоком уровне – например в UINavigationControllerDelegate.


  1. Owagam
    20.01.2019 10:42

    Согласен с комментаторами выше, по поводу написания этого всего в констрэйнтах. НО если хочется в фреймах, то зачем писать такой ужас «private func updateLayout(with size: CGSize) {
    self.tableView.frame = CGRect.init(origin: .zero, size: size)
    }» и вызывать его в 10 местах, если можно переопределить проперти intrinsicContentSize и указать размеры твоей таблицы


    1. IvanVorobei Автор
      20.01.2019 10:48

      Я устал повторять — конкретно этот пример, конечно, лучше было сделать в констрейнтах. Я показал альтернативнвый способ. Мне часто приходится делать сложные лейауты, и поддержку два ориентаций. И не просто растягивать интерфейс, а именно менять расположение элементов. И, очевидно, сложные лейауты быстрее делаются при помощи фреймов.

      Эту систему я показал в образовательных целях, конечно для примера она излишняя (очевидно что изляшняя).

      Насчёт вызывать в разных местах — да, не вижу проблемы. Если вспомнить что ‘SadeAreaDidChange’ тоже провоцирует изменение лейаута, то нужно и там. Всего 3 места для вызова функции, но в итоге плавный лейаут при Семёне ориентации и больше возможностей кастомизации.

      Обсуждение на самом деле ни о чем. Я согласен что констрейнты здесь уместны, и несколько раз сказал что альтернативный метод до ознакомительных целей. У какого опытного разработчика могла появится мысль что в этот примере такой способ уместен, не понимаю.