[Из песочницы] Декомпозируя UICollectionViewCell

После просмотра Keynote WWDC 2019 и знакомства с SwiftUI, предназначенного для декларативного описания UI в коде, хочется порассуждать о том, как можно декларативно наполнять таблички и коллекции. Например, вот так:

enum Builder {

    static func widgets(objects: Objects) -> [Widget] {
        let header = [
            Spacing(height: 25).widget,
            Header(string: "Выберите страну").widget,
            Spacing(height: 10, separator: .bottom).widget
        ]
        let body = objects
            .flatMap({ (object: Object) -> [Widgets] in
                return [
                    Title(object: object).widget,
                    Spacing(height: 1, separator: .bottom).widget
                ]
            })
        return header + body
    }
}

let objects: [Object] = ...
Builder
    .widgets(objects: objects)
    .bind(to: collectionView)

В коллекции это отрисуется следующим образом:

image

Как известно из авторитетных источников: подавляющее большинство своего времени типичный iOS-разработчик проводит за работой с табличками. Если предположить, что разработчиков на проекте катастрофически нехватает и таблички не из простых, то времени на остальные части приложения не остается совсем. И с этим надо что-то делать… Возможным решением будет декомпозиция ячеек.

Под декомпозицией ячеек подразумевается замена одной ячейки несколькими ячейками меньшего размера. При такой замене визуально ничего не должно измениться. В качестве примера можно рассмотреть посты из новостной ленты VK для iOS. Один пост можно представить как в виде одной ячейки, так и в виде группы ячеек — примитивов.

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

Используя декомпозицию ячеек, таблицы/коллекции начинают состоять из примитивов, которые часто будут переиспользоваться: примитив с текстом, примитив с картинкой, примитив с фоном и т.п. Вычисление высоты отдельно взятого примитива гораздо проще и эффективнее, чем сложной ячейки с большим количеством состояний. При желании динамическую высоту примитива можно посчитать или даже отрисовать в бэкграунде (например, текст через CTFramesetter).

С другой стороны усложняется работа с данными. Данные будут требоваться для каждого примитива и по IndexPath примитива сложно будет определить к какой реальной ячейке он относится. Придется вводить новые слои абстракции или еще как-то решать эти проблемы.

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

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

Используя UICollectionView сталкиваешься с ситуацией, когда базовый UICollectionViewFlowLayout не может сформировать требуемое расположение элементов коллекции (новый UICollectionViewCompositionalLayout в расчет не берем). В такие моменты обычно принимается решение найти какой-нибудь open-source UICollectionViewLayout. Но и среди готовых решений может не быть подходящего, как, например, в случае с динамической главной страницей крупного интернет-магазина или социальной сети. Предполагаем худшее, поэтому будем создавать собственный универсальный UICollectionViewLayout.

Помимо сложностей с выбором лейаута, требуется принять решение о том, как коллекция будет получать данные. Кроме обычного подхода, где объект (чаще всего UIViewController) соответствует протоколу UICollectionViewDataSource и предоставляет данные для коллекции, набирает популярность использование data-driven фреймворков. Яркими представителями такого подхода являются CollectionKit, IGListKit, RxDataSources и другие. Использование подобных фреймворков упрощает работу с коллекциями и предоставляет возможность анимировать изменения данных, т.к. diffing алгоритм уже присутствует в фреймворке. Для целей публикации будет выбран фреймворк RxDataSources.

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


  1. Виджет должен соответствовать необходимым протоколам для использования data-driven фреймворком. Такие протоколы обычно содержат ассоциированное значение (например, IdentifiableType в RxDataSources)
  2. Должна быть возможность собирать виджеты для разных примитивов в массив. Чтобы этого добиться виджет не должен иметь ассоциированных значений. Для этих целей можно использовать механизм стирания типов или что-то в этом духе.
  3. Виджет должен уметь считать размер примитива. Тогда при формировании UICollectionViewLayout, останется лишь правильно расположить примитивы по заранее предусмотренным правилам.
  4. Виджет должен являться фабрикой для UICollectionViewCell. Поэтому из реализации UICollectionViewDataSource будет убрана вся логика по созданию ячеек и останется лишь:
    let cell = widget.widgetCell(collectionView: collectionView, indexPath: indexPath)
    return cell

Чтобы была возможность использовать виджет с фреймворком RxDataSources, он должен соответствовать протоколам Equatable и IdentifiableType. Так как виджет представляет примитив, то для целей публикации будет достаточным, если виджет будет сам себя идентифицировать для соответствия протоколу IdentifiableType. На практике это скажется на том, что при изменении виджета будет происходить не перезагрузка примитива, а удаление и вставка. Для этого введем новый протокол WidgetIdentifiable:

protocol WidgetIdentifiable: IdentifiableType {

}

extension WidgetIdentifiable {

    var identity: Self {
        return self
    }
}

Чтобы соответствовать WidgetIdentifiable, виджету требуется соответствовать протоколу Hashable. Данные для соответствия протоколу Hashable виджет будет брать из объекта, который будет описывать конкретный примитив. Можно использовать AnyHashable для «стирания» виджетом типа объекта.

struct Widget: WidgetIdentifiable {

    let underlying: AnyHashable

    init(_ underlying: AnyHashable) {
        self.underlying = underlying
    }
}

extension Widget: Hashable {

    func hash(into hasher: inout Hasher) {
        self.underlying.hash(into: &hasher)
    }

    static func ==(lhs: Widget, rhs: Widget) -> Bool {
        return lhs.underlying == rhs.underlying
    }
}

