Представим себе экран обычного мобильного приложения с уже заполненным списком ячеек. С сервера приходит другой список. Нужно посчитать разницу между ними (что добавилось/удалилось) и проанимировать 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?).


Обновление 0.9.12


В комментариях предложили добавить методы для поддержки стандартного редактирования UITableView. Добавил. Пользоваться можно вот так:


let itemsToAnimate = try! calculator.removeItem(withIndex:indexPath) // для удаления
let itemsToAnimate = try! calculator.swapItems(withIndex:sourceIndexPath, toIndex:destinationIndexPath) // для перемещения

А добавление и так понятно. При этом только нужно учесть, что если вы собираетесь работать с перемещением ячеек, то нужно «выключить» cellModelComparator, либо проконтролировать, что он учитывает правильное положение ячеек при перемещении.


Заключение, замечания, исходники


Код испытан на куче искусственных/случайных тестов. Насколько я вижу, он работает достаточно хорошо. Если вы видите какие-то недочёты или неучтённые случаи, пишите.


Использование дженериков и привязанных типов (associated types) ломает (судя по ответам на StackOverflow) совместимость с iOS 7, поэтому поддерживаются только iOS 8 и 9.


Исходники живут на GitHub, проект называется ATableAnimationCalculator. Для интеграции можно включить исходниками к себе (там всего несколько файлов). Если нужен только алгоритм, можно подключить всё кроме расширений для UIKit'а.


Есть под в CocoaPods:


pod 'AwesomeTableAnimationCalculator'

Поддерживается Carthage:


github "bealex/AwesomeTableAnimationCalculator"

Если будут какие-то вопросы, задавайте либо тут, либо сразу в почту alex@jdnevnik.com.


Благодарности


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

Комментарии (9)


  1. kpower
    28.04.2016 20:45

    Не смотрели на способы работы NSFetchedResultsController? Точнее даже не их (т.к. он для проверки изменений, естественно, полагается на возможность узнать это у объектов класса NSManagedObject), а взаимодействие с таблицей через делегат. Просто решаемая задача-то одна, а тогда при сходном синтаксисе делегата (...WillChangeContent:, didChangeSection: и так далее) было бы проще пользоваться и вашей библиотекой.


    1. bealex
      28.04.2016 21:07

      Нет, не смотрел. Не могу сказать, взлетит оно или нет, CoreData может внезапно поставить палки в колеса, но посмотрю.


    1. bealex
      28.04.2016 21:55

      Глянул. Ничего особо не должно препятствовать использованию ATableAnimationCalculator даже вместо NSFetchedResultsController. Конечно, мы потеряем события об изменении запросов, но если нужно обновлять по требованию пользователя, то будет даже немного проще.

      Ну, или подключиться к событиям, после чего заполнить результат вручную (я имею в виду ATableDiff, там достаточно простые поля), и в controllerDidChangeContent — сказать diff.applyTo(collectionView:collectionView).

      Впрочем, не уверен, что только ради второй части нужно использовать ATableAnimationCalculator :)


      1. kpower
        28.04.2016 21:59

        Я, наверное, криво выразился. Суть не в использовании вашего контрола вместо NSFetched… (не обижайтесь, но смысла мало — первый очень хорошо решает данную задачу, но ограничен классами). Мое предложение было сделать ваше взаимодействие (протоколы) максимально похожим на то, которое идет «из коробки» (в NSFetched...) — будет проще, если все примерно в одних терминах живет (отдельно обновляет ячейки, отдельно секции, делает общую подготовку).


        1. bealex
          28.04.2016 22:09

          Никаких обид, отличное предложение.

          Это можно сделать, вообще не вопрос. Не уверен только, что правильно. И вот почему.

          Дело в том, что NSFetchedResultsController был сделан для UITableView, и так себе, например, подходит для UICollectionView с его performBatchUpdates.

          Также разнится и источник изменений. В NSFetchedResultsController — внешняя сущность, CoreData. А у меня изменения должны вливаться в модель бизнес-логикой приложения. В этой ситуации проще применить сразу все изменения, нежели выдавать их по-одному.

          Другое дело, что, возможно, имеет смысл дать возможность подключить стандартные операции, которые приходят из UI (редактирование таблицы из UI, перемещение ячеек) — это интересная мысль, я подумаю, что тут можно сделать.


          1. kpower
            28.04.2016 22:14

            А вот это отличный ответ, спасибо! В целом со всем согласен))


            1. bealex
              07.05.2016 20:58

              Добавил немного методов, чтобы проще было подключить методы редактирования UITableViewDataSource. (код в обновлении статьи про 0.9.12)


  1. jazzz13
    28.04.2016 23:23

    Кажется, это именно то, что я все время планировал написать для себя.
    NSFetchedResultsController — можно использовать, но иногда хочется работать с простыми данными.

    Добавил в закладки. Буду пробовать.


    1. bealex
      07.05.2016 20:59
      +1

      Я обновил код до 0.9.12, он стал сильно стабильнее и умнее. Если будете пользоваться — лучше использовать именно эту версию.