Статические Generic таблицы

image


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

О том, как я решаю данную проблему — под катом.

О чем речь?


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

image

Проблема


Для начала стоит определить проблему: почему мы не можем просто создать ViewController, который будет являться UITableViewDelegate и UITableViewDatasource и просто описать все нужные ячейки? Как минимум — тут возникают 5 проблем с нашей таблицей:

  1. Трудно масштабируемая
  2. Зависит от индексов
  3. Не гибкая
  4. Отсутвие переиспользования
  5. Требует много кода для инициализации


Решение


Метод решения проблемы основан на следующем фундаменте:

  1. Вынос отвественности конфигурации таблицы в отдельный класс (Constructor)
  2. Своя обертка над UITableViewDelegate и UITableViewDataSource
  3. Подключение ячеек к кастомным протоколам для переиспользования
  4. Создание своих моделей данных для каждой таблицы


Сначала я хочу показать, как это используется на практике — затем покажу как это все реализовано под капотом.

Реализация


Задача — создать таблицу с двумя текстовыми ячейками и между ними одна пустая.

Первым делом я создал обычный 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 ссылка не разорвалась внутри какой-то функции.

Можем запускать и тестировать:

image

Как видите, все работает.

Теперь давайте подведем итоги и поймем чего мы добились:

  1. Если мы создадим новую ячейку и захотим подменить текущую на нее, то это мы делаем путем изменения одной переменной. У нас очень гибкая система таблицы
  2. Мы переиспользуем все ячейки. Чем больше ячеек вы подвязываете на эту таблицу, тем легче и проще с этим работать. Отлично подходит для больших проектов.
  3. Мы снизили количество кода для создания таблицы. И нам придётся писать его еще меньше, когда у нас будет много протоколов и статических ячеек в проекте.
  4. Мы вынесли построение статических таблиц из UIViewController в Constructor
  5. Мы перестали зависеть от индексов, мы можем спокойно менять местами ячейки в массиве и логика при этом не поломается.


Код на тестовый проект в конце статьи.

Как это работает изнутри?


Как работают протоколы мы уже обсудили. Теперь надо понять как работает весь конструктор и его сопуствующие классы.

Начнем с самого конструктора:

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.

В нем мы реализуем не самую красивую логику. Мы вынуждены каждый новый протокол к ячейкам прописывать здесь, но мы это делаем один раз — поэтому это не так уж и страшно. Если модель соотвествует протоколу, как и наша ячейка, то сконфигурируем ее. Если у кого-то есть идеи, как это автоматизировать — буду рад выслушать в комментариях.

На этом логика заканчивается. Я не затрагивал сторонние утилитарные классы в этой системе, полностью с кодом вы можете ознакомиться по ссылке.

Спасибо за внимание!

© Habrahabr.ru