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 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 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.
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.
Items are grouped into sections and a collection view has one or more sections. This isn't new either.
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.
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.
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.
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.
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.