В этой статье хотел бы описать то, как устроена работа с UITableView на наших проектах в компании.

К данному подходу мы пришли в процессе унификации и поиска наиболее удобного решения для работы с таблицами.

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

Как это выглядит обычно?

Многие знают еще с первых уроков программирования под iOS такой базовый элемент интерфейса как UITableView.

Рассмотрим самый простой случай его использования:

class SomeScreen: UIViewController {
  
	@IBOutlet weak var tableView: UITableView!
  
  private var someDataToDisplay: [SomeModel] = []

  override func viewDidLoad() {
 		super.viewDidLoad()
  	self.tableView.delegate = self
    self.tableView.dataSource = self
  }
  
}

extension SomeScreen: UITableViewDelegate, UITableViewDataSource {
  
  func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return someDataToDisplay.count
  }
  
  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    guard let cell = tableView.dequeueReusableCell(withIdentifier: "myCell", 
                                                       for: indexPath) as? MyCellClass else {
          																							return }
    cell.name = someDataToDisplay[indexPath.row]
   	cell.someData = someDataToDisplay[indexPath.row]
    return cell
  }
  
}

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

Однако, в случае, когда у нас появляется несколько различных ячеек, метод

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableView

начинает выглядеть примерно следующим образом:

  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    {
      switch indexPath.row {
      case 0: 
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "myCell", 
                                                       for: indexPath) as? MyCellClass else {
          																							return }
   			cell.name = someDataToDisplay[indexPath.row]
   			cell.someData = someDataToDisplay[indexPath.row]
    		return cell
      case 1: ........ 
        // далее идут перечисления всех видов ячеек 
      }
      
    }

Конкретный случай:

Допустим, у нас есть экран поиска в каталоге, который взаимодействует с API магазина.

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

  • ячейки для отображения запросов, которые раньше совершались с переходом по клику на товар;

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

  • ячейка для найденной категории по произведенному запросу для перехода к найденной категории товаров;

Так же стоит отметить, что модели для каждой из ячеек будут разные. В наших проектах, чаще всего, мы используем VIPER, но так как мы говорим сейчас только про TableManager, так как именно в него мы инкапсулируем работу с таблицей -- это роли не сыграет.

Решение

Для начала обозначим основные роли.

Configurator - объект который в себе инкапуслирует конфигурирование ячейки таблицы.

TableManager - класс, в котором инкапсулирована работа с таблицей.

Начнем от меньшего к большему и обсудим конфигураторы, какую роль они будут играть и зачем они вообще нам тут нужны.

Для начала - создадим протокол, под который будем подписывать наши конкретные конфигураторы.

enum CatalogCellType {
  // ячейка с запросом из истории поиска
  case historyCell
  // ячейка с продуктом, найденным в каталоге
  case productCell
  // ячейка с найденной категорией
  case categoryCell
}

protocol Configurator {
  // переменная для ячейки с reuse id 
	var reuseId: String { get }
  // тип ячейки для последующей отработки нажатия на ячейку
  var cellType: CatalogCellType { get }
  // настройка ячейки
	func setupCell(_ cell: UIView)
}

Так же, как вводные данные - имеем следующие модели данных

struct SearchResponseModel: Codable {
  // результат поисковой выдачи по продуктам
  let searchProductsResponse: [SearchProductModel]
  // результат поисковой выдачи по секциям каталога
  let searchSectionResponse: [CatalogSectionModel]
}

struct CatalogSectionModel: Codable {
    let sectionId: String
    let sectionName: String
    let sectionIconURL: String?
}

struct SearchProductModel: Codable {
    let productId: String
    let productName: String
    let productIconURL: String?
}

Теперь создадим конфигураторы для каждого типа ячеек:

// конфигуратор для ячеек с поисковой выдачей по найденным продуктам
final class SearchProductConfigurator: Configurator {
  
  // reuse id для таблицы который соответствует ячейке
	var reuseId: String { String(describing: SearchProductCell.self) }
  // тип ячейки для обработки события
	var cellType: CatalogCellType { .productCell }
  // модель данных для отображения в ячейке
  var model: SearchProductModel?
  
  // метод конфигурирования ячейки
  func setupCell(_ cell: UIView) {
    guard let cell = cell as? SearchProductCellProtocol, 
    			let productModel = model else { return }
    // предположим, чтобы не вдаваться в детали, что в ячейке
    // имеется метод, который уже отоборажает все данные на ней.
    cell.displayData(productModel: productModel)
  }

}

