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