Представим себе экран обычного мобильного приложения с уже заполненным списком ячеек. С сервера приходит другой список. Нужно посчитать разницу между ними (что добавилось/удалилось) и проанимировать 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.
Благодарности
Спасибо Евгению Егорову за улучшения в структуре и дополнительные тесткейсы, которые позволили улучшить алгоритм.
ссылка на оригинал статьи https://habrahabr.ru/post/282509/
Добавить комментарий