...

четверг, 28 апреля 2016 г.

Боль и анимация таблиц для 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.


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


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

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

    Let's block ads! (Why?)

    Комментариев нет:

    Отправить комментарий