Біль і анімація таблиць для iOS. Фреймворк Awesome Table Animation Calculator


Уявімо собі екран звичайного мобільного додатка з уже заповненою списком осередків. З сервера приходить інший список. Потрібно порахувати різницю між ними (що додалося/віддалилося) і проанимировать
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!.текст мітки!.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:"С"),
ACellModelExample(text:"1", header:"A"),
ACellModelExample(text:"3", header:"B"),
ACellModelExample(text:"2", header:"B"),
ACellModelExample(text:"4", header:"С")
])

tableView.reloadData()

Оновлення може зламатися у разі, якщо не проставлений компаратор, і клітинки заздалегідь самі не відсортовані. Адже тоді може статися, що одна і та ж секція при цьому виявиться розкидана по різних частинах списку, що погано піддається аналізу. :-)
Тепер, наприклад, додамо кілька осередків в різні секції і застосуємо прораховані анімації.
let addedItems = [
ACellModelExample(text:"2.5", header:"B"),
ACellModelExample(text:"4.5", header:"С"),
]

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.
Подяки
Спасибі Євгенія Єгорова за поліпшення в структурі і додаткові тесткейсы, які дозволили поліпшити алгоритм.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.