You are currently viewing Building an Expandable List Using UICollectionView: Part 1

Building an Expandable List Using UICollectionView: Part 1

Expandable list or some might call it expandable table view, is a very common UI design that is used in a large variety of apps. Despite the fact that it is so popular, UIKit does not have any native APIs that support this kind of layout.

With the introduction of iOS 14, this is no longer true!

According to Apple, developers can now easily create an expandable list by using the new UICollectionView list layout APIs. Unfortunately, after checking out the sample code provided by Apple, I found that it is extremely confusing and very difficult to understand.

Therefore, I would like to take a shot at writing an article that clearly explains how you can effectively build an expandable list using a UICollectionView.

This is the first part of a 2 parts blog post. In the first part, we will be focusing on building an expandable list within a single-section collection view. In the second part, we will attempt to modify the list we build in part 1 to display the same model objects in a multi-section collection view. By the end of part 2, you should be able to clearly understand the relationship between each component involved in creating an expandable list.

Before proceeding, make sure to go through my previous article “Building a List with UICollectionView in Swift” if you are not familiar with the basic concept of UICollectionView list.

With all the being said, let’s begin.


The Sample App

The following video showcases the sample app that we are going to build.

As shown, the collection view consists of 1 section. Within the section, there are header cells and symbol cells that are in charge of showing different SFSymbols being grouped based on category.

Header cell and symbol cell of a collection view expandable list
Header cell and symbol cell

Defining Required Data Types

Before we start building the expandable list, we must first define all the required data types.

Let’s start by defining the collection view section.

enum Section {
    case main
}

After that, let’s define the data type that will hold the header and symbol cell’s data.

// Header cell data type
struct HeaderItem: Hashable {
    let title: String
    let symbols: [SFSymbolItem]
}

// Symbol cell data type
struct SFSymbolItem: Hashable {
    let name: String
    let image: UIImage
    
    init(name: String) {
        self.name = name
        self.image = UIImage(systemName: name)!
    }
}

Next up, we will define an enum named ListItem. This enum type will act as the collection view’s data source item identifier type.

enum ListItem: Hashable {
    case header(HeaderItem)
    case symbol(SFSymbolItem)
}

At this point, you might wonder why we need to use the ListItem as the data source item identifier type. I will explain that in detail once we start configuring the collection view’s data source.

Lastly, let’s define the sample model objects that will be consumed by the collection view. Do note that in most cases, the model objects will be provided by a backend server in the form of JSON data.

let modelObjects = [
    
    HeaderItem(title: "Communication", symbols: [
        SFSymbolItem(name: "mic"),
        SFSymbolItem(name: "mic.fill"),
        SFSymbolItem(name: "message"),
        SFSymbolItem(name: "message.fill"),
    ]),
    
    HeaderItem(title: "Weather", symbols: [
        SFSymbolItem(name: "sun.min"),
        SFSymbolItem(name: "sun.min.fill"),
        SFSymbolItem(name: "sunset"),
        SFSymbolItem(name: "sunset.fill"),
    ]),
    
    HeaderItem(title: "Objects & Tools", symbols: [
        SFSymbolItem(name: "pencil"),
        SFSymbolItem(name: "pencil.circle"),
        SFSymbolItem(name: "highlighter"),
        SFSymbolItem(name: "pencil.and.outline"),
    ]),
    
]

With all the required data types in place, we are now ready to construct our expandable list.


Configuring Collection View

First thing first, let’s set up the collection view to take up the entire view controller’s view. As usual, we will use insetGrouped as our list appearance.

// Set layout to collection view
let layoutConfig = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
let listLayout = UICollectionViewCompositionalLayout.list(using: layoutConfig)
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: listLayout)
view.addSubview(collectionView)

// Make collection view take up the entire view
collectionView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
    collectionView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 0.0),
    collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0.0),
    collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0.0),
    collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 0.0),
])

Performing Cell Registration

As mentioned earlier, our expandable list consists of 2 types of cells — header cell and symbol cell.

