Привет, Хабр! Представляю вашему вниманию перевод статьи «iOS Tableview with MVC», опубликованной в октябре 2016 года на Medium.com разработчиком Stan Ostrovskiy.


Пример использования UITableView в приложении

В данной статье на конкретном примере вы сможете ознакомиться с применением популярного паттерна MVC, при проектировании одного из самых популярных элементов интерфейса UITableView. Также данная статья в довольно понятном и доступном виде дает возможность понять базовые архитектурные принципы при проектировании вашего приложения, а также дает возможность ознакомиться с элементом UITableView. Учитывая тот факт, что немалое количество разработчиков часто пренебрегают какими-либо архитектурными решениями при создании своих приложений, считаю что данная статья будет очень полезна как для начинающих разработчиков, так и для программистов с определенным опытом. Паттерн MVC продвигается самой компанией Apple и является самым популярным шаблоном, используемым при разработке под iOS. Это не значит, что он подходит для любых задач и всегда является оптимальным выбором, но, во-первых, с помощью MVC проще всего получить общее понимание построения архитектуры вашего приложения, и, во-вторых, довольно часто MVC действительно хорошо подходит для решения определенных задач проекта. Данная статья поможет вам структурировать ваш код, сделать его удобным, переиспользуемым, читаемым и компактным.

Если вы занимаетесь разработкой iOS проектов, то вы уже знаете что одним из самых используемых компонентов является UITableView. Если же вы пока не разрабатываете под iOS, то в любом случае можете увидеть что UITableView используется во многих современных приложениях, таких как Youtube, Facebook, Twitter, Medium, а также в подавляющем большинстве мессенджеров и т.д. Проще говоря, каждый раз, когда вам необходимо отображать переменное количество объектов данных, вы используете UITableView.

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

Итак, вы хотите добавить UITableView в ваш проект.

Самый очевидный путь, которым обычно идут это UITableViewController, в который уже сразу встроен UITableView. Его настройка довольно проста, вам необходимо добавить свой массив данных и создать ячейку таблицы. Это выглядит просто и работает так как мы хотим, кроме нескольких моментов: во-первых, код UITableViewController становится огромным и во-вторых, это ломает всю концепцию паттерна MVC.

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

Существует несколько методов передачи данных между Model и Controller, в это статье я буду использовать делегирование. Этот подход позволяет получить понятный, модульный и переиспользуемый код.

Вместо использования одного UITableViewController, мы разобьем его на несколько классов:

  • DRHTableViewController: мы сделаем его подклассом UIViewController, и добавим UITableView как его подвид(subview)
  • DRHTableViewCell: подкласс UITableViewCell
  • DRHTableViewDataModel: этот класс будет заниматься запросами API, формировать данные и возвращать их в DRHTableViewController используя делегирование
  • DRHTableViewDataModelItem: простой класс, который содержит данные, которые мы будем отображать в ячейке DRHTableViewCell

Давайте начнем с UITableViewCell

Часть 1: TableViewCell

Создайте новый проект как “Single View Application”, и удалите стандартные файлы ViewController.swift и Main.storyboard. Все файлы которые нам потребуются мы создадим позже, шаг за шагом.

Для начала создайте подкласс UITableViewCell. Если вы хотите использовать XIB-файл, отметьте опцию “Also create XIB file”.



Для этого примера мы используем ячейку таблицы со следующими полями:

  1. Avatar Image (изображение пользователя)
  2. Name Label (имя пользователя)
  3. Date Label (дата)
  4. Article Title (заголовок статьи)
  5. Article Preview (предпросмотр статьи)

Вы можете использовать Autolayout как угодно, потому что дизайн ячейки таблицы не влияет ни на что, из того что мы делаем в данном руководстве. Создайте outlet для каждого подвида(subview). Ваш файл DRHTableViewCell.swift должен выглядеть следующим образом:

class DRHTableViewCell: UITableViewCell {
   @IBOutlet weak var avatarImageView: UIImageView?
   @IBOutlet weak var authorNameLabel: UILabel?
   @IBOutlet weak var postDateLabel: UILabel?
   @IBOutlet weak var titleLabel: UILabel?
   @IBOutlet weak var previewLabel: UILabel?
}

Как вы можете заметить, я поменял все дефолтные значения @IBOutlet с "!" на "?". Каждый раз, когда вы добавляете UILabel из InterfaceBuilder в ваш код, то к переменной автоматически в конце добавляется "!", это означает что переменная объявлена как неявно извлекаемый опционал. Так происходит чтобы обеспечить совместимость с API Objective-C, но я предпочитаю не пользоваться принудительным извлечением, поэтому использую вместо этого обычные опционалы.

