Swift TableManager

от автора

В этой статье хотел бы описать то, как устроена работа с 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.

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

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

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

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

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

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

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

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


ссылка на оригинал статьи https://habr.com/ru/post/649253/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *