[Из песочницы] Декомпозируя 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)
В коллекции это отрисуется следующим образом:
Как известно из авторитетных источников: подавляющее большинство своего времени типичный iOS-разработчик проводит за работой с табличками. Если предположить, что разработчиков на проекте катастрофически нехватает и таблички не из простых, то времени на остальные части приложения не остается совсем. И с этим надо что-то делать… Возможным решением будет декомпозиция ячеек.
Под декомпозицией ячеек подразумевается замена одной ячейки несколькими ячейками меньшего размера. При такой замене визуально ничего не должно измениться. В качестве примера можно рассмотреть посты из новостной ленты VK для iOS. Один пост можно представить как в виде одной ячейки, так и в виде группы ячеек — примитивов.
Декомпозировать ячейки получится не всегда. Сложно будет разбить на примитивы ячейку, которая имеет тень или скругление со всех сторон. В таком случае исходная ячейка и будет являться примитивом.
Используя декомпозицию ячеек, таблицы/коллекции начинают состоять из примитивов, которые часто будут переиспользоваться: примитив с текстом, примитив с картинкой, примитив с фоном и т.п. Вычисление высоты отдельно взятого примитива гораздо проще и эффективнее, чем сложной ячейки с большим количеством состояний. При желании динамическую высоту примитива можно посчитать или даже отрисовать в бэкграунде (например, текст через CTFramesetter
).
С другой стороны усложняется работа с данными. Данные будут требоваться для каждого примитива и по IndexPath
примитива сложно будет определить к какой реальной ячейке он относится. Придется вводить новые слои абстракции или еще как-то решать эти проблемы.
Можно долго рассуждать о возможных плюсах и минусах данной затеи, но лучше попробовать описать подход к декомпозиции ячеек.
Так как UITableView
ограничена в своих возможностях, а у нас, как уже упоминалось, достаточно сложные таблички, то адекватным решением будет использование UICollectionView
. Именно о UICollectionView
и пойдет речь в данной публикации.
Используя UICollectionView
сталкиваешься с ситуацией, когда базовый UICollectionViewFlowLayout
не может сформировать требуемое расположение элементов коллекции (новый UICollectionViewCompositionalLayout
в расчет не берем). В такие моменты обычно принимается решение найти какой-нибудь open-source UICollectionViewLayout
. Но и среди готовых решений может не быть подходящего, как, например, в случае с динамической главной страницей крупного интернет-магазина или социальной сети. Предполагаем худшее, поэтому будем создавать собственный универсальный UICollectionViewLayout
.
Помимо сложностей с выбором лейаута, требуется принять решение о том, как коллекция будет получать данные. Кроме обычного подхода, где объект (чаще всего UIViewController
) соответствует протоколу UICollectionViewDataSource
и предоставляет данные для коллекции, набирает популярность использование data-driven фреймворков. Яркими представителями такого подхода являются CollectionKit, IGListKit, RxDataSources и другие. Использование подобных фреймворков упрощает работу с коллекциями и предоставляет возможность анимировать изменения данных, т.к. diffing алгоритм уже присутствует в фреймворке. Для целей публикации будет выбран фреймворк RxDataSources.
Введем промежуточную структуру данных и назовем ее виджетом. Опишем основные свойства, которыми должен обладать виджет:
- Виджет должен соответствовать необходимым протоколам для использования data-driven фреймворком. Такие протоколы обычно содержат ассоциированное значение (например,
IdentifiableType
в RxDataSources) - Должна быть возможность собирать виджеты для разных примитивов в массив. Чтобы этого добиться виджет не должен иметь ассоциированных значений. Для этих целей можно использовать механизм стирания типов или что-то в этом духе.
- Виджет должен уметь считать размер примитива. Тогда при формировании
UICollectionViewLayout
, останется лишь правильно расположить примитивы по заранее предусмотренным правилам. - Виджет должен являться фабрикой для
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
})
}
}
В заключении хотелось бы показать реальную работу коллекции, которая построена целиком на виджетах.
Как можно увидеть, декомпозиция UICollectionViewCell
осуществима и в подходящих ситуациях способна упростить жизнь разработчику.
Приведенный в публикации код является очень упрощенным и не должен собираться. Целью было описать подход, а не предоставить готовое решение.
Протокол WidgetPresentable
можно расширить и другими функциями, позволяющими оптимизировать лейаут, например, widgetSizeEstimated(containerWidth:)
или widgetSizePredefined(containerWidth:)
, которые возвращают предполагаемый и фиксированный размер соответственно. Стоит отметить, что функция widgetSize(containerWidth:)
должна возвращать размер примитива даже для ресурсоемких вычислений, например, для systemLayoutSizeFitting(_:)
. Подобные вычисления можно кэшировать через Dictionary
, NSCache
и т.п.
Как известно, все типы ячеек, используемые UICollectionView
, должны быть предварительно зарегистрированы в коллекции. Однако, чтобы переиспользовать виджеты между разными экранами/коллекциями и не регистрировать заранее все идентификаторы/типы ячеек, необходимо обзавестись механизмом, который будет регистрировать ячейку непосредственно перед ее первым использованием в рамках каждой коллекции. В публикации для этого использовалась функция cellDequeueSafely(indexPath:)
.
В коллекции может не быть ни хедеров, ни футеров. На их месте будут примитивы. Наличие supplementary в коллекции никаких особых бонусов не даст в текущем подходе. Обычно их используют когда массив данных строго соответствует количеству ячеек и требуется показать дополнительные вьюшки перед, после или между ячейками. В нашем случае данные для вспомогательных вьюх также можно добавлять в массив виджетов и рисовать как примитивы.
В рамках одной коллекции могут находиться виджеты с одинаковыми объектами. Например, одинаковый Spacing
в начале и в конце коллекции. Наличие таких неуникальных объектов приведет к тому, что анимация в коллекции пропадет. Чтобы сделать подобные объекты уникальными, можно использовать специальные AnyHashable
тэги, #file
и #line
места создания объекта и т.п.