Далее нам необходимо добавить метод для инициализации всех элементов ячейки таблицы(ярлыки, картинки и тд.). Вместо использования отдельных переменных для каждого элемента давайте создадим небольшой класс DRHTableViewDataModelItem.

class DRHTableViewDataModelItem {
   var avatarImageURL: String?
   var authorName: String?
   var date: String?
   var title: String?
   var previewText: String?
}

Дату конечно лучше хранить как тип Date, но для упрощения, в нашем примере будем хранить ее как String.

Все переменные являются опционалами, поэтому можно не переживать за их значения по умолчанию. Чуть позже мы напишем Init(), а теперь давайте вернемся к DRHTableViewCell.swift и добавим следующий код, который проинициализирует все элементы нашей ячейки таблицы.

func configureWithItem(item: DRHTableViewDataModelItem) {
   // setImageWithURL(url: item.avatarImageURL)
   authorNameLabel?.text = item.authorName
   postDateLabel?.text = item.date
   titleLabel?.text = item.title
   previewLabel?.text = item.previewText
}

Метод SetImageWithURL зависит от того как вы собираетесь работать подгрузкой изображений в проекте, поэтому я не буду описывать его в данной статье.

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

Часть 2: TableView

В этом примере будем использовать viewController в Storyboard. Сначала создадим подкласс UIViewController:



В данном проекте я буду использовать UIViewController вместо UITableViewController, чтобы расширить возможности контроля на элементами. Также, использование UITableView в качестве подвида, позволит вам разместить таблицу как угодно, при помощи Autolayout. Далее, создадим файл storyboard и дадим ему аналогичное имя DRHTableViewController. Перетащите ViewController из библиотеки с объектами и впишите в него имя класса:



Добавьте UITableView привяжите ее ко всем четырем краям контроллера:



И в конце добавьте аутлет tableView в DRHTableViewController:

class DRHTableViewController: UIViewController {
   @IBOutlet weak var tableView: UITableView?
}

Мы уже создали DRHTableViewDataModelItem, поэтому можем добавить следующую локальную переменную в класс:

fileprivate var dataArray = [DRHTableViewDataModelItem]()

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

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

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

tableView?.register(nib: UINib?, forCellReuseIdentifier: String)

Вместо того чтобы создавать nib перед вызовом этого метода и вписывать длинный и сложный идентификатор нашей ячейки, мы сделаем и Nib и ReuseIdentifier свойствами класса DRHTableViewCell

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

Откройте DRHTableViewCell и добавьте следующий код в начало класса:

class DRHMainTableViewCell: UITableViewCell {
   class var identifier: String { 
      return String(describing: self)
   }
   class var nib: UINib { 
      return UINib(nibName: identifier, bundle: nil)
   }
   .....
}

Сохраните изменения и вернитесь в DRHTableViewController. Вызов метода registerNib станет выглядеть намного проще:

tableView?.register(DRHTableViewCell.nib, forCellReuseIdentifier: DRHTableViewCell.identifier)

Не забудьте настроить tableViewDataSource и TableViewDelegate на self.

override func viewDidLoad() {
   super.viewDidLoad()
   tableView?.register(DRHTableViewCell.nib, forCellReuseIdentifier:   
   DRHTableViewCell.identifier)
   tableView?.delegate = self
   tableView?.dataSource = self
}

Как только вы это сделаете, компилятор выдаст ошибку: “Cannot assign value of type DRHTableViewController to type UITableViewDelegate”(Не могу присвоить значение типа DRHTableViewController типу UITableViewDelegate).

Когда вы используете подкласс UITableViewController, у вас уже есть встроенные delegate и datasource. Если же вы добавляете UITableView как подвид UIViewController, вам необходимо реализовывать соответствие UIViewController протоколам UITableViewControllerDelegate и UITableViewControllerDataSource самостоятельно.

Чтобы избавиться от данной ошибки, просто добавьте два расширения классу DRHTableViewController:

extension DRHTableViewController: UITableViewDelegate {

}
extension DRHTableViewController: UITableViewDataSource {

}

После этого появится другая ошибка: “Type DRHTableViewController does not conform to protocol UITableViewDataSource”(Тип DRHTableViewController не соответствует протоколу UITableViewDataSource). Это происходит потому что существует несколько обязательных методов, которые необходимо реализовать в этих расширениях.

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

Все методы в UITableViewDelegate являются необязательными, поэтому ошибок не будет если вы их не переопределите. Кликните мышкой с зажатым «Command» на UITableViewDelegate, чтобы посмотреть какие методы доступны. Самые часто используемые это методы выбора ячеек таблицы, установки высоты ячейки таблицы и конфигурации верхнего и нижнего заголовков таблицы.

Как вы могли заметить, два метода, упомянутые выше, должны возвращать значение, поэтому вы снова видите ошибку ”Missing return type”(Отсутствует возвращаемое значение). Давайте это поправим. Для начала установим количество столбцов в секции: мы уже объявили массив данных dataArray, поэтому мы можем просто взять его количество элементов:

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return dataArray.count
}

