В этой статье хотел бы описать то, как устроена работа с 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.
Вместо вывода
Благодаря этому подходу мы имеем:
возможность легко масштабировать функциональность таблицы
инкапсулируем настройку ячеек в конфигураторы и не работаем напрямую с моделями данных
упрощается добавление новых ячеек в таблицу
улучшается читаемость кода и обработка событий из ячеек
работа с таблицей инкапсулируется в отдельный сервис
Благодарю за уделенное время и надеюсь, что статья будет Вам полезна! Если будут вопросы - с радостью ответим!
ws233
Вот еще несколько подобных решений, уже описанных на Хабре:
Reactive Data Display Manager
и это
Возможно, сможете что-то почерпнуть оттуда.
В качестве быстрого совета. Обратите внимание, что конфигураторы ячеек у вас имеют абсолютно идентичный код. Их можно привести к дженерику, написать один раз и переиспользовать. По второй ссылке это как раз и реализовано. Только называется оно там фабрикой, если я правильно помню терминологию.
Ну, и объединять 2 ответственности (DataSource и DataDelegate) в 1м классе (SearchTableManager) тоже не лучшее решение, будет сложно переиспользовать код. Аналогично сделано в первой статье. Там же можно поглядеть, к каким сложностям это приводит и как ребята попробовали их решить. В вашем случае все равно придется в каждом экране делать свой менеджер. Если разнести ответственности, то их уже можно переиспользовать отдельно. Например, переиспользовать те же ячейки, но привязать на них уже другие действия.
И последним этапом можно будет еще и развязать экшены действий пользователя по каждому типу ячейки друг от друга. Тогда каждое действие можно будет независимо переиспользовать в любом контроллере. Полученный делегат можно аналогично написать один раз и переиспользовать примерно в 90% случаев. Тут поможет Responder Chain. Статью о том, как responder chain использовать с универсальной реализацией делегата мы готовим. Скоро она появится на Хабре, как продолжение цикла статей из второй ссылки.
kalimov-BSL Автор
В данном случае - разумеется, можно использовать дженерик, но это самый простой случай, в более сложных кейсах там так не получится ввиду разной направленнсти и функциональности ячеек.
Не вижу проблемы в том, чтобы добавить delegate и datasource в один сервис ввиду того, что они не будут изменяться с добавлением ячеек или вроде того. Возможно было бы красиво, но пока считаем это избыточным.
Ну и на практике менеджеры таблиц достаточно легко переиспользовать, что мы и делаем между экранами и проектами со схожей архитектурой =)