Как построить сложный UICollectionView, используя iOS 13

В первой и второй статьях я рассмотрел UICollectionViewCompositionalLayout и UICollectionViewDiffableDataSource — основные компоненты, которые используются при создании UICollectionView. Но это все теория. А как реализовать сложный layout, используя эти компоненты, я постараюсь объяснить в своей третьей статье. Мой подход не претендует на универсальность, но с его помощью удалось покрыть все придуманные мною требования.

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

  1. Информация о клиенте.
  2. Список взятых книг.
  3. Список книг, которые можно взять.

В итоге все будет выглядеть, как на скриншоте:

Перед тем как приступить к разработке, нужно решить одну проблему. Как видно из скриншота, в разных секциях присутствуют разные объекты и они по-разному размещаются. 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.

Похожие статьи:
14 травня відбудеться спільний мітап Go-розробників та DevOps-інженерів від SoftServe.Будемо говорити про введення в Go/CoreOS/Containers/k8s/, приклади...
Цього разу DOU Ревізор завітав до Lohika, міжнародної аутсорсингової компанії, серед клієнтів якої Skype, Airbnb, BuzzFeed та інші. Lohika було...
Мені здається, що про рефакторинг ми чуємо дуже часто, але кількість питань з цієї теми не зменшується, а навіть...
После обновления линейки Galaxy A, в компании Samsung занялись более бюджетными аппаратами Galaxy J. В обоих случаях разницы...
Оператор мобильной связи «Билайн» объявил о выпуске обновления для своего приложения «Мой Билайн»,...
Яндекс.Метрика