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

Building an Expandable List Using UICollectionView: Part 2

In last week’s article, we learned how to leverage the NSDiffableDataSourceSectionSnapshot to create an expandable list in a single-section collection view. In this article, we will pick up where we left off by modifying last week’s sample app to display the same set of model objects in a multi-section collection view.

Following animated GIF showcases what we going to build in this article.

Building an Expandable List Using UICollectionView (Multiple sections)
The sample app

If you have not gone through my last week’s article — “Building an Expandable List Using UICollectionView: Part 1“, I strongly encourage you to check it out before proceeding.

That said, let’s get right into it!


Defining Required Data Types

The data types required for the sample app are basically the same as part 1. The only difference is that we no longer need the Section enum as the section identifier type. This is because the Section enum is only suitable for representing a collection view with a fixed number of sections.

The following are all the data types and model objects we need for the sample app:

// Section identifier type & 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)!
    }
}

// Item identifier type
enum ListItem: Hashable {
    case header(HeaderItem)
    case symbol(SFSymbolItem)
}

// The model objects to show
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"),
    ]),
    
]

Configuring Collection View & Performing Cell Registration

The code to configure the collection view and perform cell registration is exactly the same as part 1. Therefore, feel free to refer to part 1 if you need a more detailed explanation of the following code.

// MARK: Create list layout
let layoutConfig = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
let listLayout = UICollectionViewCompositionalLayout.list(using: layoutConfig)

// MARK: Configure collection view
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),
])

// MARK: Cell registration
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)]
}

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
}

Initializing Data Source

As mentioned earlier, we are no longer able to use the Section enum (defined in part 1) as the data source section identifier type. In order to represent a collection view with a dynamic number of sections, we must use the model objects data type (HeaderItem) as section identifier type.

By changing the section identifier type from Section to HeaderItem, means that we are changing the data type that represents a section in the collection view.

At this point, you might be wondering, does changing the section identifier type affect the cell provider closure? The answer is”No”.

This is because the main purpose of the cell provider closure is to dequeue a cell based on the item identifier type, it has nothing to do with the section identifier type. Since we are still using ListItem as the item identifier type, the logic to dequeue a cell should stay the same.

// Change section identifier type from "Section" to "HeaderItem"
dataSource = UICollectionViewDiffableDataSource<HeaderItem, ListItem>(collectionView: collectionView) {
    (collectionView, indexPath, listItem) -> UICollectionViewCell? in
    
    // Code here is exactly the same as part 1
    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
    }
}

Setting Up Snapshots

In order to display our model objects in a multi-section collection view, we must reconstruct the data source snapshot (NSDiffableDataSourceSnapshot) and section snapshot (NSDiffableDataSourceSectionSnapshot).

If you’re unfamiliar with the concept of snapshots, you can refer to the “Understanding Different Kind of Snapshots” section in part 1.

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

The structure of the snapshots for an expandable list in a multi-section collection view
Sample app snapshots structure

For the sake of comparison, here’s the snapshot structure for a single-section collection view.

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

As can be seen, we need to construct a data source snapshot with 3 collection view sections. Within each collection view section will contain a single-section section snapshot. In other words, we will need a total of 3 section snapshots for the entire collection view.

Let us first focus on constructing the data source snapshot. As explained earlier, we are using HeaderItem as the section identifier type, meaning 1 HeaderItem instance will represent 1 collection view section. Therefore, to construct a data source snapshot with 3 sections is fairly simple, just append modelObjects as sections will do.

var dataSourceSnapshot = NSDiffableDataSourceSnapshot<HeaderItem, ListItem>()

// Create collection view section based on number of HeaderItem in modelObjects
dataSourceSnapshot.appendSections(modelObjects)
dataSource.apply(dataSourceSnapshot)

After that, let’s construct the required section snapshots. Here’s how we do it:

// Loop through each header item so that we can create a section snapshot for each respective header item.
for headerItem in modelObjects {
    
    // 1
    // Create a section snapshot
    var sectionSnapshot = NSDiffableDataSourceSectionSnapshot<ListItem>()
    
    // 2
    // Create a header ListItem & append as parent
    let headerListItem = ListItem.header(headerItem)
    sectionSnapshot.append([headerListItem])
    
    // 3
    // Create an array of symbol ListItem & append as child of headerListItem
    let symbolListItemArray = headerItem.symbols.map { ListItem.symbol($0) }
    sectionSnapshot.append(symbolListItemArray, to: headerListItem)
    
    // 4
    // Expand this section by default
    sectionSnapshot.expand([headerListItem])
    
    // 5
    // Apply section snapshot to the respective collection view section
    dataSource.apply(sectionSnapshot, to: headerItem, animatingDifferences: false)
}

Let’s go through the above code in details:

  1. For each headerItem which representing a collection view section, create a section snapshot with ListItem as its item identifier type.
  2. Create a ListItem with headerItem as associated value and append it to the section snapshot. This will create a section in the section snapshot.
  3. Convert headerItem‘s symbols array into an array of ListItem and append it to the section represented by headerListItem.
  4. The section represented by headerListItem should be expanded by default.
  5. Apply the section snapshot to the collection view section represented by headerItem.

That’s about it! You have successfully created an expandable list using a multi-section collection view. Build and run your sample code to see it in action.


List with Expandable Sections

All the while we are focusing on making a cell (header cell) that expands / collapses when being tapped. But what if you want to have a list with expandable sections as shown below?

List with expandable section header
List with expandable section

Lucky for us, Apple has made achieving this kind of layout extremely easy. All we need to do is to set the collection view layout’s header mode to .firstItemInSection.

var layoutConfig = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
layoutConfig.headerMode = .firstItemInSection
let listLayout = UICollectionViewCompositionalLayout.list(using: layoutConfig)

By setting the header mode to .firstItemInSection, the first UICollectionViewListCell within a collection view section will automatically use a header appearance.


Wrapping Up

There are 3 takeaways from this article:

  1. Section identifier type (HeaderItem) is only used for collection view section representation. It has nothing to do with the data being shown on the collection view. It also has nothing to do with the expandable section.
  2. The expand/collapse behavior of a collection view is mainly controlled by the structure of an NSDiffableDataSourceSnapshot. It has nothing to do with the section identifier type (HeaderItem).
  3. To create a list with expandable sections, just set the collection view header mode to .firstItemInSection will do.

All this might seem a bit confusing at first. But once you understand the concept behind, building an expandable list is just a matter of constructing the required snapshots correctly.

You can find the full sample code of part 1 and part 2 on GitHub.


Do you find this article helpful? Let me know in the comment section below. You can also reach out to me at Twitter and subscribe to my monthly newsletter.

Thanks for reading. 👨🏻‍💻


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.