
Всем нам часто приходится сталкиваться со статическими таблицами, они могут являться настройками нашего приложения, экранами авторизации, экранами «о нас» и многими другими. Но часто начинающие разработчики не применяют никакие паттерны разработки подобных таблиц и пишут все в одном классе немасштабируемую, негибкую систему.
О том, как я решаю данную проблему — под катом.
О чем речь?
Прежде чем решать проблему статических таблиц — стоит понять что это такое. Статические таблицы — это таблицы, где у вас уже известны количество строк и контент, который в них находится. Примеры подобных таблиц ниже.

Проблема
Для начала стоит определить проблему: почему мы не можем просто создать 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<Self> { return StaticDataSourceDelegate<Self>.init(container: self) } func setup<T: StaticConstructorContainer>(at tableView: UITableView, dataSource: StaticDataSourceDelegate<T>) { 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<Container: StaticConstructorContainer>: 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.
В нем мы реализуем не самую красивую логику. Мы вынуждены каждый новый протокол к ячейкам прописывать здесь, но мы это делаем один раз — поэтому это не так уж и страшно. Если модель соотвествует протоколу, как и наша ячейка, то сконфигурируем ее. Если у кого-то есть идеи, как это автоматизировать — буду рад выслушать в комментариях.
На этом логика заканчивается. Я не затрагивал сторонние утилитарные классы в этой системе, полностью с кодом вы можете ознакомиться по ссылке.
Спасибо за внимание!
