Modern Performant Collection Views With Swift

An Introduction to Compositional Collection View Layouts

Modern Performant Collection Views With Swift
1 An Introduction to Compositional Collection View Layouts 16:22
2 Building Adaptive User Interfaces With Compositional Layouts Plus 09:09
3 Adding Supplementary Views With Compositional Layouts Plus 07:51

Building modern user interfaces can be challenging if you solely rely on traditional Cocoa APIs. Apple is aware of this limitation and introduced a number of brand new APIs to build modern, performant user interfaces.

This series introduces you to several APIs that transform a traditional user interface into a modern, performant user interface. The APIs we focus on include compositional collection view layouts, diffable data sources, and Combine. We use the Cocoacasts client as an example to illustrate how to use these APIs in a real world application.

Collection Views

The UICollectionView class is powerful and flexible. It is the class that drives many complex user interfaces, including Apple's App Store. The UICollectionViewLayout class is at the core of that power and flexibility. The problem is that more advanced collection view layouts require you to subclass UICollectionViewLayout or UICollectionViewFlowLayout. Subclassing these classes isn't trivial, especially if this is new to you.

Cocoacasts Client

We take the Cocoacasts client as an example in this series. The current implementation shows the user the Feed tab and the Library tab. The Feed tab displays a collection view listing the most recent episodes.

The Feed Tab of the Cocoacasts Client

The Library tab is more complex because it contains several sections, the user's recently watched episodes, the most watched episodes, and an overview of the collections and categories on the Cocoacasts website.

The Library Tab of the Cocoacasts Client

The user interface is acceptable but far from ideal. There are a number of problems we need to address. The collection view of the Feed tab has several layout issues and it misses a title at the top. The user interface looks the same on iPhone and iPad. It doesn't take advantage of the screen real estate of larger devices, such as iPad.

The Library tab suffers from the same issues and it lacks structure. The sections miss section headers and the user interface could be more appealing. We are looking for a user interface that resembles that of Apple's App Store.

Compositional Layouts, Diffable Data Sources, and Combine

Apple introduced several significant improvements to its frameworks in the past few years and it acknowledged the importance of modern programming patterns, such as reactive programming. Combine and Swift UI are Apple's answer to the growing complexity of modern software development.

The plan of this series is to show you how to build beautiful and performant user interfaces using the latest and greatest of what Apple's frameworks have to offer. This series focuses on three modern APIs, (1) compositional layouts, (2) diffable data sources, and (3) Combine. These three APIs are at the leading edge of what Apple has to offer and provide the tools to build amazing user interfaces that look and feel fantastic on any device.

Compositional Layouts

It is easy to become overwhelmed if compositional layouts, diffable data sources, and Combine are new to you. We apply these technologies step by step. This stepped approach also shows you that each of these technologies can be applied separately. We start by improving the user interface of the Feed and Library tabs by adopting compositional layouts. This is a recent addition that drastically improves the possibilities of collection view layouts without the need for subclassing UICollectionViewLayout or UICollectionViewFlowLayout. The API is intuitive and easy to use.

Introducing UICollectionViewCompositionalLayout

We start by refactoring the user interface of the Feed tab. Open Feed.storyboard and select the collection view of the feed view controller. The user interface of the collection view of the feed view controller is driven by a UICollectionViewFlowLayout instance. When you add a collection view to a view, Interface Builder automatically adds a flow layout to the collection view. Using a flow layout to drive the user interface of a collection view is therefore a very common scenario. The UICollectionViewFlowLayout class handles most of the heavy lifting for you as long as the user interface is relatively simple.

Collection View Flow Layout in Interface Builder

Let's explore the implementation of the FeedViewController class in FeedViewController.swift. The feed view controller is the delegate of its collection view and it also implements the UICollectionViewDelegateFlowLayout protocol. The UICollectionViewDelegateFlowLayout protocol extends the UICollectionViewDelegate protocol and defines the layout of its collection view. The implementation isn't complex and that is part of the problem. The FeedViewController class defines the size of the items of the collection view without taking the form factor of the device into account.

extension FeedViewController: UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        return CGSize(width: collectionView.bounds.width, height: 200.0)
    }

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
        return 20.0
    }

}

The UICollectionViewFlowLayout class is powerful and flexible, but the implementation becomes complex rather quickly. In this series, we replace the UICollectionViewFlowLayout class with the brand new UICollectionViewCompositionalLayout class. Like the UICollectionViewFlowLayout class, it inherits from the UICollectionViewLayout class. The key difference is that the UICollectionViewCompositionalLayout class doesn't need to be subclassed to create advanced layouts. That sounds promising. Let's get started.

Laying the Foundation

Because the FeedViewController class no longer needs to conform to the UICollectionViewDelegateFlowLayout protocol, we can remove the methods of the UICollectionViewDelegateFlowLayout protocol.

extension FeedViewController: UICollectionViewDelegate {}

In the didSet property observer of the collectionView property, we need to create an instance of the UICollectionViewCompositionalLayout class and assign it to the collection view's collectionViewLayout property. We create the UICollectionViewCompositionalLayout instance in a helper method with name createCollectionViewLayout().

