read

Since iOS 14, there are lots of “features” and changes to UICollectionView, making it possible to create a standard table/list UI. This WWDC 2020 session introduced the modern way of doing old things, with a huge sample code.

I will not be using all the “modern features”, specifically the decomposition of a cell to content configuration and background configuration is tedious.

The View Controller

We will use a ViewModel, which provides the models for the collection view. For this example, the section will be Continent while the items are Country.

A skeleton setup of the view controller is as such.

class ViewController: UIViewController {

    private let viewModel = ViewModel()

    override func viewDidLoad() {
        super.viewDidLoad()

        view.addSubview(collectionView)
        updateDataSource(animatingDifferences: false)
    }

    private func updateDataSource(animatingDifferences: Bool = true) {
        ...
    }

    private lazy var dataSource: UICollectionViewDiffableDataSource<Continent, Country> = {
        ...
    }()

    private lazy var collectionView: UICollectionView = {
        ...
    }()

}

We will look at each of the missing parts.

Data Source

With the new NSDiffableDataSourceSnapshot, the old ways of using the delegate pattern via UITableViewDataSource is no longer necessary.

While it is merely a change in the design pattern, it does enforce a diffable model, which makes animating the differences possible.

In our updateDataSource(), we simply update dataSource with a snapshot from our view model. Whenever the UI needs to “reload”, simply call it.

private func updateDataSource(animatingDifferences: Bool = true) {
    var snapshot = NSDiffableDataSourceSnapshot<Continent, Country>()

    /// Update via view model
    snapshot.appendSections(viewModel.continents)
    viewModel.continents.forEach {
        let countries = viewModel.countries(for: $0)
        snapshot.appendItems(countries, toSection: $0)
    }

    dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
}

UICollectionViewDiffableDataSource

The dataSource object is a UICollectionViewDiffableDataSource, which requires the the section and item models, in this example it is <Continent, Country>.

This is how we created it, along with the cell provider closure.

private lazy var dataSource: UICollectionViewDiffableDataSource<Continent, Country> = {
    /// 1. Cell provider to configure the cell with the indexPath/model
    let cellRegistration = UICollectionView.CellRegistration<CountryCell, Country> { cell, indexPath, country in
        cell.configure(with: country)
    }

    var datasource = UICollectionViewDiffableDataSource<Continent, Country>(collectionView: collectionView) { collectionView, indexPath, country in
        /// The dequeue happens here, using the cellRegistration (no more cell identifiers)
        return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: country)
    }

    /// 2. Header/Footer/Supplementary views provider
    /// Use a standard `UICollectionViewListCell` as the view
    let headerRegistration = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionHeader) { [unowned self] supplementaryView, elementKind, indexPath in
        guard let continent = self.dataSource.sectionIdentifier(for: indexPath.section) else { return }

        var config = supplementaryView.defaultContentConfiguration()
        config.text = continent.description
        supplementaryView.contentConfiguration = config
    }
    dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in
        return collectionView.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: indexPath)
    }

    return datasource
}()

All of these should be fairly similar, except we now pass these registration closures to the data source, and no more cell identifier!

For the cell, I have a custom CountryCell with a self-configuring method. If you use a UICollectionViewListCell, you will get the standard table cells style.

As for the header view, I am using the UICollectionViewListCell, which has a content configuration & view – the modern way is to configure.

The data source is initialized with the collection view.

Collection View

Lastly, the collection view has to be initialized with a list layout.

private lazy var collectionView: UICollectionView = {
    /// Use the configuration to define the background color, etc
    var layoutConfig = UICollectionLayoutListConfiguration(appearance: .grouped)
    layoutConfig.headerMode = .supplementary

    /// Other configs
    layoutConfig.showsSeparators = false
    layoutConfig.leadingSwipeActionsConfigurationProvider = { ... }

    /// This is the "list/table layout"
    var layout = UICollectionViewCompositionalLayout.list(using: layoutConfig)

    let collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
    collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
    collectionView.delegate = self
    return collectionView
}()

Again, the way is to use a list configuration object, and then create the compositional layout with it. You can configure various properties to do with the layout.

Yet, it is weird that certain configurations such as the leadingSwipeActionsConfigurationProvider is also part of the layout.

The headerMode is required for having a header. If you provide a header registration to the data source, you still must configure it in the layout.

Selecting an item

While UICollectionViewDataSource is gone, you still need the delegate for selecting an item etc.

extension ViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        /// Get the model from dataSource
        guard let country = dataSource.itemIdentifier(for: indexPath) else { return }
        print("Selected \(country.name)")
        collectionView.deselectItem(at: indexPath, animated: true)
    }
}

Hmm. A mix of patterns.


Image

@samwize

¯\_(ツ)_/¯

Back to Home