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

от автора

Представим себе экран обычного мобильного приложения с уже заполненным списком ячеек. С сервера приходит другой список. Нужно посчитать разницу между ними (что добавилось/удалилось) и проанимировать UICollectionView.

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

Раз проблема встретилась в нескольких проектах, нужно её обобщить и работать дальше с обобщённой реализацией. Интересная задача! Несколько дней борьбы с документацией, здравым смыслом, багами реализации таблиц в iOS, и получился код с достаточно простым интерфейсом, адаптирующийся к широкому кругу задач, про который я хочу рассказать.

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

Чуть более формальное описание задачи

Представим, что у нас есть таблица, которая состоит из секций с ячейками.

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

Анимировать таблицу нужно уметь в двух случаях:

  • поменялась сортировка таблицы (например, сортировали по именам, теперь сортируем по фамилиям)
  • изменился какой-то кусок данных. Некоторые ячейки могут добавиться, некоторые измениться, некоторые удалиться.

Если изменилось что-то понятное (например, добавилась одна ячейка), то всё просто. Но что делать, если у нас чат, в котором сообщения могут редактироваться и удаляться пачками? Или список пользователей, который показывается из кэша, а потом получается с сервера и полностью обновляется?

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

ATableAnimationCalculator представляет собой модель данных для таблицы, которая следит за текущим состоянием ячеек и, если ей сказать «вот тут новое что-то, посчитай разницу» — считает, выдавая список индексов ячеек и секций, требующих изменения (удаления, вставки, перемещения). После этого результат вычисления можно применить к таблице, обходя проблемы в реализации анимаций iOS.

Структура данных фреймворка

В названиях первая буква «A» — это не префикс фреймворка, как можно подумать, а сокращение слова «Awesome». 😉

Фреймворк состоит из:

  • Модели:
    • Протокола ACellModel, который нужно реализовать в модели ячейки.
    • Класса ASectionModelASectionModelObjC для поддержки 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *