Представим себе экран обычного мобильного приложения с уже заполненным списком ячеек. С сервера приходит другой список. Нужно посчитать разницу между ними (что добавилось/удалилось) и проанимировать UICollectionView
.
«Простой» подход — полностью заменить модель с последующим вызовом reloadData
. К сожалению, при этом теряются анимации и могут возникать другие нежелательные эффекты и тормоза. Куда интереснее редактировать списки аккуратно, анимированно. Попробовав это сделать несколько раз, я убедился, что это неимоверно трудно.
Раз проблема встретилась в нескольких проектах, нужно её обобщить и работать дальше с обобщённой реализацией. Интересная задача! Несколько дней борьбы с документацией, здравым смыслом, багами реализации таблиц в iOS, и получился код с достаточно простым интерфейсом, адаптирующийся к широкому кругу задач, про который я хочу рассказать.
Конечно же, фреймворк в первую очередь решает мои собственные задачи. Если вам вдруг нужна фича, которая сейчас там отсутствует, пишите, спрашивайте, попробую доработать.
Чуть более формальное описание задачи
Представим, что у нас есть таблица, которая состоит из секций с ячейками.
Таблицы или списки — это
UICollectionView
илиUITableView
, я их не буду различать в статье. Судя по одинаковым багам, внутри там один и тот же код, да и интерфейс похож.
Анимировать таблицу нужно уметь в двух случаях:
- поменялась сортировка таблицы (например, сортировали по именам, теперь сортируем по фамилиям)
- изменился какой-то кусок данных. Некоторые ячейки могут добавиться, некоторые измениться, некоторые удалиться.
Если изменилось что-то понятное (например, добавилась одна ячейка), то всё просто. Но что делать, если у нас чат, в котором сообщения могут редактироваться и удаляться пачками? Или список пользователей, который показывается из кэша, а потом получается с сервера и полностью обновляется?
Для примера попробуйте представить адресную книгу, где была сортировка от А до Я, а потом она поменялась обратную. Последние секции должны переместиться наверх, и внутри секций ячейки должны пересортироваться. Какие индексы будут у перемещений? В какой последовательности система будет применять анимации? Все эти вопросы очень поверхностно описаны в документации, и приходится разбираться методом «тыка».
ATableAnimationCalculator
представляет собой модель данных для таблицы, которая следит за текущим состоянием ячеек и, если ей сказать «вот тут новое что-то, посчитай разницу» — считает, выдавая список индексов ячеек и секций, требующих изменения (удаления, вставки, перемещения). После этого результат вычисления можно применить к таблице, обходя проблемы в реализации анимаций iOS.
Структура данных фреймворка
В названиях первая буква «A» — это не префикс фреймворка, как можно подумать, а сокращение слова «Awesome». ;-)
Фреймворк состоит из:
- Модели:
- Протокола
ACellModel
, который нужно реализовать в модели ячейки. - Класса
ASectionModel
(иASectionModelObjC
для поддержки Objctive-C), от которого необходимо отнаследовать модель секции. Класс, а не протокол, чтобы не повторять код, посвященный внутреннему устройству секций. - Протокола
ACellSectionModel
, реализация которого знает, как связать ячейки и секции.
- Протокола
- Основного алгоритма
ATableAnimationCalculator
. - Результата работы алгоритма, структуры
ATableDiff
(с расширениями для UIKit'а, которые живут в отдельном файле).
Класс секции совсем простой. Он нужен для хранения индексов начала/конца, но, поскольку это подробности реализации, наружу торчит только инициализатор и индексы, которые могут быть полезны в целях отладки. Класс ASectionModelObjC
ровно такой же, его нужно использовать, когда требуется поддержка Objective-C.
public class ASectionModel: ASectionModelProtocol {
public internal (set) var startIndex:Int
public internal (set) var endIndex:Int
public init()
}
Протокол ячейки не сложнее. Необходимо равенство ячеек, нужно проверять их содержимое на идентичность и уметь их копировать (зачем — в разделе про грабли).
public protocol ACellModel: Equatable {
// Копирующий конструктор
init(copy:Self)
// Сравнивает содержимое ячеек, чтобы найти те, которые нужно обновить
func contentIsSameAsIn(another:Self) -> Bool
}
Также есть протокол, связывающий ячейки и секции вместе. Он помогает понять, находятся ли две ячейки в одной секции и создать секцию по произвольной ячейке. Обратите внимание, что привязанный тип секции должен и наследоваться от класса ASectionModel
, и реализовывать протокол Equatable
.
public protocol ACellSectionModel {
associatedtype ACellModelType: ACellModel
associatedtype ASectionModelType: ASectionModelProtocol, Equatable
// Позволяет, не создавая секцию, проверять,
// в одной ли секции находятся ячейки
func cellsHaveSameSection(one one:ACellModelType, another:ACellModelType) -> Bool
// Создаёт секцию для ячейки
func createSection(forCell cell:ACellModelType) -> ASectionModelType
}
В классе ATableAnimationCalculator
есть компаратор, который используется для сортировки ячеек, несколько методов для использования в .dataSource
таблицы и методы для запуска вычисления изменений. Также для отладки может быть полезно поглядеть на списки ячеек и секций.
public class ATableAnimationCalculator<ACellSectionModelType:ACellSectionModel>: NSObject {
// Показываю тут тайпалиасы, чтобы было понятнее, что написано дальше
private typealias ACellModelType = ACellSectionModelType.ACellModelType
private typealias ASectionModelType = ACellSectionModelType.ASectionModelType
// Эти поля могут быть полезны для отладки
public private(set) var items:[ACellModelType]
public private(set) var sections:[ASectionModelType]
// Компаратор можно поменять. После смены нужно
// вызвать resortItems и проанимировать изменение при необходимости
public var cellModelComparator:(ACellModelType, ACellModelType)
public init(cellSectionModel:ACellSectionModelType)
}
public extension ATableAnimationCalculator {
// Эти методы напрямую могут (и должны) использоваться
// в соответствующих методах .dataSource и .delegate
func sectionsCount() -> Int
func itemsCount(inSection sectionIndex:Int) -> Int
func section(withIndex sectionIndex:Int) -> ACellModelType.ASectionModelType
func item(forIndexPath indexPath:NSIndexPath) -> ACellModelType
func item(withIndex index:Int) -> ACellModelType
}
public extension ATableAnimationCalculator {
// Этот метод просто возвращает diff, если изменения
// не затронули напрямую объекты (как, например, при смене сортировки)
func resortItems() throws -> DataSourceDiff
// Если набор данных поменялся целиком, можно его обработать этим методом.
// Получится своеобразный аналог reloadData, только анимированный.
func setItems(newItems:[ACellModelType]) throws -> DataSourceDiff
// Если поменялась часть данных, то проще всего воспользоваться этим методом.
func updateItems(addOrUpdate addedOrUpdatedItems:[ACellModelType],
delete:[ACellModelType]) throws -> DataSourceDiff
}
Калькулятор специально сделан максимально независимым, чтобы можно было его использовать где угодно. Для UICollectionView
и UITableView
написаны соответствующие расширения, которые позволяют анимированно применить к ним результаты вычислений:
public extension ATableDiff {
func applyTo(collectionView collectionView:UICollectionView)
func applyTo(tableView tableView:UITableView)
}
Пример использования фреймворка
Поглядим на простую реализации секции, в которой есть только заголовок.
public class ASectionModelExample: ASectionModel, Equatable {
public let title:String
public init(title:String) {
self.title = title
super.init()
}
}
public func ==(lhs:ASectionModelExample, rhs:ASectionModelExample) -> Bool {
return lhs.title == rhs.title
}
В ячейке три поля:
- ID, который обеспечивает равенство ячеек. Именно по этому полю мы понимаем, что ячейка та же, только содержимое поменялось.
- Header. Обычно в ячейке есть поле (имя, фамилия или дата создания объекта), по которому создаётся секция. Тут таким полем является «заголовок».
- Text, текст, который выводится в ячейке и по которому мы производим сравнение содержимого.
class ACellModelExample: ACellModel {
var id:String
var header:String
var text:String
init(text:String, header:String) {
id = NSUUID().UUIDString // просто чтобы не париться с айдишками
self.text = text
self.header = header
}
required init(copy:ACellModelExample) {
id = copy.id
text = copy.text
header = copy.header
}
func contentIsSameAsIn(another:ACellModelExample) -> Bool {
return text == another.text
}
}
func ==(lhs:ACellModelExample, rhs:ACellModelExample) -> Bool {
return lhs.id == rhs.id
}
И, наконец, класс, который знает, как связать воедино ячейки и секции.
class ACellSectionModelExample: ACellSectionModel {
func cellsHaveSameSection(one one:ACellModelExample, another:ACellModelExample) -> Bool {
return one.header == another.header
}
func createSection(forCell cell:ACellModelExample) -> ASectionModelExample {
return ASectionModelExample(title:cell.header)
}
}
Теперь поглядим, как это всё прикрутить к UITableView
. Сначала подключим калькулятор к методам .dataSource'а
таблицы. Это сделать легко, так как калькулятор берёт на себя все запросы по количеству и получению элементов по индексам.
Код намеренно сделан минимальным по размеру, реальный код должен быть более аккуратным и, пожалуйста, без восклицательных знаков. :-)
// Дженерик выводится из параметра конструктора
private let calculator = ATableAnimationCalculator(cellSectionModel:ACellSectionModelExample())
func numberOfSectionsInTableView(tableView:UITableView) -> Int {
return calculator.sectionsCount()
}
func tableView(tableView:UITableView, numberOfRowsInSection section:Int) -> Int {
return calculator.itemsCount(inSection:section)
}
func tableView(tableView:UITableView,
cellForRowAtIndexPath indexPath:NSIndexPath) -> UITableViewCell {
var cell = tableView.dequeueReusableCellWithIdentifier("generalCell")
cell!.textLabel!.text = calculator.item(forIndexPath:indexPath).text
return cell!
}
func tableView(tableView:UITableView, titleForHeaderInSection section:Int) -> String? {
return calculator.section(withIndex:section).title
}
Первое обновление данных обычно не нужно анимировать, поэтому просто установим список и вызовем, как обычно, reloadData
. Калькулятор отсортирует (если проставлен компаратор) ячейки и разобьёт по секциям.
try! calculator.setItems([
ACellModelExample(text:"5", header:"C"),
ACellModelExample(text:"1", header:"A"),
ACellModelExample(text:"3", header:"B"),
ACellModelExample(text:"2", header:"B"),
ACellModelExample(text:"4", header:"C")
])
tableView.reloadData()
Обновление может сломаться в случае, если не проставлен компаратор, и ячейки заранее сами не отсортированы. Ведь тогда может получиться, что одна и та же секция при этом окажется раскидана по разным частям списка, что плохо поддаётся анализу. :-)
Теперь, к примеру, добавим пару ячеек в разные секции и применим просчитанные анимации.
let addedItems = [
ACellModelExample(text:"2.5", header:"B"),
ACellModelExample(text:"4.5", header:"C"),
]
let itemsToAnimate = try! calculator.updateItems(addOrUpdate:addedItems, delete:[])
itemsToAnimate.applyTo(tableView:tableView)
Также можно поменять компаратор, после чего анимированно пересортировать ячейки.
calculator.cellModelComparator = { left, right in
return left.header < right.header
? true
: left.header > right.header
? false
: left.text < right.text
}
let itemsToAnimate = try! self.calculator.resortItems()
itemsToAnimate.applyTo(tableView:self.tableView)
Собственно, всё.
Подводные грабли при использовании
Помните копирующий конструктор в модели ячейки? В нём нужно копировать ячейку целиком, и ID (из примера), и данные ячейки (заголовок, текст в примере). В противном случае может получиться, что при изменении данных в модели они поменяются и внутри данных алгоритма. После этого алгоритм не сможет определить, что ячейки обновились. Появятся неявные баги с необновлением ячеек, в которых тяжело разобраться.
Другое поле граблей скрывает алгоритм — сложное обновление таблиц и баги текущей реализации iOS. К примеру, сейчас в случае одновременного перемещения секций и ячеек внутри этих секций не отрабатывает обновление ячеек, приходится форсированно их просить порелоадиться. Нужно об этом помнить, если вы решите не использовать уже написанные методы, а реализовывать их самостоятельно.
В процессе тестирования я выяснил, что метод performBatchUpdates
работает, скажем так, странно. В симуляторе он может выдать, например, EXC_I386_DIV
(исключение деления на ноль). Иногда случается, что срабатывают ассерты (про которые неизвестно ничего, только номер строки в глубинах UIKit'а). Если вдруг у вас будут кейсы, когда все ломается, и они стабильно повторяются — пишите, я попробую встроить код, который их учтёт.
Использование в Objective-C
Можно попробовать использовать калькулятор в коде для Objective-C. Это не слишком удобно, и я не ставил перед собой цель поддерживать Objective-C, но возможно. Делается это так:
- Нужно реализовать все протоколы на Swift'е. При этом ячейка будет определена, например, так:
@objc class ACellModelExampleObjC: NSObject, ACellModel
,
секция так:
@objc public class ASectionModelExampleObjC: ASectionModelObjC
(тут важен базовый класс).
Модель для ячейки-секции не требует поддержки ObjC:
class ACellSectionModelExample ObjC: ACellSectionModel
- Создаем класс, который будет скрывать от Objective-C все внутренности и сложности вроде дженериков.
@objc
class ATableAnimationCalculatorObjC: NSObject {
private let calculator =
ATableAnimationCalculator(cellSectionModel:ACellSectionModelExampleObjC())
func getCalculator() -> AnyObject? {
return calculator
}
func setItems(items:[ACellModelExampleObjC], andApplyToTableView tableView:UITableView) {
try! calculator.setItems(items).applyTo(tableView:tableView)
}
}
После чего можно его использовать в Objective-C.
#import "ATableAnimationCalculator-Swift.h"
ATableAnimationCalculatorObjC *calculator = [[ATableAnimationCalculatorObjC alloc] init];
[calculator setItems:@[
[[ACellModelExampleObjC alloc] initWithText:@"1" header:@"A"],
[[ACellModelExampleObjC alloc] initWithText:@"2" header:@"B"],
[[ACellModelExampleObjC alloc] initWithText:@"3" header:@"B"],
[[ACellModelExampleObjC alloc] initWithText:@"4" header:@"C"],
[[ACellModelExampleObjC alloc] initWithText:@"5" header:@"C"],
]
andApplyToTableView:myTableView];
Как видно, в Swift потребуется вынести всю работу со структурой ATableDiff
, а сам калькулятор будет выдаваться в Objective-C, как id
(AnyObject?
).
Заключение, замечания, исходники
Код испытан на куче искусственных/случайных тестов. Насколько я вижу, он работает достаточно хорошо. Если вы видите какие-то недочёты или неучтённые случаи, пишите.
Использование дженериков и привязанных типов (associated types) ломает (судя по ответам на StackOverflow) совместимость с iOS 7, поэтому поддерживаются только iOS 8 и 9.
Исходники живут на GitHub, проект называется ATableAnimationCalculator
. Для интеграции можно включить исходниками к себе (там всего несколько файлов). Если нужен только алгоритм, можно подключить всё кроме расширений для UIKit'а.
Есть под в CocoaPods:
pod 'AwesomeTableAnimationCalculator'
Поддерживается Carthage:
github "bealex/AwesomeTableAnimationCalculator"
Если будут какие-то вопросы, задавайте либо тут, либо сразу в почту alex@jdnevnik.com.
Благодарности
Спасибо Евгению Егорову за улучшения в структуре и дополнительные тесткейсы, которые позволили улучшить алгоритм.
Автор: REDMADROBOT