// конфигуратор для ячеек с поисковой выдачей по категориям продуктов
final class SearchSectionConfigurator: Configurator {
  
  // reuse id для таблицы который соответствует ячейке
	var reuseId: String { String(describing: SearchSectionCell.self) }
  // тип ячейки для обработки события
	var cellType: CatalogCellType { .categoryCell }
  // модель данных для отображения в ячейке
  var model: CatalogSectionModel?
  
  // метод конфигурирования ячейки
  func setupCell(_ cell: UIView) {
    guard let cell = cell as? SearchSectionCellCellProtocol, 
    			let sectionModel = model else { return }
    // предположим, чтобы не вдаваться в детали, что в ячейке
    // имеется метод, который уже отоборажает все данные на ней.
    cell.displayData(sectionModel: sectionModel)
  }

}

// конфигуратор для ячеек с предыдущей поисковой выдачей 
final class SearchPreviousRequestConfigurator: Configurator {
  
  // reuse id для таблицы который соответствует ячейке
	var reuseId: String { String(describing: SearchPreviousCell.self) }
  // тип ячейки для обработки события
	var cellType: CatalogCellType { .historyCell }
  // текст поискового запрос для отображения в ячейке
  var model: String?
  
  // метод конфигурирования ячейки
  func setupCell(_ cell: UIView) {
    guard let cell = cell as? SearchPreviousCellProtocol, 
    			let searchModel = model else { return }
    // предположим, чтобы не вдаваться в детали, что в ячейке
    // имеется метод, который уже отоборажает все данные на ней.
    cell.displayData(searchModel: searchModel)
  }

}

Теперь, когда все приготовления закончены - можно приступать непосредственно к TableManager. Надо заранее определить, какие данные мы будем получать "снаружи". Определим это в протоколе

protocol SearchTableManagerProtocol: AnyObject {
  // первоначальная передача таблицы в менеджер
  func attachTable(_ tableView: UITableView)
  // отображение предыдущих запросов
  func displayPreviousRequests(requests: [String])
  // отображение результатов поисковой выдачи
  func displaySearchResult(_ results: SearchResponseModel)
  
  // колбеки на нажатия разных типов ячеек
  var didProductTapped((SearchProductModel) -> Void)? { get set }
  var didCategoryTapped((CatalogSectionModel) -> Void)? { get set }
  var didPreviousSearchTapped((String) -> Void)? { get set }
}

В нашем случае, при использовании VIPER мы располагаем TableManager в слое Interactor, куда таблица из ViewController через Presenter и Interactor аттачится при загрузке контроллера.

Далее займемся реализацией непосредственно TableManager

final class SearchTableManager: NSObject, SearchTableManagerProtocol {
  
  // MARK: - Private properties
  
  // MARK: - Callbacks
  
  var didProductTapped((SearchProductModel?) -> Void)? 
  var didCategoryTapped((CatalogSectionModel?) -> Void)?
  var didPreviousSearchTapped((String?) -> Void)?
  
  // MARK: - Public functions
  
  func attachTable(_ tableView: UITableView) {
    
  }
  
  func displayPreviousRequests(requests: [String]) {
    
  }
  
  func displaySearchResult(_ results: SearchResponseModel) {
    
  }
  
  // MARK: - Private functions
  
}

Начнем с реализации

func attachTable(_ tableView: UITableView)

для этого нам нужно добавить следующий код:

private var table: UITableView? 

func attachTable(_ tableView: UITableView) {
	self.table = tableView
  table.dataSource = self
  table.delegate = self
  // далее можно настроить таблицу, в том числе зарегистрировать ячейки
  // но, что еще лучше, вынести настройку в отдельный метод
}

В данном методе мы получаем нашу таблицу в TableManager и производим ее первончальную конфигурацию. Так же сохраняем ссылку на таблицу для дальнейшнего к ней доступа. На данном этапе просто игнорируйте ошибки о том, что наш менеджер не может быть delegate , datasource. Сейчас мы это исправим.

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

private var configuratorsDataSource: [Configurator] = []

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

Т.е. для 1 строки таблицы необходимо будет обратиться к configuratorsDataSource[0], и так далее.

Для нескольких секций

В случае нескольких секций мы можем использовать configuratorsDataSource: [[Configurator]], где чтобы получить доступ к первомой строке второй секции необходимо будет обратиться соответственно configuratorsDataSource[1][0].

Далее давайте реализуем методы, необходимые таблице для работы.

