Python conf in Kharkiv, Nov 16 with Intel, Elastic engineering leaders. Prices go up 21.10

Как UICollectionViewDiffableDataSource упрощает разработку UICollectionView

В предыдущей статье я поделился результатами своих исследований UICollectionViewCompositionalLayout. Но всем опытным разработчикам известно, что UICollectionView состоит из двух частей: Layout и DataSource. В этой статье я рассмотрю вторую половину, которую представили на WWDC, — UICollectionViewDiffableDataSource.

Если вы где-то слышали или читали о IGListKit, то вам UICollectionViewDiffableDataSource покажется очень знакомым. Действительно, Apple, как это часто бывает, взяла за основу идею, которая реализована в IGListKit, и представила свое решение.

В этом решении присутствует два класса UICollectionViewDiffableDataSource и NSDiffableDataSourceSnapshot, оба дженерики и взаимосвязаны.

open class UICollectionViewDiffableDataSource<SectionIdentifierType, ItemIdentifierType> : NSObject, UICollectionViewDataSource where SectionIdentifierType : Hashable, ItemIdentifierType : Hashable 

public struct NSDiffableDataSourceSnapshot<SectionIdentifierType, ItemIdentifierType> where SectionIdentifierType : Hashable, ItemIdentifierType : Hashable

UICollectionViewDiffableDataSource

Основная задача этого класса — предоставить все необходимые данные UICollectionView. Его инициализация происходит с блоком кода, который должен предоставить ячейки для коллекции. При необходимости можно установить дополнительный блок, который будет возвращать Supplementary View.

 public typealias CellProvider = (UICollectionView, IndexPath, ItemIdentifierType) -> UICollectionViewCell?

    public typealias SupplementaryViewProvider = (UICollectionView, String, IndexPath) -> UICollectionReusableView?

    public var supplementaryViewProvider: UICollectionViewDiffableDataSource<SectionIdentifierType, ItemIdentifierType>.SupplementaryViewProvider?

    public init(collectionView: UICollectionView, cellProvider: @escaping UICollectionViewDiffableDataSource<SectionIdentifierType, ItemIdentifierType>.CellProvider)

Второй его особенностью является возможность принимать NSDiffableDataSourceSnapshot: класс принимает слепок ваших данных и хранит его у себя, для того чтобы можно было находить разницу между двумя разными слепками и производить автоматически обновления в UICollectionView.

open func apply(_ snapshot: NSDiffableDataSourceSnapshot<SectionIdentifierType, ItemIdentifierType>, animatingDifferences: Bool = true, completion: (() -> Void)? = nil)

    open func snapshot() -> NSDiffableDataSourceSnapshot<SectionIdentifierType, ItemIdentifierType>

    open func itemIdentifier(for indexPath: IndexPath) -> ItemIdentifierType?

    open func indexPath(for itemIdentifier: ItemIdentifierType) -> IndexPath?

