Daily coverage of WWDC20.
A Swift by Sundell spin-off.

Using UICollectionView to build lists on iOS 14

Published at 14:40 GMT, 26 Jun 2020
Written by: John Sundell

Ever since UICollectionView was first introduced in iOS 6, it has occasionally been tricky to decide whether to use a collection view or a table view to build a given list-based UI.

Up until this point, however, UITableView did have some quite substantial advantages over its collection view sibling in terms of list-specific features — since table views come with things like easy reordering, deletion and swipe actions, as well as system-provided styling — all built-in. But this year, UICollectionView has gained a remarkable number of new features when it comes to building lists, and is now arguably the better option in the vast majority of situations.

Let’s take a first look at some of those new APIs, and how we can get started using them by building a simple collection view-powered list.

Setting things up

Let’s say that we’re working on a view controller for rendering a list of contacts, which has a view model that contains its data, as well as a UICollectionView and a UICollectionViewDataSource for rendering that data. Within our viewDidLoad method, we then connect our data source to our collection view (both of which are lazily created in order to avoid non-optional optionals) and tell our view controller to update its list — like this:

class ContactListViewController: UIViewController {
    private let viewModel = ContactListViewModel()
    private lazy var collectionView = makeCollectionView()
    private lazy var dataSource = makeDataSource()

    override func viewDidLoad() {
        super.viewDidLoad()

        collectionView.dataSource = dataSource
        view.addSubview(collectionView)
        updateList()
    }
    
    ...
}

Next, let’s go ahead and implement all of the methods that we call above, starting with makeCollectionView.

Configurations and registrations

Last year, at WWDC 2019, Apple introduced the new compositional layout API for collection views, which — like the name implies — enables various kinds of layouts to be composed from smaller building blocks.

New this year is that such layouts can now be easily created (with two lines of code!) for common list variants, such as plain, grouped and insetGrouped — which in turn enables us to quickly configure a list-like collection view like this:

private extension ContactListViewController {
    func makeCollectionView() -> UICollectionView {
        let config = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
        let layout = UICollectionViewCompositionalLayout.list(using: config)
        return UICollectionView(frame: .zero, collectionViewLayout: layout)
    }
}

Another thing that’s new this year is the CellRegistration API, and cells can now also be configured in a much more declarative way using content configurations.

Cell registrations provide a new, much more type-safe way to tell UIKit what UICollectionViewCell subclass to use to render a given cell, and using a cell’s defaultContentConfiguration, we can bind our data to it without having to be aware of its exact subviews.

Here’s how we could combine those two new APIs to create a CellRegistration that uses the new UICollectionViewListCell to render our contact list’s various cells:

private extension ContactListViewController {
    func makeCellRegistration() -> UICollectionView.CellRegistration<UICollectionViewListCell, Contact> {
        UICollectionView.CellRegistration { cell, indexPath, contact in
            // Configuring each cell's content:
            var config = cell.defaultContentConfiguration()
            config.text = contact.name
            config.secondaryText = contact.email
            cell.contentConfiguration = config

            // Configuring a trailing swipe action for deleting a contact:
            cell.trailingSwipeActionsConfiguration = UISwipeActionsConfiguration(
                actions: [UIContextualAction(
                    style: .destructive,
                    title: "Delete",
                    handler: { [weak self] _, _, completion in
                        self?.deleteContact(withID: contact.id)
                        self?.updateList()
                        completion(true)
                    }
                )]
            )

            // Showing a disclosure indicator as the cell's accessory:
            cell.accessories = [.disclosureIndicator()]
        }
    }
}

Note how we pass the given contact’s id into our deleteContact method when our swipe action was triggered, rather than using its IndexPath. Apple recommends never using index paths as identifiers, as they’re not guaranteed to be stable as list items get inserted and removed.

Apart from increased type safety, a major benefit of the above new API is that we can perform all of our cell configuration, as well as our swipe action event handling, within a single closure. That in turn means that we have to keep track of fewer pieces of state ourselves, which should lead to an overall simpler implementation in most cases.

Constructing and updating a diffable data source

Now that we’ve created our collection view and have all of our cell configuration code in place, let’s now construct our list’s data source. To do that, we’ll use the built-in diffable data source API that was introduced last year, in combination with the CellRegistration instance that we built above, like this:

private extension ContactListViewController {
    func makeDataSource() -> UICollectionViewDiffableDataSource<Section, Contact> {
        let cellRegistration = makeCellRegistration()

        return UICollectionViewDiffableDataSource<Section, Contact>(
            collectionView: collectionView,
            cellProvider: { view, indexPath, item in
                view.dequeueConfiguredReusableCell(
                    using: cellRegistration,
                    for: indexPath,
                    item: item
                )
            }
        )
    }
}

Finally, it’s time to populate the above data source with our contact list data. To do that, we’ll first create a Section enum to describe our list’s two sections, and we’ll then insert the data from our viewModel into those two sections using the NSDiffableDataSourceSnapshot API:

enum Section: CaseIterable {
    case favorites
    case all
}

private extension ContactListViewController {
    func updateList() {
        var snapshot = NSDiffableDataSourceSnapshot<Section, Contact>()
        snapshot.appendSections(Section.allCases)
        snapshot.appendItems(viewModel.favorites, toSection: .favorites)
        snapshot.appendItems(viewModel.all, toSection: .all)
        dataSource.apply(snapshot)
    }
}

With the above in place, we’ve now built a simple grouped contact list containing two sections, with support for swipe actions and disclosure indicators — all using UICollectionView.

Conclusion

The cool thing is that not only is UICollectionView now more or less on par with UITableView in terms of list rendering — it has even surpassed it, since the new outlines that are now heavily used within sidebars on iPadOS are only available through collection views.

So while there still might be edge cases that only UITableView covers, I think it’s fair to say that UICollectionView is now the preferred way of building lists on iOS (at least once we’re able to use iOS 14 as an app’s minimum deployment target).

For more information, I recommend checking out the following WWDC session videos:

Thanks for reading! 🚀

Written by: John Sundell