Even though both of them will be of type UICollectionViewListCell, we still need to create 2 different CellRegistration instances. This is because both of them have different item identifier types and different behavior.

The header cell registration will have HeaderItem as it’s item identifier type. At the same time, within the cell registration handler, we must add an outline disclosure accessory to the header cell. This is to enable the expand / collapse behavior of the header cell.

let headerCellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, HeaderItem> {
    (cell, indexPath, headerItem) in
    
    // Set headerItem's data to cell
    var content = cell.defaultContentConfiguration()
    content.text = headerItem.title
    cell.contentConfiguration = content
    
    // Add outline disclosure accessory
    // With this accessory, the header cell's children will expand / collapse when the header cell is tapped.
    let headerDisclosureOption = UICellAccessory.OutlineDisclosureOptions(style: .header)
    cell.accessories = [.outlineDisclosure(options:headerDisclosureOption)]
}

On the other hand, the symbol cell registration will have SFSymbolItem as its item identifier type.

let symbolCellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, SFSymbolItem> {
    (cell, indexPath, symbolItem) in
    
    // Set symbolItem's data to cell
    var content = cell.defaultContentConfiguration()
    content.image = symbolItem.image
    content.text = symbolItem.name
    cell.contentConfiguration = content
}

With both header and symbol cell registration ready, we can proceed to configure the collection view data source.


Initializing Data Source

Still remember the ListItem enum that we defined earlier? This is where it comes into action.

As you may already know, the UICollectionViewDiffableDataSource only supports 1 item identifier type. However, in our expandable list, we have 2 types of data — HeaderItem and SFSymbolItem.

To solve this problem, we can leverage an enum with associated value to wrap both HeaderItem and SFSymbolItem into a single data type called ListItem. With that, we will be able to initialize the UICollectionViewDiffableDataSource using ListItem as item identifier type.

The fact that ListItem is an enum, we can now use a switch statement within the cell provider closure to check for all the possible data types and dequeue a collection view cell accordingly.

dataSource = UICollectionViewDiffableDataSource<Section, ListItem>(collectionView: collectionView) {
    (collectionView, indexPath, listItem) -> UICollectionViewCell? in
    
    switch listItem {
    case .header(let headerItem):
    
        // Dequeue header cell
        let cell = collectionView.dequeueConfiguredReusableCell(using: headerCellRegistration,
                                                                for: indexPath,
                                                                item: headerItem)
        return cell
    
    case .symbol(let symbolItem):
        
        // Dequeue symbol cell
        let cell = collectionView.dequeueConfiguredReusableCell(using: symbolCellRegistration,
                                                                for: indexPath,
                                                                item: symbolItem)
        return cell
    }
}

Do note that while dequeuing a reusable cell, you must always match the item’s data type with the respective cell registration item identifier type.

Matching item's data type with cell registration item identifier type
Matching item’s data type with cell registration item identifier type

Setting Up Snapshots

Understanding Different Kind of Snapshots

In order to display the model objects in the collection view in an expandable manner, we will have to make use of the NSDiffableDataSourceSnapshot and NSDiffableDataSourceSectionSnapshot.

NSDiffableDataSourceSnapshot was introduced in iOS 13. Generally speaking, a NSDiffableDataSourceSnapshot is used to provide data for collection views (or table views). We can use it to define the collection view sections, as well as the items within each collection view section.

On the other hand, NSDiffableDataSourceSectionSnapshot was introduced in this year’s WWDC alongside iOS 14. It is used to provide data for a specific collection view (or table view) section. It functions similarly to NSDiffableDataSourceSnapshot where we can use it to represent sections and items. Therefore, we can treat it as a mini NSDiffableDataSourceSnapshot for the collection view (or table view) section.

To summarise:

  • A collection view can only have one NSDiffableDataSourceSnapshot.
  • Within a collection view section can only have one NSDiffableDataSourceSectionSnapshot.
  • A collection view can have more than one NSDiffableDataSourceSectionSnapshot.
  • The NSDiffableDataSourceSectionSnapshot got nothing to do with the collection view section, it is used to define multi-section data within a collection view section. If you do not have multi-section data within a collection view section, then you do not need to create a NSDiffableDataSourceSectionSnapshot.

