Статические Generic таблицы
Всем нам часто приходится сталкиваться со статическими таблицами, они могут являться настройками нашего приложения, экранами авторизации, экранами «о нас» и многими другими. Но часто начинающие разработчики не применяют никакие паттерны разработки подобных таблиц и пишут все в одном классе немасштабируемую, негибкую систему.
О том, как я решаю данную проблему — под катом.
О чем речь?
Прежде чем решать проблему статических таблиц — стоит понять что это такое. Статические таблицы — это таблицы, где у вас уже известны количество строк и контент, который в них находится. Примеры подобных таблиц ниже.
Проблема
Для начала стоит определить проблему: почему мы не можем просто создать ViewController, который будет являться UITableViewDelegate и UITableViewDatasource и просто описать все нужные ячейки? Как минимум — тут возникают 5 проблем с нашей таблицей:
- Трудно масштабируемая
- Зависит от индексов
- Не гибкая
- Отсутвие переиспользования
- Требует много кода для инициализации
Решение
Метод решения проблемы основан на следующем фундаменте:
- Вынос отвественности конфигурации таблицы в отдельный класс (Constructor)
- Своя обертка над UITableViewDelegate и UITableViewDataSource
- Подключение ячеек к кастомным протоколам для переиспользования
- Создание своих моделей данных для каждой таблицы
Сначала я хочу показать, как это используется на практике — затем покажу как это все реализовано под капотом.
Реализация
Задача — создать таблицу с двумя текстовыми ячейками и между ними одна пустая.
Первым делом я создал обычный TextTableViewCell с UILabel.
Далее, к каждому UIViewController со статической таблицей нужен свой Constructor, давайте его создадим:
class ViewControllerConstructor: StaticConstructorContainer {
typealias ModelType = <#type#>
}
Когда мы наследовали его от StaticConstructorContainer, первым делом Generic протокол требует от нас тип модели (ModelType) — это тип модели ячейки, который мы тоже должны создать, давайте сделаем это.
Я для этого использую enum, так как это больше подходит для наших задач и тут начинается самое интересное. Мы будем наполнять контентом нашу таблицу с помощью протоколов, таких как: Titled, Subtitled, Colored, Fonted и так далее. Как вы можете догадаться — эти протоколы отвечают за отображение текста. Допустим, протокол Titled требует title: String? , и если наша ячейка поддерживает отображения title, он ее заполнит. Давайте посмотрим как это выглядит:
protocol Fonted {
var font: UIFont? { get }
}
protocol FontedConfigurable {
func configure(by model: Fonted)
}
protocol Titled {
var title: String? { get }
}
protocol TitledConfigurable {
func configure(by model: Titled)
}
protocol Subtitled {
var subtitle: String? { get }
}
protocol SubtitledConfigurable {
func configure(by model: Subtitled)
}
protocol Imaged {
var image: UIImage? { get }
}
protocol ImagedConfigurable {
func configure(by model: Imaged)
}
Соотвественно, здесь представлено только малая часть подобных протоколов, вы можете создавать и сами, как видите — это очень просто. Напоминаю, что мы создаем их 1 раз для 1 цели и потом забываем их и спокойно используем.
Наша ячейка (с текстом) поддерживает по сути следующие вещи: Шрифт текста, сам текст, цвет текста, цвет background’a ячейки и вообще любые вещи, приходящие вам на ум.
Нам понадобится пока что только title. Поэтому мы наследуем нашу модель от Titled. Внутри модели в case мы указываем какие типы ячеек у нас будут.
enum CellModel: Titled {
case firstText
case emptyMiddle
case secondText
var title: String? {
switch self {
case .firstText: return "Я - первый"
case .secondText: return "Я - второй"
default: return nil
}
}
}
Так как в средней (пустой ячейке) никакого label нет, то можно вернуть nil.
C ячейкой закончили и можно ее вставить в наш конструктор.
class ViewControllerConstructor: StaticConstructorContainer {
typealias ModelType = CellModel
var models: [CellModel] // Здесь мы должны выставить порядок и количество ячеек, отображаемых в коде
func cellType(for model: CellModel) -> Self.StaticTableViewCellClass.Type {
// здесь мы должны вернуть тип ячейки, которая принадлежит модели
}
func configure(cell: UITableViewCell, by model: CellModel) {
// Здесь мы можем конфигурировать ячейку вручную, если это необходимо, но можно оставить это пустым
}
func itemSelected(item: CellModel) {
// аналог didSelect, не завязанный на индексах
}
}
И по сути, это весь наш код. Можно сказать, что наша таблица готова. Давайте заполним данные и посмотрим что произойдет.
Ах да, чуть не забыл. Нужно наследовать нашу ячейку от протокола TitledConfigurable, чтобы она могла вставить в себя title. Ячейки поддерживают и динамичную высоту тоже.
extension TextTableViewCell: TitledConfigurable {
func configure(by model: Titled) {
label.text = model.title
}
}
Как выглядит заполненный конструктор:
class ViewControllerConstructor: StaticConstructorContainer {
typealias ModelType = CellModel
var models: [CellModel] = [.firstText, .emptyMiddle, .secondText]
func cellType(for model: CellModel) -> StaticTableViewCellClass.Type {
switch model {
case .emptyMiddle: return EmptyTableViewCell.self
case .firstText, .secondText: return TextTableViewCell.self
}
}
func configure(cell: UITableViewCell, by model: CellModel) {
cell.selectionStyle = .none
}
func itemSelected(item: CellModel) {
switch item {
case .emptyMiddle: print("Нажата средняя ячейка")
default: print("Нажата другая ячейка...")
}
}
}
Выглядит довольно компактным, не так ли?
Собственно, последнее что нам осталось сделать, это подключить это все во ViewController’e:
class ViewController: UIViewController {
private let tableView: UITableView = {
let tableView = UITableView()
return tableView
}()
private let constructor = ViewControllerConstructor()
private lazy var delegateDataSource = constructor.delegateDataSource()
override func viewDidLoad() {
super.viewDidLoad()
constructor.setup(at: tableView, dataSource: delegateDataSource)
}
}
Все готово, мы должны вынести delegateDataSource как отдельный проперти в наш класс, чтобы weak ссылка не разорвалась внутри какой-то функции.
Можем запускать и тестировать:
Как видите, все работает.
Теперь давайте подведем итоги и поймем чего мы добились:
- Если мы создадим новую ячейку и захотим подменить текущую на нее, то это мы делаем путем изменения одной переменной. У нас очень гибкая система таблицы
- Мы переиспользуем все ячейки. Чем больше ячеек вы подвязываете на эту таблицу, тем легче и проще с этим работать. Отлично подходит для больших проектов.
- Мы снизили количество кода для создания таблицы. И нам придётся писать его еще меньше, когда у нас будет много протоколов и статических ячеек в проекте.
- Мы вынесли построение статических таблиц из UIViewController в Constructor
- Мы перестали зависеть от индексов, мы можем спокойно менять местами ячейки в массиве и логика при этом не поломается.
Код на тестовый проект в конце статьи.
Как это работает изнутри?
Как работают протоколы мы уже обсудили. Теперь надо понять как работает весь конструктор и его сопуствующие классы.
Начнем с самого конструктора:
protocol StaticConstructorContainer {
associatedtype ModelType
var models: [ModelType] { get }
func cellType(for model: ModelType) -> StaticTableViewCellClass.Type
func configure(cell: UITableViewCell, by model: ModelType)
func itemSelected(item: ModelType)
}
Это обычный протокол, который требует уже знакомые нам функции.
Более интересен его extension:
extension StaticConstructorContainer {
typealias StaticTableViewCellClass = StaticCell & NibLoadable
func delegateDataSource() -> StaticDataSourceDelegate {
return StaticDataSourceDelegate.init(container: self)
}
func setup(at tableView: UITableView, dataSource: StaticDataSourceDelegate) {
models.forEach { (model) in
let type = cellType(for: model)
tableView.register(type.nib, forCellReuseIdentifier: type.name)
}
tableView.delegate = dataSource
tableView.dataSource = dataSource
dataSource.tableView = tableView
}
}
Функция setup, которую мы вызывали в нашем ViewController регистрирует все ячейки для нас и делегирует dataSource и delegate.
А delegateDataSource () создает для нас обертку UITableViewDataSource и UITableViewDelegate. Давайте рассмотрим его:
class StaticDataSourceDelegate: NSObject, UITableViewDelegate, UITableViewDataSource {
private let container: Container
weak var tableView: UITableView?
init(container: Container) {
self.container = container
}
func reload() {
tableView?.reloadData()
}
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
let type = container.cellType(for: container.models[indexPath.row])
return type.estimatedHeight ?? type.height
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
let type = container.cellType(for: container.models[indexPath.row])
return type.height
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return container.models.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let model = container.models[indexPath.row]
let type = container.cellType(for: model)
let cell = tableView.dequeueReusableCell(withIdentifier: type.name, for: indexPath)
if let typedCell = cell as? TitledConfigurable, let titled = model as? Titled {
typedCell.configure(by: titled)
}
if let typedCell = cell as? SubtitledConfigurable, let subtitle = model as? Subtitled {
typedCell.configure(by: subtitle)
}
if let typedCell = cell as? ImagedConfigurable, let imaged = model as? Imaged {
typedCell.configure(by: imaged)
}
container.configure(cell: cell, by: model)
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let model = container.models[indexPath.row]
container.itemSelected(item: model)
}
}
Думаю, к функциям heightForRowAt, numberOfRowsInSection, didSelectRowAt вопросов нет, они всего лишь реализуют понятный функционал. Самый интересный здесь метод — cellForRowAt.
В нем мы реализуем не самую красивую логику. Мы вынуждены каждый новый протокол к ячейкам прописывать здесь, но мы это делаем один раз — поэтому это не так уж и страшно. Если модель соотвествует протоколу, как и наша ячейка, то сконфигурируем ее. Если у кого-то есть идеи, как это автоматизировать — буду рад выслушать в комментариях.
На этом логика заканчивается. Я не затрагивал сторонние утилитарные классы в этой системе, полностью с кодом вы можете ознакомиться по ссылке.
Спасибо за внимание!