Давайте вспомним, что нам предоставляла Apple раньше. До этого момента у нас был объект, который хранил все данные для коллекции и являлся для нее UICollectionViewDataSource. Он возвращал количество секций, элементов, ячеек и т. д. Если нам было необходимо вставить, удалить, переместить элементы в коллекции, то для начала нужно разобраться, что изменилось в наших данных, и потом сообщить об этом коллекции, воспользовавшись этими методами.

 open func insertSections(_ sections: IndexSet)
    open func deleteSections(_ sections: IndexSet)
    open func reloadSections(_ sections: IndexSet)
    open func moveSection(_ section: Int, toSection newSection: Int)

    open func insertItems(at indexPaths: [IndexPath])
    open func deleteItems(at indexPaths: [IndexPath])
    open func reloadItems(at indexPaths: [IndexPath])
    open func moveItem(at indexPath: IndexPath, to newIndexPath: IndexPat

После этого нам нужно было вызвать метод performBatchUpdates или ничего не говорить коллекции и просто вызвать reloadData. В моей практике очень часто использовался второй вариант по двум причинам:

  1. Сложно найти разницу в изменениях, для этого нужен какой-то алгоритм.
  2. Вызывая performBatchUpdates, очень нередко можно было увидеть вот это:
Invalid update: invalid number of items in section 0. The number of items contained in an existing section after the update (1) must be equal to the number of items contained in that section before the update (1), plus or minus the number of items inserted or deleted from that section (1 inserted, 0 deleted) and plus or minus the number of items moved into or out of that section (0 moved in, 0 moved out).

Если вы начнете гуглить эту ошибку, то вначале все будет неоднозначно. Потом придет осознание, что где-то в данных, которые хранятся в UICollectionViewDataSource и в коллекции, происходит нестыковка, но на исправление этой ошибки тоже уйдет уйма времени.

Если честно, для меня уже это звучит, как страшный сон, потому что я давно на практике использую IGListKit, который позволяет избавиться от этих проблем. Теперь с UICollectionViewDiffableDataSource тоже можно этого избежать. Достаточно просто сообщать о том, что появились новые данные, и затем все необходимые операции будут выполнены и в UICollectionView все отобразится плавно, с анимацией.

NSDiffableDataSourceSnapshot

Эта структура представляет собой слепок данных, который обрабатывает DataSource. У него есть все методы для манипуляции данными: insert, move, delete, append, reload. Не буду останавливаться на этих методах подробнее, думаю, все понятно и новичкам. Лучше постараюсь объяснить то, что не описано в документации.

В основе данных слепка лежат два дженерик-элемента:

SectionIdentifierType : Hashable
ItemIdentifierType : Hashable

Как видно из описания, это идентификатор секции и элемента, оба они должны имплементировать протокол Hashable. Эти идентификаторы могут быть любыми объектами, элементы будут передаваться в блок кода для создания ячеек. Идентификаторы секций и элементов хранятся независимо друг от друга и должны быть уникальными. Идентификаторы секций и элементов не пересекаются друг с другом. Если у вас будет два элемента с одним и тем же идентификатором, DataSource выбросит эксепшен и отругает вас.

В слепке всегда должна быть хотя бы одна секция. В секцию элементы можно добавлять двумя способами: с указанием идентификатора секции и без него.

public mutating func appendItems(_ identifiers: [ItemIdentifierType], toSection sectionIdentifier: SectionIdentifierType? = ni

Если не указывать идентификатор секции, элементы добавятся к последней секции. Благодаря тому, что элементы хранятся отдельно от секций, мы можем перемещать их без указания секции.

Теперь немного о протоколе Hashable. Он необходим для сравнения двух слепков, при получении нового слепка DataSource пройдется по элементам в текущем и новом слепках и сравнит их. Структуры в последнем Swift по возможности автоматически реализуют этот протокол, поэтому они более удобны при создании слепков. В классах вам придется имплементировать все самостоятельно.

Слепок можно использовать двумя способами. Первый способ — взять уже текущий у DataSource и изменить его.

open func snapshot() -> NSDiffableDataSourceSnapshot<SectionIdentifierType, ItemIdentifierType>

Это удобно использовать, если есть необходимость в обновлении ячеек. Но тут есть одна особенность. Если вы используете структуры в качестве данных, то не забывайте, что они копируются, поэтому так просто изменить в слепке их не выйдет. Было бы очень удобно иметь метод update, которого, к сожалению, нет. Ниже я приведу пример кода, который берет слепок, изменяет значение и обновляет ячейку.

@objc func reloadItem() {
        var snapshot = dataSource.snapshot()
        var item = snapshot.itemIdentifiers[2]
        item.value = Int.random(in: 1...25)
        snapshot.deleteItems([item])
        snapshot.insertItems([item], beforeItem: snapshot.itemIdentifiers[2])
        snapshot.reloadItems([item])
        dataSource.apply(snapshot, animatingDifferences: true)
    }

В случае с классами об этом беспокоиться не нужно. Копирование не происходит, поэтому значение можно изменить напрямую.

Во втором случае, который мне нравится больше, можно создавать слепок из исходных данных и отдавать его в DataSource, используя метод apply .

func dataSnapshot() -> NSDiffableDataSourceSnapshot<Section, Item> {
        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapshot.appendSections(sections)
        snapshot.appendItems(firstSectionData, toSection: .first)
        if sections.contains(.second) {
            snapshot.appendItems(secondSectionData, toSection: .second)
        }
        if sections.contains(.third) {
            snapshot.appendItems(thirdSectionData, toSection: .third)
        }
        return snapshot
    }

Бонус

Есть несколько небольших дополнительных возможностей, о которых я узнал в процессе изучения UICollectionViewDiffableDataSource.

Метод apply класса UICollectionViewDiffableDataSource можно вызывать в background-потоке, он будет там просчитывать все изменения, а методы UICollectionView автоматически вызовет в основном потоке. Это нужно в случае, если у вас большие объемы данных, по которым нужно рассчитывать разницу. Есть одно ограничение: метод apply нужно следует вызывать в одном и том же потоке, иначе вы увидите предупреждение в логах, и разработчики не обещают нормального поведения класса в таком случае.

Длительностью анимации изменений в коллекции можно управлять, для этого достаточно вызвать метод apply в анимационном блоке.

UIView.animate(withDuration: 2) {
      self.dataSource.apply(snapshot, animatingDifferences: true)
  }

И последнее. Рано или поздно появится вопрос, как же использовать разные элементы для разных секций, так как UICollectionViewDiffableDataSource — класс дженерик и протокол Hashable накладывает ограничения для применения конкретных классов, приходится использовать дополнительные способы. Я вижу как минимум два.

Первый способ — это применение ассоциированных enum.

enum Item: Hashable {
        case string(String)
        case number(Int)
    }

dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collection) {
            (collectionView: UICollectionView, indexPath: IndexPath, item: Item) -> UICollectionViewCell? in

            // Get a cell of the desired kind.
            guard let cell = collectionView.dequeueReusableCell(
                withReuseIdentifier: TextCell.reuseIdentifier,
                for: indexPath) as? TextCell else { fatalError("Cannot create new cell") }

            // Populate the cell with our item description.
            switch item {
            case .number(let value):
                cell.configureWith(text: "Section \(indexPath.section), Row \(indexPath.row), Item \(value)")
            case .string(let value):
                cell.configureWith(text: "Section \(indexPath.section), Row \(indexPath.row), Item \(value)")
            }

            // Return the cell.
            return cell
        }

Второй — использовать структуру-обертку AnyHashable.

struct IntItem: Hashable {
        let id = UUID().uuidString
        var value: Int
    }

    struct StringItem: Hashable {
        let id = UUID().uuidString
        var value: String
    }
dataSource = UICollectionViewDiffableDataSource<Section, AnyHashable>(collectionView: collection) {
            (collectionView: UICollectionView, indexPath: IndexPath, item: AnyHashable) -> UICollectionViewCell? in

            // Get a cell of the desired kind.
            guard let cell = collectionView.dequeueReusableCell(
                withReuseIdentifier: TextCell.reuseIdentifier,
                for: indexPath) as? TextCell else { fatalError("Cannot create new cell") }

            // Populate the cell with our item description.
            switch item {
            case let item as IntItem:
                cell.configureWith(text: "Section \(indexPath.section), Row \(indexPath.row), Item \(item.value)")
            case let item as StringItem:
                cell.configureWith(text: "Section \(indexPath.section), Row \(indexPath.row), Item \(item.value)")
            default:
                ()
            }

            // Return the cell.
            return cell
        }

Итоги

Данный datasource значительно облегчает нам разработку на UICollectionView. Мы избавляемся от необходимости дополнительно вызывать методы, уменьшаем количество ошибок которые можем допустить. В последней и завершительной статье я постараюсь объединить UICollectionViewDiffableDataSource и UICollectionViewCompositionalLayout, построить на их основе сложный экран с коллекцией.

Полезные ссылки

Читайте также: Отображение списков с помощью UICollectionViewCompositionalLayout в iOS и Как построить сложный UICollectionView, используя iOS 13.

LinkedIn

9 комментариев

Подписаться на комментарииОтписаться от комментариев Комментарии могут оставлять только пользователи с подтвержденными аккаунтами.

К сожалению, пока только в iOS 13, потому актуальнее было бы рассматривать какой-нибудь
Carbon и DifferenceKit.

Спасибо за статьи. Инетересно, лаконично и без воды

Я в таких випадках використовую github.com/jflinter/Dwifft — досить компатна і якісно зроблена ліба. А загалом респект Еплу за чергову порцію «цукру». Автору подяка за статтю!

это всё хорошо, но вот

UICollectionViewDiffableDataSource

доступен только с 13 оси. А значит, что он сможет применять или в 13+ апаха сейчас или через год-два в текущих. Со стороны это выглядит, как «мы делали это для swiftUI, но вы тоже можете пользоваться»
Вот интересно, почему епл решил это сделать в системной либе, а не вынес в стороннюю либу? тогда могли бы использовать и на более древних операционках. А так да, IGListKit наше всё.

Я лишь могу написать что жизнь это боль :) Все зависит от проекта, я планирую поддерживать 12-ую до тех пор пока не выйдет 14-я, а дальше жизнь покажет. И давайте не забывать что SwiftUI до сих пор в бете. И вот он врядли будет использоваться еще год или два, тут можно вспомнить как быстро появились проекты на Swift. А так же некоторые legacy проекты все еще на Objective-C.

да что вы знаете о боле? :)
у нас местами obj-c++ и мы еще поддерживаем 10 ось.

обнять и заплакать

Подписаться на комментарии