OK, enough with the theory. It’s time to construct the snapshots.

Constructing the Snapshots

For our sample app, we are trying to show multi-section data within a collection view section. Therefore, we will need to construct 1 NSDiffableDataSourceSnapshot and 1 NSDiffableDataSourceSectionSnapshot.

The following diagram illustrates the structure of the snapshots that we need to construct.

The structure of the snapshots for an expandable list in a single section collection view
Structure of the snapshots

Constructing the NSDiffableDataSourceSnapshot is fairly straightforward. Since the collection view only consists of 1 section, we just need to append the main section to the snapshot, and that’s about it.

var dataSourceSnapshot = NSDiffableDataSourceSnapshot<Section, ListItem>()

// Create a section in the data source snapshot
dataSourceSnapshot.appendSections([.main])
dataSource.apply(dataSourceSnapshot)

Do note that the data source snapshot and the data source we initialized earlier must have the same section identifier type and item identifier type.

Next up, let’s construct the NSDiffableDataSourceSectionSnapshot. Here’s how we do it:

// 1
// Create a section snapshot for main section
var sectionSnapshot = NSDiffableDataSourceSectionSnapshot<ListItem>()

// 2
for headerItem in modelObjects {
   
    // 3
    // Create a header ListItem & append as parent
    let headerListItem = ListItem.header(headerItem)
    sectionSnapshot.append([headerListItem])
    
    // 4
    // Create an array of symbol ListItem & append as children of headerListItem
    let symbolListItemArray = headerItem.symbols.map { ListItem.symbol($0) }
    sectionSnapshot.append(symbolListItemArray, to: headerListItem)
    
    // 5
    // Expand this section by default
    sectionSnapshot.expand([headerListItem])
}

// 6
// Apply section snapshot to main section
dataSource.apply(sectionSnapshot, to: .main, animatingDifferences: false)

Let’s go through the above code in details:

  1. Create a section snapshot with ListItem as its item identifier type. Note that the section snapshot item identifier type must match with the data source item identifier type.
  2. Loop through each HeaderItem instance in modelObjects to construct a section snapshot that represents the same data hierarchy as modelObjects.
  3. Create a ListItem with headerItem as associated value and append it to the section snapshot. This will create a section in the section snapshot.
  4. Convert headerItem‘s symbols array into an array of ListItem and append it to the section represented by headerListItem.
  5. The section represented by headerListItem should be expanded by default.
  6. Display the data in the section snapshot by applying the section snapshot to the data source’s main section.

So there you have it! Build and run the sample code to see everything in action.

You can get the full sample code here.


Wrapping Up

This concludes the first part of “Building an Expandable List Using UICollectionView“. As you may have noticed, I purposely create the expandable list in a single-section collection view so that you will not mix up the collection view section and the NSDiffableDataSourceSectionSnapshot section.

In the second part, we will modify the code we have written so far to create an expandable list using a multi-section collection view. Here’s a sneak peek of what we trying to build in part 2:

Building an Expandable List Using UICollectionView
Part 2 sample app

Stay tuned!

If you like this article, feel free to follow me on Twitter, and subscribe to my monthly newsletter.

Thanks for reading. 👨🏻‍💻

[Updated: 22 September 2020]

You can find part 2 of this article here:

🔗 Building an Expandable List Using UICollectionView: Part 2


Further Readings


👋🏻 Hey!

While you’re still here, why not check out some of my favorite Mac tools on Setapp? They will definitely help improve your day-to-day productivity. Additionally, doing so will also help support my work.

  • Bartender: Superpower your menu bar and take full control over your menu bar items.
  • CleanShot X: The best screen capture app I’ve ever used.
  • PixelSnap: Measure on-screen elements with ease and precision.
  • iStat Menus: Track CPU, GPU, sensors, and more, all in one convenient tool.