Хабрахабр

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

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

enum Builder ) return header + body }
} let objects: [Object] = ...
Builder .widgets(objects: objects) .bind(to: collectionView)

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

image

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

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

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

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

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

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

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

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

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

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

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

    let cell = widget.widgetCell(collectionView: collectionView, indexPath: indexPath)
    return cell

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

protocol WidgetIdentifiable: IdentifiableType { } extension WidgetIdentifiable { var identity: Self { return self }
}

Данные для соответствия протоколу Hashable виджет будет брать из объекта, который будет описывать конкретный примитив. Чтобы соответствовать WidgetIdentifiable, виджету требуется соответствовать протоколу 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:) — для получения ячеек.

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

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<SpacingView> = 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) }
}

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

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

typealias DataSource = RxCollectionViewSectionedAnimatedDataSource<WidgetSection> class Controller: UIViewController { private lazy var dataSource: DataSource = self.makeDataSource() var observerWidgets: (Observable<Widgets>) -> 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 осуществима и в подходящих ситуациях способна упростить жизнь разработчику.

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

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

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

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть