Python conf in Kharkiv, Nov 16 with Intel, Elastic engineering leaders. Prices go up 21.10

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

LinkedIn

5 комментариев

Подписаться на комментарииОтписаться от комментариев Комментарии могут оставлять только пользователи с подтвержденными аккаунтами.

Чому моргає(рефрешиться) весь collectionView при натисканні на один cell?

Потому что сразу в нескольких секциях изменении и выполняется с анимацией, этого всего можно избежать при желании, варианты есть

просто цікаво, для чого апдейетити цілу секцію в якій є чекбокси, так працює адаптер?

Да, я думаю что из-за того что апдейт происходит сразу в трех секция при выборе книги, он так реагирует, плюс не забываем об анимации, это важно, без анимации было бы без морганий полностью, тут как раз можно ее и отключать. Форкните проект на гите попробуйте разные варианты :)

Подписаться на комментарии