Как построить сложный UICollectionView, используя iOS 13
В первой и второй статьях я рассмотрел UICollectionViewCompositionalLayout и UICollectionViewDiffableDataSource — основные компоненты, которые используются при создании UICollectionView. Но это все теория. А как реализовать сложный layout, используя эти компоненты, я постараюсь объяснить в своей третьей статье. Мой подход не претендует на универсальность, но с его помощью удалось покрыть все придуманные мною требования.
Представим, что есть библиотека и наша задача — создать мобильное приложение, с помощью которого клиенты смогут брать книги из библиотеки и возвращать их обратно. В таком мобильном приложении будет три секции:
- Информация о клиенте.
- Список взятых книг.
- Список книг, которые можно взять.
В итоге все будет выглядеть, как на скриншоте:

Перед тем как приступить к разработке, нужно решить одну проблему. Как видно из скриншота, в разных секциях присутствуют разные объекты и они по-разному размещаются. UICollectionViewDiffableDataSource не разрешает напрямую использовать разные объекты, поэтому я постараюсь построить обертку, которая позволит это сделать.
Ради эксперимента я буду использовать только структуры в качестве объектов, что упрощает имплементацию протокола Hashable, но усложняет немного процесс хранения этих объектов.
Section & Cell
Для начала создадим два базовых объекта. Первый — это простой протокол Cell, который будет имплементировать ячейки в коллекции. Внутри будет одна функция, вызываемая для конфигурации ячейки. Чтобы не зависеть от конкретного типа, введем ассоциативный тип Object.
protocol Cell {
associatedtype Object
func configure(with object: Object)
}
Второй объект — это абстрактный класс Section, который будет имплементировать протокол Hashable. Этот класс понадобится для того, чтобы все секции имели одинаковое поведение.
class Section: Hashable {
let id: String
init(id: String) {
self.id = id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: Section, rhs: Section) -> Bool {
return lhs.id == rhs.id
}
}
CollectionAdapter
CollectionAdapter будет связывать два наших ключевых объекта — UICollectionViewCompositionalLayout и UICollectionViewDiffableDataSource. Этот адаптер будет хранить в себе UICollectionViewDiffableDataSource и запрашивать данные для слепков с помощью делегата. Так как в секциях будут разные элементы, сразу будем использовать структуру AnyHashable.
В делегате будет два простых метода: первый будет возвращать все секции для коллекции, второй — элементы для секции. Ячейки для datasource будут предоставлять секции, для этого необходимо добавить в класс Section новую функцию, которая будет это делать.
С UICollectionViewCompositionalLayout все просто: необходимо возвращать расположение элементов в секциях. Для этого тоже добавим функцию в класс Section.
protocol CollectionAdapterDelegate: AnyObject {
func sections() -> [Section]
func itemsFor(section: Section) -> [AnyHashable]
}
class CollectionAdapter {
private weak var collection: UICollectionView?
private lazy var datasource: UICollectionViewDiffableDataSource<Section, AnyHashable> = UICollectionViewDiffableDataSource(collectionView: self.collection!, cellProvider: cell)
private weak var delegate: CollectionAdapterDelegate?
init(collection: UICollectionView, delegate: CollectionAdapterDelegate) {
self.collection = collection
self.delegate = delegate
super.init()
collection.collectionViewLayout = UICollectionViewCompositionalLayout(sectionProvider: sectionLayout)
}
private func cell(in collection: UICollectionView, at indexPath: IndexPath, for item: AnyHashable) -> UICollectionViewCell? {
guard let item = datasource.itemIdentifier(for: indexPath) else {
return nil
}
let section = datasource.snapshot().sectionIdentifiers[indexPath.section]
return section.cell(for: item, at: indexPath, in: collection)
}
private func sectionLayout(for sectionIndex: Int, environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? {
let section = datasource.snapshot().sectionIdentifiers[sectionIndex]
return section.layout(environment: environment)
}
}
class Section: Hashable {
open func cell(for item: AnyHashable, at indexPath: IndexPath, in collection: UICollectionView) -> UICollectionViewCell? {
return nil
}
open func layout(environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? {
return nil
}
}
Одной важной функции здесь не хватает. Нужно создавать слепки и передавать их в datasource методом apply. Кроме того, перед тем как отрисовывать ячейки, придется зарегистрировать их в коллекции. ViewController будет делегатом для нашего адаптера и будет возвращать необходимые данные. Ниже код этой функции.
func performUpdates(animated: Bool, completion: (() -> Void)? = nil) {
guard let delegate = delegate, let collection = collection else {
return
}
var snapshot = NSDiffableDataSourceSnapshot<Section, AnyHashable>()
for section in delegate.sections() {
section.registerCells(in: collection)
snapshot.appendSections([section])
let items = delegate.itemsFor(section: section)
snapshot.appendItems(items)
}
datasource.apply(snapshot, animatingDifferences: animated, completion: completion)
}
CollectionSection
Если взять за правило, что в одной секции будут храниться элементы одного типа, то можно создать дженерик-класс, в нем будет два дженерик-элемента, ячейка и сам объект. Так как наш класс Section является абстрактным, то наследоваться будем от него. Мы уже знаем, что для коллекции нужно возвращать ячейки и layout.
class CollectionSection<T: Hashable, CollectionCell: Cell>: Section
where CollectionCell: UICollectionViewCell, CollectionCell.Object == T {
var layout: ((NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection)?
private var cellId: String {
return String(describing: CollectionCell.self)
}
override func registerCells(in collection: UICollectionView) {
collection.register(UINib(nibName: cellId, bundle: nil),
forCellWithReuseIdentifier: cellId)
}
override func cell(for item: AnyHashable, at indexPath: IndexPath, in collection: UICollectionView) -> UICollectionViewCell? {
guard let item = item as? T else {
return nil
}
guard let cell = collection.dequeueReusableCell(withReuseIdentifier: cellId, for: indexPath) as? CollectionCell else {
return nil
}
cell.configure(with: item)
return cell
}
override func layout(environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? {
return layout?(environment)
}
}
Client Section
Согласно нашим требованиям, в этой секции будет располагаться краткая информация о клиенте и количество взятых им книг. Для этого понадобится ячейка и сама модель.
struct ClientInfo: Hashable {
let id: String
let clientName: String
let maxBooksAmount: Int
var booksAmount: Int
}
class ClientInfoCell: UICollectionViewCell, Cell {
@IBOutlet weak var heyLbl: UILabel!
@IBOutlet weak var booksInfoLbl: UILabel!
override func awakeFromNib() {
super.awakeFromNib()
}
func configure(with object: ClientInfo) {
heyLbl.text = "Hey \(object.clientName)!"
switch object.booksAmount {
case 0:
booksInfoLbl.text = "You have \(object.booksAmount) books from \(object.maxBooksAmount), time to find your best book!"
case 1..<object.maxBooksAmount:
booksInfoLbl.text = "You have \(object.booksAmount) books from \(object.maxBooksAmount), dont't forget to return them on time!"
case object.maxBooksAmount:
booksInfoLbl.text = "You can't take more books, try to return at least one book"
default:
()
}
}
}
После этого нужно сконфигурировать секцию и вернуть данные о ней и ее объектах в адаптер.
func sections() -> [Section] {
let clientSection = CollectionSection<ClientInfo, ClientInfoCell>(id: CollectionSections.clientSection.rawValue)
clientSection.layout = { env in
return NSCollectionLayoutSection.listLayout(environment: env, height: .estimated(90))
}
return [clientSection]
}
func itemsFor(section: Section) -> [AnyHashable] {
switch section {
case is CollectionSection<ClientInfo, ClientInfoCell>:
return [clientInfo]
default:
return []
}
}
override func viewDidLoad() {
super.viewDidLoad()
adapter.performUpdates(animated: false)
}

Library Books Section
Возьмемся за секцию, в которой будем отображать библиотечные книги. В текущей секции будут храниться книги, которые мы можем взять из библиотеки. Если книга взята, то необходимо будет отметить ее галочкой и поместить во вторую секцию. Ячейки будут иметь динамическую высоту и располагаться сеткой. Чтобы брать и возвращать книгу, нужно реализовать метод нажатия на элемент. Для этого добавим его в CollectionAdapter, Section и CollectionSection.
extension CollectionAdapter: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = datasource.itemIdentifier(for: indexPath) else {
return
}
let section = datasource.snapshot().sectionIdentifiers[indexPath.section]
section.didSelect(item: item, at: indexPath.row)
}
}
class Section: Hashable {
open func didSelect(item: AnyHashable, at index: Int) {
}
}
class CollectionSection<T: Hashable, CollectionCell: Cell>: Section
where CollectionCell: UICollectionViewCell, CollectionCell.Object == T {
var cellSelection: ((T, Int) -> Void)?
override func didSelect(item: AnyHashable, at index: Int) {
guard let item = item as? T else {
return
}
cellSelection?(item, index)
}
}
Теперь добавим необходимый метод в ViewController:
func didSelect(book: Book, at index: Int) {
books[index].isSelected.toggle()
let book = books[index]
if book.isSelected && clientInfo.booksAmount != clientInfo.maxBooksAmount {
clientInfo.booksAmount += 1
clientBooks.append(ClientBook(book: book))
} else if !book.isSelected, let clientBookIndex = clientBooks.firstIndex(where: { $0.id == book.id }) {
clientInfo.booksAmount -= 1
clientBooks.remove(at: clientBookIndex)
}
adapter.performUpdates(animated: true)
}
Вместе с этим кодом мы получаем уже доступные две секции. При изменении количества книг будет обновляться первая секция.

Client Books Section
В этой секции будут отображаться книги, которые клиент взял из библиотеки. Секция должна горизонтально скроллиться, и при нажатии на кнопку в ячейке секции необходимо возвращать книгу библиотеке. Два интересных требования нужно закрыть. Первое — необходимо иметь возможность разместить emptyView в секции, если у клиента нет еще ни одной книги. Решение, которое я нашел, основывается на том, что в новом layout мы можем разместить supplementaryView, которая будет занимать всю секцию. Layout не подведет и, даже если в секции отсутствуют элементы, нарисует нашу view. Я создал отдельную секцию, которая наследуется от ClientBooksSection. Эта секция умеет возвращать supplementaryView для коллекции и отображает ее, когда у нее нет элементов. О том, что элементов нет, сообщает адаптер.
final class ClientBooksSection: CollectionSection<ClientBook, ClientBookCell> {
static private let emptyViewKind = "EmptyClientBookSectionView"
private var emptyView: EmptyClientBookSectionView?
override var isEmpty: Bool {
didSet {
emptyView?.contentView.isHidden = !isEmpty
}
}
override func registerCells(in collection: UICollectionView) {
super.registerCells(in: collection)
collection.register(UINib(nibName: ClientBooksSection.emptyViewKind, bundle: nil),
forSupplementaryViewOfKind: ClientBooksSection.emptyViewKind,
withReuseIdentifier: ClientBooksSection.emptyViewKind)
}
override func layout(environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(200))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.6),
heightDimension: .absolute(200))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
subitems: [item])
let emptyViewSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(150.0))
let left = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: emptyViewSize,
elementKind: ClientBooksSection.emptyViewKind,
alignment: .leading)
let section = NSCollectionLayoutSection(group: group)
section.boundarySupplementaryItems = [left]
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
return section
}
override func supplementaryView(kind: String, for item: AnyHashable?, at indexPath: IndexPath, in collection: UICollectionView) -> UICollectionReusableView? {
guard let emptyView = collection.dequeueReusableSupplementaryView(
ofKind: kind,
withReuseIdentifier: ClientBooksSection.emptyViewKind,
for: indexPath) as? EmptyClientBookSectionView else {
return nil
}
self.emptyView = emptyView
emptyView.contentView.isHidden = !isEmpty
return emptyView
}
}
Второе требование — это дополнительная конфигурация ячейки, которая понадобится, чтобы реагировать на нажатия внутри ячейки. Наш ViewController станет делегатом для нее.
let clientBookSection = ClientBooksSection(id: CollectionSections.clientBookSection.rawValue)
clientBookSection.cellConfiguration = { cell in
cell.delegate = self
}
extension ViewController: ClientBookCellDelegate {
func didTapReturn(book: ClientBook) {
guard let index = clientBooks.firstIndex(of: book) else {
return
}
clientInfo.booksAmount -= 1
clientBooks.remove(at: index)
if let bookIndex = books.firstIndex(where: { $0.id == book.id }) {
books[bookIndex].isSelected.toggle()
}
adapter.performUpdates(animated: true)
}
}

Итоги
Подводя черту под всеми тремя статьями, хочется сказать, что Apple сделала то, что уже давно должна была сделать. Компания создала инструменты, которые облегчают разработку списков. Это действительно хорошо, хоть и с опозданием. Как только вы перестанете поддерживать iOS 12, можно смело браться за рефакторинг всех существующих списков на новый манер :) С полным кодом проекта можно ознакомиться на GitHub.