Некоторые могли заметить, что я не переопределил другой метод: numberOfSectionsInTableView, который обычно используется в UITableViewController. Этот метод необязательный и он возвращает значение по умолчанию равное единице. В данном примере у нас только одна секция в tableView, поэтому нет никакой необходимости переопределять данный метод.

Последний шаг в конфигурировании UITableViewDataSource это настройка ячейки таблицы в методе cellForRowAtIndexPath:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
   if let cell = tableView.dequeueReusableCell(withIdentifier: 
   DRHTableViewCell.identifier, for: indexPath) as? DRHTableViewCell   
   {
      return cell 
   }
   return UITableViewCell()
}

Давайте рассмотрим его построчно.

Для того чтобы создать ячейку таблицы, мы вызываем метод dequeueReusableCell с идентификатором DRHTableViewCell. Он возвращает UITableViewCell, и, соответственно, мы используем опциональное приведение типов из UITableViewCell в DRHTableViewCell:

let cell = tableView.dequeueReusableCell(withIdentifier: 
   DRHTableViewCell.identifier, for: indexPath) as? DRHTableViewCell

Далее мы производим безопасное извлечение опционала и в случае успеха возвращаем ячейку:

if let cell = tableView.dequeueReusableCell(withIdentifier: 
   DRHTableViewCell.identifier, for: indexPath) as? DRHTableViewCell   
   {
      return cell 
   } 

Если же не получилось извлечь значение, то возвращаем ячейку по умолчанию UITableViewCell:

if let cell = tableView.dequeueReusableCell(withIdentifier: 
   DRHTableViewCell.identifier, for: indexPath) as? DRHTableViewCell   
   {
      return cell 
   }
return UITableViewCell()

Может мы все-таки что-то забыли? Да, нам надо проинициализировать ячейку данными:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
   if let cell = tableView.dequeueReusableCell(withIdentifier: 
   DRHTableViewCell.identifier, for: indexPath) as? DRHTableViewCell 
   { 
      cell.configureWithItem(item: dataArray[indexPath.item])
      return cell
   }
   return UITableViewCell()
}

Теперь мы готовы к заключительной части: надо создать и подключить DataSource к нашей TableView

Часть 3: DataModel

Создайте класс DRHDataModel.

Внутри этого класса мы запрашиваем данные либо из файла JSON, либо с помощью HTTP-
запроса или просто из локального файла с данными. Это не то, на чем бы мне хотелось сосредоточиться в данной статье, поэтому, поэтому я предположу что мы уже сделали API-запрос и он вернул нам опциональный массив типа AnyObject и опциональную ошибку Error:

class DRHTableViewDataModel {
   func requestData() {
      // code to request data from API or local JSON file will go   
         here
      // this two vars were returned from wherever:
      // var response: [AnyObject]?
      // var error: Error?
      if let error = error {
          // handle error
      } else if let response = response {
          // parse response to [DRHTableViewDataModelItem]
          setDataWithResponse(response: response)
      }
   }
}

В методе setDataWithResponse мы заполним массив из DRHTableViewDataModelItem, используя полученный в запросе массив. Добавьте следующий код ниже requestData:

private func setDataWithResponse(response: [AnyObject]) {
   var data = [DRHTableViewDataModelItem]()
   for item in response {
     // create DRHTableViewDataModelItem out of AnyObject
   }
}

Как вы помните, мы не еще не создали никакого инициализатора для DRHTableViewDataModel. Поэтому давайте вернемся в класс DRHTableViewDataModel и добавим метод для инициализации. В этом случае мы будем использовать опциональный инициализатор со словарем [String: String]?..

init?(data: [String: String]?) {
if let data = data, let avatar = data[“avatarImageURL”], let name = data[“authorName”], let date = data[“date”], let title = data[“title”], let previewText = data[“previewText”] {
self.avatarImageURL = avatar
self.authorName = name
self.date = date
self.title = title
self.previewText = previewText
} else {
   return nil
}
}

Если какое-либо поле будет отсутствовать в словаре, или сам словарь будет nil, инициализация не пройдет (вернет nil).

Имея данный инициализатор мы можем создать метод setDataWithResponse в классе DRHTableViewDataModel:

private func setDataWithResponse(response: [AnyObject]) {
   var data = [DRHTableViewDataModelItem]()
   for item in response {
     if let drhTableViewDataModelItem =   
     DRHTableViewDataModelItem(data: item as? [String: String]) {
        data.append(drhTableViewDataModelItem)
     }
   }
}

После завершения цикла for у нас будет готовый заполненный массив из DRHTableViewDataModelItem. Как же теперь передать этот массив в TableView?

Часть 4: Delegate

Сначала создайте протокол делегата DRHTableViewDataModelDelegate в файле DRHTableViewDataModel.swift сразу над объявлением класса DRHTableViewDataModel:

protocol DRHTableViewDataModelDelegate: class {
}

Внутри этого протокола мы также создадим два метода:

protocol DRHTableViewDataModelDelegate: class {
   func didRecieveDataUpdate(data: [DRHTableViewDataModelItem])
   func didFailDataUpdateWithError(error: Error)
}

Ключевое слово «class» в протоколе ограничивает применяемость протокола до типов класса(исключая структуры и перечисления). Это важно, если мы собираемся использовать слабую ссылку на делегата. Мы должны быть уверены, что не создадим цикл сильных ссылок между делегатом и делегируемыми объектами, поэтому мы используем слабую ссылку(см. ниже)

Далее добавьте опциональную слабую переменную в класс DRHTableViewDataModel:

weak var delegate: DRHTableViewDataModelDelegate?

Теперь нам необходимо добавить метод делегата. В данном примере, нам необходимо передать ошибку Error, в случае если запрос данных не прошел, в случае же успешного запроса мы создадим массив данных. Метод обработчика ошибки находится внутри метода requestData

class DRHTableViewDataModel {
func requestData() {
      // code to request data from API or local JSON file will go   
         here
      // this two vars were returned from wherever:
      // var response: [AnyObject]?
      // var error: Error?
      if let error = error {
          delegate?.didFailDataUpdateWithError(error: error)
     } else if let response = response {
          // parse response to [DRHTableViewDataModelItem]
          setDataWithResponse(response: response)
     }
   }
}

И, наконец, добавьте второй метод делегата в конец метода setDataWithResponse:

private func setDataWithResponse(response: [AnyObject]) {
   var data = [DRHTableViewDataModelItem]()
   for item in response {
      if let drhTableViewDataModelItem =  
      DRHTableViewDataModelItem(data: item as? [String: String]) {
         data.append(drhTableViewDataModelItem)
      }
   }
   delegate?.didRecieveDataUpdate(data: data)
}

Теперь мы готовы передать данные в tableView.

Часть 5: Отображение данных

С помощью DRHTableViewDataModel мы можем заполнить наш tableView данными. Для начала нам надо создать ссылку на dataModel внутри DRHTableViewController:

private let dataSource = DRHTableViewDataModel()

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

override func viewWillAppear(_ animated: Bool) {
   super.viewWillAppear(true)
   dataSource.requestData()
}

Это простой пример, поэтому я делаю запрос данных в viewWillAppear. В реальном же приложении это будет зависеть от множества факторов, таких как время кэширования данных, использования API, и логики работы приложения.

Далее, установим делегата на self, в методе ViewDidLoad:

dataSource.delegate = self

Вы снова увидите ошибку, потому что DRHTableViewController пока еще не реализует функции DRHTableViewDataModelDelegate. Исправьте это, добавив следующий код в конец файла:

extension DRHTableViewController: DRHTableViewDataModelDelegate {
   func didFailDataUpdateWithError(error: Error) {
   }
   func didRecieveDataUpdate(data: [DRHTableViewDataModelItem]) {
   }
} 

И наконец, нам надо обработать события didFailDataUpdateWithError и didRecieveDataUpdate:

extension DRHTableViewController: DRHTableViewDataModelDelegate {
   func didFailDataUpdateWithError(error: Error) {
       // handle error case appropriately (display alert, log an error, etc.)
   }
   func didRecieveDataUpdate(data: [DRHTableViewDataModelItem]) {    
       dataArray = data
   }
}

Как только мы инициализируем данными наш локальный массив dataArray, мы готовы обновить таблицу. Но вместо того чтобы делать это в методе didRecieveDataUpdate, мы используем обозреватель свойства dataArray:

fileprivate var dataArray = [DRHTableViewDataModelItem]() {
   didSet {
      tableView?.reloadData()
   }
}

Код внутри didSet выполнится сразу же после инициализации dataArray, то есть именно тогда когда нам надо.

Вот и все! Теперь у вас есть работающий прототип tableView, с индивидуально сконфигурированной ячейкой таблицы и инициализированной данными. И у вас нет никаких классов tableViewController'а c несколькими тысячами строк кода. Каждый блок кода который вы создали является переиспользуемым и может быть использован повторно в любом месте проекта, что дает неоспоримые преимущества.

Для вашего удобства, вы можете ознакомиться с полным кодом проекта по следующей ссылке на Github.

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


  1. varton86
    26.04.2018 13:01

    «Дату конечно лучше хранить как тип Data».
    Лучше все-таки как тип Date.


  1. Passt Автор
    26.04.2018 13:01

    Ага, спасибо, опечатался )