Как построить сложный 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.