На этом этапе первые два свойства виджета выполняются. Это нетрудно проверить собрав в массив несколько виджетов с разными типами объектов.

let widgets = [Widget("Hello world"), Widget(100500)]

Для реализации оставшихся свойств введем новый протокол WidgetPresentable

protocol WidgetPresentable {

    func widgetCell(collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionViewCell
    func widgetSize(containerWidth: CGFloat) -> CGSize
}

Функция widgetSize(containerWidth:) будет использоваться в UICollectionViewLayout при формировании атрибутов ячеек, а widgetCell(collectionView:indexPath:) — для получения ячеек.

При соответствии виджета протоколу WidgetPresentable, виджет будет выполнять все обозначеные в начале публикации свойства. Однако, содержащийся внутри виджета AnyHashable объект придется заменить на композицию WidgetPresentable и WidgetHashable, где WidgetHashable не будет иметь ассоциированного значения (как в случае с Hashable) и тип объекта внутри виджета останется «стертым»:

protocol WidgetHashable {

    func widgetEqual(_ any: Any) -> Bool
    func widgetHash(into hasher: inout Hasher)
}

В конечном варианте виджет будет выглядеть следующим образом:

struct Widget: WidgetIdentifiable {

    let underlying: WidgetHashable & WidgetPresentable

    init(_ underlying: WidgetHashable & WidgetPresentable) {
        self.underlying = underlying
    }
}

extension Widget: Hashable {

    func hash(into hasher: inout Hasher) {
        self.underlying.widgetHash(into: &hasher)
    }

    static func ==(lhs: Widget, rhs: Widget) -> Bool {
        return lhs.underlying.widgetEqual(rhs.underlying)
    }
}

extension Widget: WidgetPresentable {

    func widgetCell(collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionViewCell {
        return underlying.widgetCell(collectionView: collectionView, indexPath: indexPath)
    }

    func widgetSize(containerWidth: CGFloat) -> CGSize {
        return underlying.widgetSize(containerWidth: containerWidth)
    }
}

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

struct Spacing: Hashable {

    let height: CGFloat
}

class SpacingView: UIView {

    lazy var constraint = self.heightAnchor.constraint(equalToConstant: 1)

    init() {
        super.init(frame: .zero)
        self.constraint.isActive = true
    }
}

extension Spacing: WidgetHashable {

    func widgetEqual(_ any: Any) -> Bool {
        if let spacing = any as? Spacing {
            return self == spacing
        }
        return false
    }

    func widgetHash(into hasher: inout Hasher) {
        self.hash(into: &hasher)
    }
}

extension Spacing: WidgetPresentable {

    func widgetCell(collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionViewCell {
        let cell: WidgetCell = collectionView.cellDequeueSafely(indexPath: indexPath)
        if cell.view == nil {
            cell.view = SpacingView()
        }
        cell.view?.constraint.constant = height
        return cell
    }

    func widgetSize(containerWidth: CGFloat) -> CGSize {
        return CGSize(width: containerWidth, height: height)
    }
}

WidgetCell — это всего лишь сабкласс UICollectionViewCell, который принимает UIView и добавяет ее как сабвью. cellDequeueSafely(indexPath:) — это функция, которая регистрирует ячейку в коллекции перед переиспользованием, если ранее ячейка в коллекции зарегистрирована не была. Использоваться Spacing будет так, как это описано в самом начале публикации.

После получения массива виджетов, его останется лишь забиндить на observerWidgets:

typealias DataSource = RxCollectionViewSectionedAnimatedDataSource

class Controller: UIViewController {

    private lazy var dataSource: DataSource = self.makeDataSource()

    var observerWidgets: (Observable) -> Disposable {
        return collectionView.rx.items(dataSource: dataSource)
    }

    func makeDataSource() -> DataSource {
        return DataSource(configureCell: { (_, collectionView: UICollectionView, indexPath: IndexPath, widget: Widget) in
            let cell = widget.widgetCell(collectionView: collectionView, indexPath: indexPath)
            return cell
        })
    }
}

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


image

Как можно увидеть, декомпозиция UICollectionViewCell осуществима и в подходящих ситуациях способна упростить жизнь разработчику.

Приведенный в публикации код является очень упрощенным и не должен собираться. Целью было описать подход, а не предоставить готовое решение.

Протокол WidgetPresentable можно расширить и другими функциями, позволяющими оптимизировать лейаут, например, widgetSizeEstimated(containerWidth:) или widgetSizePredefined(containerWidth:), которые возвращают предполагаемый и фиксированный размер соответственно. Стоит отметить, что функция widgetSize(containerWidth:) должна возвращать размер примитива даже для ресурсоемких вычислений, например, для systemLayoutSizeFitting(_:). Подобные вычисления можно кэшировать через Dictionary, NSCache и т.п.

Как известно, все типы ячеек, используемые UICollectionView, должны быть предварительно зарегистрированы в коллекции. Однако, чтобы переиспользовать виджеты между разными экранами/коллекциями и не регистрировать заранее все идентификаторы/типы ячеек, необходимо обзавестись механизмом, который будет регистрировать ячейку непосредственно перед ее первым использованием в рамках каждой коллекции. В публикации для этого использовалась функция cellDequeueSafely(indexPath:).

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

В рамках одной коллекции могут находиться виджеты с одинаковыми объектами. Например, одинаковый Spacing в начале и в конце коллекции. Наличие таких неуникальных объектов приведет к тому, что анимация в коллекции пропадет. Чтобы сделать подобные объекты уникальными, можно использовать специальные AnyHashable тэги, #file и #line места создания объекта и т.п.

© Habrahabr.ru