extension SearchTableManager: UITableViewDelegate, UITableViewDataSource {
  	// количество ячеек в секции
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        configuratorsDataSource.count
    }
    // конфинурация ячеек, независит от количества и содержимого ячеек
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let configurator = configuratorsDataSource[indexPath.row]
        let cell = tableView.dequeueReusableCell(withIdentifier: configurator.reuseId, for: indexPath)
        configurator.setupCell(cell)
        return cell
    }
  
  	// обработка нажатия в зависимости от типа ячейки
  	func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    		let currentConfigurator = configuratorsDataSource[indexPath.row]
      	switch currentConfigurator.cellType {
        case .historyCell: 
          	self.didPreviousSearchTapped?(currentConfigurator.model)
        case .categoryCell: 
          	self.didCategoryTapped?(currentConfigurator.model)
         case .productCell:
         		self.didProductTapped?(currentConfigurator.model)
        }
    } 

}

Собственно, на этом наш UITableViewDataSource можно считать завершенным и меняться он не будет независимо от количества ячеек.

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

// создание конфигуратора для ячейки с продуктом 
private func createProductResponseConfigurator(with model: SearchProductModel) -> Configurator {          
	let configurator = SearchProductConfigurator()
  configurator.model = model
  return configurator 
}

// создание конфигуратора для ячейки с секцией каталога 
private func createSectionResponseConfigurator(with model: SearchProductModel) -> Configurator {          
	let configurator = SearchSectionConfigurator()
  configurator.model = model
  return configurator 
}

// создание конфигуратора для ячейки с предыдущими запросами
private func createPreviouseRequestConfigurator(_ model: String) -> Configurator {          
	let configurator = SearchPreviousRequestConfigurator()
  configurator.model = model
  return configurator 
}

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

func displayPreviousRequests(requests: [String]) {
  var output: [Configurator]= requsts.compactMap { createPreviouseRequestConfigurator($0) }
  self.configuratorsDataSource = output
  table?.reloadData()
}

func displaySearchResult(_ results: SearchResponseModel) {
	var output: [Configurator] = []
  output += results.searchProductsResponse.compactMap { createProductResponseConfigurator($0) }
  output += results.searchSectionResponse.compactMap { createSectionResponseConfigurator($0) }
	self.configuratorsDataSource = output
  table?.reloadData()
}

Ну, теперь все. Тут мы намеренно упускаем то, откуда будут браться данные, так как в нашем случае их нам передает Interactor.

Вместо вывода

Благодаря этому подходу мы имеем:

  • возможность легко масштабировать функциональность таблицы

  • инкапсулируем настройку ячеек в конфигураторы и не работаем напрямую с моделями данных

  • упрощается добавление новых ячеек в таблицу

  • улучшается читаемость кода и обработка событий из ячеек

  • работа с таблицей инкапсулируется в отдельный сервис

Благодарю за уделенное время и надеюсь, что статья будет Вам полезна! Если будут вопросы - с радостью ответим!

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


  1. ws233
    03.02.2022 18:04

    Вот еще несколько подобных решений, уже описанных на Хабре:

    1. Reactive Data Display Manager

    2. и это

    Возможно, сможете что-то почерпнуть оттуда.

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

    Ну, и объединять 2 ответственности (DataSource и DataDelegate) в 1м классе (SearchTableManager) тоже не лучшее решение, будет сложно переиспользовать код. Аналогично сделано в первой статье. Там же можно поглядеть, к каким сложностям это приводит и как ребята попробовали их решить. В вашем случае все равно придется в каждом экране делать свой менеджер. Если разнести ответственности, то их уже можно переиспользовать отдельно. Например, переиспользовать те же ячейки, но привязать на них уже другие действия.

    И последним этапом можно будет еще и развязать экшены действий пользователя по каждому типу ячейки друг от друга. Тогда каждое действие можно будет независимо переиспользовать в любом контроллере. Полученный делегат можно аналогично написать один раз и переиспользовать примерно в 90% случаев. Тут поможет Responder Chain. Статью о том, как responder chain использовать с универсальной реализацией делегата мы готовим. Скоро она появится на Хабре, как продолжение цикла статей из второй ссылки.


    1. kalimov-BSL Автор
      04.02.2022 12:10

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

      Не вижу проблемы в том, чтобы добавить delegate и datasource в один сервис ввиду того, что они не будут изменяться с добавлением ячеек или вроде того. Возможно было бы красиво, но пока считаем это избыточным.

      Ну и на практике менеджеры таблиц достаточно легко переиспользовать, что мы и делаем между экранами и проектами со схожей архитектурой =)