@IBOutlet var collectionView: UICollectionView! {
    didSet {
        // Configure Collection View
        collectionView.delegate = self
        collectionView.dataSource = self

        // Create Collection View Layout
        collectionView.collectionViewLayout = createCollectionViewLayout()

        // Register Episode Collection View Cell
        let xib = UINib(nibName: EpisodeCollectionViewCell.nibName, bundle: .main)
        collectionView.register(xib, forCellWithReuseIdentifier: EpisodeCollectionViewCell.reuseIdentifier)
    }
}

We spend the rest of this episode in the createCollectionViewLayout() method. That's where the magic happens. But before we implement this helper method, you need to become familiar with items, groups, and sections, the three building blocks of compositional layouts. Items, groups, and sections are at the heart of modern collection views driven by compositional layouts.

// MARK: -

private func createCollectionViewLayout() -> UICollectionViewLayout {

}

Items, Groups, and Sections

It is essential that you understand what items, groups, and sections are and how they relate to one another. Every collection view driven by the UICollectionViewCompositionalLayout class makes use of items, groups, and sections.

Items and sections are not new. A collection view displays one or more items. These items are the cells of the collection view.

A collection view displays one or more items.

Items are grouped into sections and a collection view has one or more sections. This isn't new either.

Items are grouped into sections.

Groups are new and an integral part of compositional layouts. How do they fit in? A group is a collection or cluster of items. You could say that groups are what make compositional layouts powerful. Even though they may seem redundant, groups are the basic unit of layout in compositional layouts. Let's continue implementing the feed view controller to better understand how items, groups, and sections play together.

A group is a collection or cluster of items.

Creating a Compositional Layout

We need to create an instance of the UICollectionViewCompositionalLayout class in the createCollectionViewLayout() method. The UICollectionViewCompositionalLayout class offers two options, we start with the simplest option first.

To create a UICollectionViewCompositionalLayout instance, we need to pass the initializer an instance of the NSCollectionLayoutSection class. Notice the NS prefix of the class. The core classes of compositional layouts are available on iOS, tvOS, and macOS. The classes to create a collection view compositional layout are specific to each platform. We use the UICollectionViewCompositionalLayout class on iOS and tvOS and the NSCollectionViewCompositionalLayout class on macOS.

We need to pass an NSCollectionLayoutSection instance to the initializer of the UICollectionViewCompositionalLayout class. How do we create an NSCollectionLayoutSection instance? Remember that a section contains one or more groups and a group contains one or more items. This means we need to start by creating an item, an instance of the NSCollectionLayoutItem class.

The initializer of the NSCollectionLayoutItem class accepts an instance of the NSCollectionLayoutSize class as its only argument. The NSCollectionLayoutSize class is another key component of compositional layouts because it changes how you define the size of items and groups. Developers are no longer required to perform complex calculations to define the size of an item. The initializer of the NSCollectionLayoutSize class is simple. It accepts two NSCollectionLayoutDimension instances, one for the width of the item and one for the height of the item.

There are several new types you need to become familiar with and that can be a bit overwhelming. These types are here to help you, though. Once you understand how each of these types fits into the bigger picture, it starts to make sense. Each of these types has a specific purpose, making it easier than ever to define the layout of a collection view.

Take the NSCollectionLayoutDimension class as an example. This class defines four type methods to make it trivial to define the size of, in this example, an item. The most obvious type method is absolute(_:). It accepts a CGFloat object as its only argument to define the absolute with or height of an item or group.

If you're not sure what the width or height of an item or group is going to be, then you invoke the estimated(_:) type method of the NSCollectionLayoutDimension class. As the name suggests, the estimated(_:) type method returns an NSCollectionLayoutDimension instance that holds an estimate of the width or height of the item or group. The width or height of the item or group changes as the layout collects more information and defines the final width or height.

The last two type methods are quite powerful, fractionalWidth(_:) and fractionalHeight(_:). As their names imply, you can define the width or height of the item or group relative to the container of the item or group. Don't worry if this sounds confusing. Let's use these classes to define the size of the items of the collection view.

We need to invoke the initializer of the NSCollectionLayoutSize class. The initializer accepts an NSCollectionLayoutDimension instance for the with of the item and an NSCollectionLayoutDimension instance for the height of the item. Let's start simple. We want the items of the collection view to span the width of the collection view. To accomplish that, we invoke the fractionalWidth(_:) type method of the NSCollectionLayoutDimension class, passing in 1.0 as the argument. This means the item is as wide as its container. Don't worry about the word container for now.

How tall should the items be? The episode collection view cells the feed view controller displays contain text and we don't know what the height of each item is going to be. For that reason, we invoke the estimated(_:) type method of the NSCollectionLayoutDimension class, passing in 200.0 as the argument. The compositional layout adjusts the height of the item as the item is rendered.

private func createCollectionViewLayout() -> UICollectionViewLayout {
    // Define Item Size
    let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(200.0))
}

