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 SFSymbol
s being grouped based on category.
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.
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 aNSDiffableDataSourceSectionSnapshot
.
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.
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:
- 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. - Loop through each
HeaderItem
instance inmodelObjects
to construct a section snapshot that represents the same data hierarchy asmodelObjects
. - Create a
ListItem
withheaderItem
as associated value and append it to the section snapshot. This will create a section in the section snapshot. - Convert
headerItem
‘ssymbols
array into an array ofListItem
and append it to the section represented byheaderListItem
. - The section represented by
headerListItem
should be expanded by default. - 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:
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
- The Undocumented Facts of Diffable Data Source Section Snapshot
- UICollectionView List with Custom Cell and Custom Configuration
- Designing Custom UICollectionViewListCell in Interface Builder
- UICollectionView List with Interactive Custom Header
- Replicate the Expandable Date Picker Using UICollectionView List
👋🏻 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.