[Из песочницы] Как мы придумали TableAdapter и упростили работу с UITableView
При работе с UITableView
хотелось избежать написания шаблонного кода, который еще больше усложняется, если нужно обновлять состояние таблицы анимировано. Apple представила свое решение этой проблемы на WWDC 2019, но оно работает только с iOS 13. А у нас, как у студии разработки мобильный приложений, нет такой роскоши в выборе минимальной версии iOS.
Поэтому мы реализовали наше видение data-driven подхода для работы с таблицами, попутно упростив настройку ячеек. И добавили анимированное обновление таблицы, которое основано на автоматическом подсчете различий между старыми и новыми данными за линейное время O(n). Все это мы оформили небольшую библиотеку, которую назвали TableAdapter.
О том, что у нас получилось и как мы к этому пришли, и пойдет речь в статье.
Плюсы и минусы TableAdapter
Плюсы
- Обновление таблицы с анимациями
- Автоматическое вычисление разности за линейное время
- Не нужно наследовать таблицу, ячейку или модель
- Никаких больше
dequeReusable...
- Типобезопасная настройка ячеек, хедера/футера
- Инициализация ячейки любым способом
- Простая настройка секций
- Легка в расширении
Минусы
За все приходится платить, в данном случае это ограничение на модель данных в виде реализации протокола Hashable
. Что, в общем-то достаточно просто в языке Swift, т.к. большинство базовых типов уже удовлетворяет этому протоколу. И для некоторых типов моделей естественна уникальность по какому-либо признаку.
Примеры
Использование
Использование библиотеки – простой трехшаговый процесс:
1. Подготовка моделей, ячеек и хедеров/футеров
1.1 Подготовка модели
Модель, которая будет передана в ячейку, должна удовлетворять протоколу Hashable
.
extension User: Hashable { ... }
1.2 Подготовка ячейки
Ячейка должна реализовывать протокол Configurable
, чтобы получить модель. Это дженерик протокол, так что мы можем выставить реальный тип модели.
extension Cell: Configurable {
public func setup(with item: User) {
textLabel?.text = item.name
}
}
В случае, если указанный тип модели не совпадёт с тем, который реально будет передан, то приложение не упадет. В консоль будет написано сообщение о несоответствии типов, а метод setup(with:)
не будет вызван у ячейки.
1.3 Подготовка хедера/футера
Кастомный хедер/футер также должен реализовывать протокол Configurable
, чтобы получить свою модель для настройки. Для использования дефолтного хедера/футера таблицы шаг 1.3 можно пропустить.
2. Создание секций
Секция содержит информацию о элементах (моделях) для ячеек в этой секции, хедере/футере. Она является дженерик типом Section<Item, SectionId>
, при создании которой нужно указать тип модели (Item
) и тип идентификатора секции (SectionId
). Идентификатор (id) также должен реализовывать протокол Hashable
, что нужно для автоматического подсчета различий между старыми и новыми секциями.
Дефолтный хедер/футер
Для отображения дефолтного хедера/футера секции в таблице нужно передать строку по ключу header
/footer
.
Кастомный хедер/футер
В случае отображения кастомного хедера/футера в таблице нужно передавать идентификатор headerIdentifier
/footerIdentifier
, по которому он зарегистрирован в таблице для переиспользования. А тип передаваемой модели для настройки должен соответствовать типу, указанному при реализации протокола Configurable
(Шаг 1.3).
let section = Section<User, Int>(
id: 0,
items: users,
header: "Users Section",
footer: "Users Section",
headerIdentifier: "HeaderReuseIdentifier",
footerIdentifier: "FooterReuseIdentifier"
)
На практике удобно в качестве id
секций использовать енамы.
3. Создание адаптера
Адаптер также является дженерик типом TableAdapter<Item, SectionId>
, при создании которого также нужно указать тип моделей в секциях (Item
) и тип идентификатора секций (SectionId
). Эти типы должны совпадать с типами, указанными при создании секции.
Для использования разных типов ячеек нужно передать замыкание CellReuseIdentifierProvider
, которое возвращает идентификатор ячейки. Для удобства, в это замыкание сразу передается IndexPath и соответствующая ему модель с типом, указанным ранее.
Для обработки нажатия на ячейку может быть передано замыкание СellDidSelectHandler
, в которое передается таблица, IndexPath и соответствующая ему модель.
lazy var adapter = TableAdapter<User, Int>(
tableView: tableView,
cellIdentifierProvider: { (indexPath, item) -> String? in
// Return cell reuse identifier for item at indexPath
},
cellDidSelectHandler: { [weak self] (table, indexPath, item) in
// Handle cell selection for item at indexPath
}
)
Далее адаптер обновляется с созданными ранее секциями:
adapter.update(with: [section], animated: true)
class ViewController: UIViewController {
let tableView = ...
lazy var adapter = TableAdapter<User, Int>(
tableView: tableView,
cellIdentifierProvider: { (indexPath, item) -> String? in
return "CellReuseIdentifier"
},
cellDidSelectHandler: { [weak self] (table, indexPath, item) in
// Handle cell selection for item at indexPath
}
)
let users: [User] = [...]
override func viewDidLoad() {
super.viewDidLoad()
setupTable()
let section = Section<User, Int>(
id: 0,
items: users,
header: "Users Section",
footer: "Users Section",
headerIdentifier: "HeaderReuseIdentifier",
footerIdentifier: "FooterReuseIdentifier"
)
adapter.update(with: [section]], animated: true)
}
func setupTable() {
tableView.register(
Cell.self,
forCellReuseIdentifier: "CellReuseIdentifier"
)
tableView.register(
Header.self,
forHeaderFooterViewReuseIdentifier identifier: "HeaderReuseIdentifier"
)
tableView.register(
Footer.self,
forHeaderFooterViewReuseIdentifier identifier: "FooterReuseIdentifier"
)
}
}
Протокол Hashable
не позволяет использовать гетерогенные коллекции элементов, поэтому для удобства их можно оборачивать в енам со связанным значением (associated value).
Частные случаи
Один тип ячеек
Если в таблице используется всего один тип ячеек, то в адаптер можно не передавать замыкание CellReuserIdentifierProvider
. A ячейку нужно зарегистрировать по дефолтному идентификатору ячеек, который "Cell" под капотом:
tableView.register(
Cell.self,
forCellReuseIdentifier: adapter.defaultCellIdentifier
)
Одинаковый хедер/футер для всех секций
В этом случае можно не передавать headerIdentifier
/footerIdentifier
при создании секции. А хедер/футер вью зарегистрировать по дефолтному идентификатору, который под капотом "Header"
/"Footer"
.
tableView.register(
HeaderView.self,
forHeaderFooterViewReuseIdentifier identifier: adapter.defaultHeaderIdentifier
)
tableView.register(
FooterView.self,
forHeaderFooterViewReuseIdentifier identifier: adapter.defaultFooterIdentifier
)
Sender
На случай, если нужно выставить, например, делегат, у ячеек у TableAdapter'a есть свойство sender
. Оно будет передано в ячейку, хедер/футер при реализации протокола SenderConfigurable
.
class ViewController: UIViewController {
lazy var adapter = TableAdapter<User, Int>(
tableView: tableView,
sender: self
)
}
extension Cell: SenderConfigurable {
func setup(with item: User, sender: ViewController) {
textLabel?.text = item.name
delegate = sender
}
}
Замечание: если указанный тип модели или сендера не совпадёт с реально переданными, то приложение не упадет. В консоль будет написано сообщение о несоответствии типов, а метод setup(with:sender:)
не будет вызван.
Низкоуровневая настройка
Если требуется более тонкая настройка ячеек, то адаптер можно создать с замыканием CellProvider
, которое возвращает ячейку для модели:
lazy var adapter = TableAdapter<User, Int>(
tableView: tableView,
cellProvider: { (table, indexPath, user) in
let cell = table.dequeueReusableCell(
withIdentifier: "CellReuseIdentifier",
for: indexPath
) as! Cell
cell.setup(with: user)
return cell
}
)
Для реализации других методов датасорса/делегата таблицы или расширения функционала TableAdapter
может быть наследован.
Реализация
Обновление таблицы с анимациями
Анимированное обновление таблицы основано на автоматическом вычислении различий (diff) между старыми и новыми данными в терминах удалений, вставок и перемещений элементов. Для их вычисления мы используем алгоритм Пола Хеккеля, подробно описанный в статье A technique for isolating differences between files. Он позволяет посчитать нужные различия за линейное O(n) время. Именно для этого и требовалась реализация протокола Hashable
.
Однако, существуют такие наборы валидных различий с точки зрения данных, применение которого в методе performBatchUpdates(_:completion:)
таблицы приведет к падению приложения. Для решения этой проблемы мы выполняем обновление таблицы в два этапа. Для этого вычисляем различия в три шага.
Вычисление различий
- Считаем различия между старыми и новыми (финальными) секциями только на основании их идентификаторов (id). Модели в секциях на данном шаге не учитываются.
let sectionsDiff = try calculateDiff(form: oldSections, to: newSections)
- Получаем промежуточное состояние данных путем применения различий, расчитанных на предыдущем шаге, к старым секциям. Таким образом, на данном шаге мы имеем секции, которые полностью соответствуют новым секциям по id. Однако, в секциях, которые остались на своем месте или были перемещены, будут старые элементы. А в тех, которые были добавлены, будут новые элементы.
let intermediateSections = applyDiff(sectionsDiff, to: oldSections)
- Для каждой пары соответствующих секций (с одинаковым id) из промежуточного и нового набора данных считаем различия уже между элементами. Далее собираем эти попарные различия вместе. Таким образом, на этом шаге мы получим различия для всех элементов в условиях, что секции стоят уже на финальных позициях.
let rowsDiff = try calculateRowsDiff(from: intermediateSections, to: newSections)
В итоге у нас есть вся необходимая информация для обновления таблицы:
let diff = Diff(
sections: sectionsDiff,
rows: rowsDiff,
intermediateData: intermediateSections,
resultData: newSections
)
Обновление состояния таблицы
- Обновление секций: обновляем таблицу до промежуточного состояния данных (из шага два) используя разность, посчитанную на первом шаге.
data = diff.intermediateData
tableView.insertSections(diff.sections.inserts, with: animationType)
tableView.deleteSections(diff.sections.deletes, with: animationType)
diff.sections.moves.forEach { tableView.moveSection($0.from, toSection: $0.to) }
- Обновление ячеек: обновляем таблицу до нового(финального) состояния используя разность, посчитанную на третьем шаге.
data = diff.resultData
tableView.deleteRows(at: diff.rows.deletes, with: animationType)
tableView.insertRows(at: diff.rows.inserts, with: animationType)
diff.rows.moves.forEach { tableView.moveRow(at: $0.from, to: $0.to) }
Замечание 1: к минусам такого подхода можно отнести отсутствие анимации перемещения ячейки из одной секции таблицы в другую. В данном случае ячейка «удалится» в одной секции и «вставится» в другой.
Замечание 2: на самом деле модели не обязательно должны быть уникальными, т.е. иметь уникальный хеш. Алгоритм корректно посчитает различия даже в случае присутствия дубликатов в коллекции, но в некоторых случаях дубликаты могут быть ложно помечены для удаления, а затем, соответственно, помечены для добавления. Т.о. произойдет анимация удаления, а затем добавления одной и той же ячейки. В случае обновления без анимации adapter.update(with: sections, animated: false)
разности вообще не будут вычисляться и уникальность элементов не требуется вовсе.
Настройка через Configurable
протокол
При настройке ячеек хотелось избежать повторного написания шаблонного кода, который сильно разрастается при наличии в таблице разных типов ячеек:
let user = users[indexPath.row]
let cell = tableView.dequeueReusableCell(
withIdentifier: cellId,
for: indexPath
) as! Cell
cell.setup(with: user)
Также хотелось оставить свободу разработчику в выборе базового класса ячейки и оставить возможность указать конкретный тип модели при передачи для настройки. Поэтому было принято решение использовать протоколы со связанными типами (associatedtype).
Так появился протокол Configurable
, имплементация которого классом ячейки позволит все вышеперечисленное:
protocol Configurable {
associatedtype ItemType: Any
func setup(with item: ItemType)
}
Однако, после получения объекта ячейки методом dequeueReusableCell(withIdentifier:for:)
мы не сможем привести ее к типу Configurable
из-за наличия в нем связанного типа ItemType
. Поэтому мы создали обычный протокол AnyConfigurable
:
protocol AnyConfigurable {
func anySetup(with item: Any)
}
к которому и будем приводит объект ячейки, с последующей передачей модели для настройки:
let cell = tableView.dequeueReusableCell(withIdentifier: cellId, for: indexPath)
if let cell = cell as? AnyConfigurable {
cell.anySetup(with: item)
}
А протокол Configurable
наследуем от AnyConfigurable
и имплементирует его следующим образом:
extension Configurable {
func anySetup(with item: Any) {
if let item = item as? ItemType {
setup(with: item)
}
}
}
Таким образом, мы как-бы «скрыли» протокол со связанными типом обычным протоколом внутри библиотеки, одновременно оставив разработчику удобный интерфейс. Все приведения типов выполняются безопасным образом, поэтому по этому поводу падений не будет.
Стоит отметить, что подобный подход также используется в стандартной библиотеке Swift при реализации type-erased wrappers. Например AnyHashable, AnyIndex.
Замечание: реальная реализация в библиотеке немного отличается от приведенной выше. А именно: добавлены выведения предупреждений в консоль в случае несовпадения ожидаемого типа модели и реально переданного.
Ссылка на GitHub
В итоге мы получили решение, которое позволяет удобно настраивать ячейки, писать меньше шаблонного кода и анимации в придачу. В то же время осталась возможность низкоуровневой настройки таблицы и расширения функционала при необходимости.
Реализация на GitHub