We use the NSCollectionLayoutSize instance to create an instance of the NSCollectionLayoutItem class.

private func createCollectionViewLayout() -> UICollectionViewLayout {
    // Define Item Size
    let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(200.0))

    // Create Item
    let item = NSCollectionLayoutItem(layoutSize: itemSize)
}

If you're working with a collection view compositional layout, then items are always contained in a group. The next step is defining the size of the group and using that size to create an instance of the NSCollectionLayoutGroup class.

We keep the layout simple for now. The group of items should also span the width of the collection view and, since we don't know the size of the items at compile time, we estimate the height of the group.

private func createCollectionViewLayout() -> UICollectionViewLayout {
    // Define Item Size
    let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(200.0))

    // Create Item
    let item = NSCollectionLayoutItem(layoutSize: itemSize)

    // Define Group Size
    let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(200.0))
}

We have several options to create an instance of the NSCollectionLayoutGroup class. We first need to define the layout of the group. You can think of a group as a nested flow layout. The flow layout can be horizontal, vertical, or custom. In this example, a group is a horizontal line of items.

We invoke the horizontal(layoutSize:subitems:) type method of the NSCollectionLayoutGroup class. The first argument of the horizontal(layoutSize:subitems:) type method is the NSCollectionLayoutSize instance we created a moment ago. The second argument is an array of the NSCollectionLayoutItem instances the group contains. We wrap the NSCollectionLayoutItem instance in an array and pass it as the second argument of the horizontal(layoutSize:subitems:) method.

private func createCollectionViewLayout() -> UICollectionViewLayout {
    // Define Item Size
    let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(200.0))

    // Create Item
    let item = NSCollectionLayoutItem(layoutSize: itemSize)

    // Define Group Size
    let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(200.0))

    // Create Group
    let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [ item ])
}

We are almost ready to create the UICollectionViewCompositionalLayout instance. To invoke the init(section:) initializer of the UICollectionViewCompositionalLayout class, we need to define an NSCollectionLayoutSection instance. This is simple. The initializer of the NSCollectionLayoutSection class accepts a single argument, an NSCollectionLayoutGroup instance. We pass the NSCollectionLayoutSection instance to the initializer of the UICollectionViewCompositionalLayout class and return the result from the createCollectionViewLayout() method.

private func createCollectionViewLayout() -> UICollectionViewLayout {
    // Define Item Size
    let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(200.0))

    // Create Item
    let item = NSCollectionLayoutItem(layoutSize: itemSize)

    // Define Group Size
    let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(200.0))

    // Create Group
    let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [ item ])

    // Create Section
    let section = NSCollectionLayoutSection(group: group)

    return UICollectionViewCompositionalLayout(section: section)
}

This may seem like a lot of code for a simple layout, but I hope you agree that the code we wrote isn't complex. It is descriptive and easy to understand. This pattern keeps coming back when working with collection view compositional layouts. Every compositional layout has items, groups, and sections. This is a repeating pattern and that is what makes the API consistent and robust.

Build and Run

It's time to see what layout we end up with. Build and run the application to see the result. This looks quite nice considering the number of lines of the createCollectionViewLayout() method. It's important to understand that the collection view is no longer driven by a UICollectionViewFlowLayout instance. In the didSet property observer of the collectionView property, we replaced the flow layout with a compositional layout.

Collection View Compositional Layout

There's one improvement I want to make. The collection view could use a bit of leading and trailing space. This is easy to add. Revisit the createCollectionViewLayout() method. We set the contentInsets property of the NSCollectionLayoutSection instance. Despite the name of the property, contentInsets is of type NSDirectionalEdgeInsets. Notice that the initializer accepts a value for the leading and trailing edges, not for the left and right edges. A compositional layout automatically adjusts its layout for right-to-left languages.

private func createCollectionViewLayout() -> UICollectionViewLayout {
    // Define Item Size
    let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(200.0))

    // Create Item
    let item = NSCollectionLayoutItem(layoutSize: itemSize)

    // Define Group Size
    let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(200.0))

    // Create Group
    let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [ item ])

    // Create Section
    let section = NSCollectionLayoutSection(group: group)

    // Configure Section
    section.contentInsets = NSDirectionalEdgeInsets(top: 0.0, leading: 20.0, bottom: 0.0, trailing: 20.0)

    return UICollectionViewCompositionalLayout(section: section)
}

Build and run the application one more time to see the result. This looks better.

Adding Leading and Trailing Space

What's Next?

There is one more issue we need to address. Let's build and run the application on a device with a larger screen. The feed view controller shows a single column of items on iPad, both in portrait and in landscape. This is fine, but it isn't a great use of screen real estate.

Collection View Compositional Layout

Compositional layouts make solving this problem straightforward. In the next episode, we modify the compositional layout to dynamically update itself as the size of the collection view changes. The goal is to show two columns on iPad in portrait and three columns on iPad in landscape. The layout should also update if the user manually changes the size of the collection view.

Next Episode "Building Adaptive User Interfaces With Compositional Layouts"