You are currently viewing Replicate the Expandable Date Picker Using UICollectionView List

Replicate the Expandable Date Picker Using UICollectionView List

The expandable date picker is a very common UI component in the Apple design system. You can find it in the iOS Reminder and Calendar App. Even though it does not come out of the box with UIKit, we can easily create one using a custom cell within a table view.

Reminder App’s date picker in iOS 13
Reminder App’s date picker in iOS 13

With the introduction of compositional list layout for UICollectionView, we can basically replicate most of the table view behavior using a collection view, including the expandable date picker. In this article, let’s explore what it takes to replicate this commonly used UI component using a collection view.

The following animated GIF showcases what we are trying to build throughout this article.

Replicate the Expandable Date Picker Using UICollectionView List
The sample app

Note:

Learn the basics of compositional list layout and diffable data source in this article — “Building a List with UICollectionView in Swift“.


The Data Item Type

First thing first, we must define a data item type to hold the data required for our collection view. As usual, we will be using enum, let’s name it DatePickerItem.

enum DatePickerItem: Hashable {
    case header(Date)
    case picker(Date)
}

Note that the Date type associated value for both header and picker cases will represent the date being shown within the header and date picker cell.


Getting Ready the Custom Cell

Let’s start by creating a custom UICollectionViewListCell that holds a date picker. I have covered in detail the concept of how to create a custom UICollectionViewListCell in one of my previous articles. If you have not read it, here’s a quick recap on the topic.

A Quick Recap

In order to create a custom cell for UICollectionView list, we need 3 major components:

  1. Custom cell (a subclass of UICollectionViewListCell)
  2. Content configuration (a subclass of UIContentConfiguration)
  3. Content view (a subclass of UIContentView)

The custom cell in charge of creating a content configuration object. The generated content configuration object will then make a content view that displays the data in the content configuration object.

All these are much easier to understand by using the diagram as shown below:

UICollectionView list custom cell concept overview
UICollectionView list custom cell concept

The Implementation

As always, I won’t go into too much detail on how the implementation works as it has been covered in my previous article. Feel free to check that out if you would like to know more.

With all that said, let’s start by creating a custom cell named DatePickerCell.

class DatePickerCell: UICollectionViewListCell {
    
    var item: DatePickerItem?
    
    override func updateConfiguration(using state: UICellConfigurationState) {
        
        // Create new configuration object and update it base on state
        var newConfiguration = DatePickerContentConfiguration().updated(for: state)
        
        // Update any configuration parameters related to data item
        newConfiguration.item = item

        // Set content configuration in order to update custom content view
        contentConfiguration = newConfiguration
        
    }
}

Next up is the content configuration, we will name it DatePickerContentConfiguration.

struct DatePickerContentConfiguration: UIContentConfiguration, Hashable {

    var item: DatePickerItem?
    
    func makeContentView() -> UIView & UIContentView {
        // Initialize an instance of DatePickerContentView
        return DatePickerContentView(configuration: self)
    }
    
    func updated(for state: UIConfigurationState) -> Self {
        return self
    }
}

Lastly, let’s create the content view named DatePickerContentView that contains a UIDatePicker.

class DatePickerContentView: UIView, UIContentView {

    // ...
    // ...
    
    init(configuration: DatePickerContentConfiguration) {
        super.init(frame: .zero)
        
        // Create the content view UI
        setupAllViews()
        
        // Apply the configuration (set data to UI elements / define custom content view appearance)
        apply(configuration: configuration)
    }
    
    // ...
}

Within the setupAllViews() method, we only need to do 1 thing — add a wheels style date picker into the content view.

private func setupAllViews() {
    
    datePicker.preferredDatePickerStyle = .wheels
    
    addSubview(datePicker)
    datePicker.translatesAutoresizingMaskIntoConstraints = false
    NSLayoutConstraint.activate([
        datePicker.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
        datePicker.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
        datePicker.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
        datePicker.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor),
    ])
}

The last part of the puzzle is to implement the apply(configuration:) method. Here is where we will set the date picker’s date based on the content configuration object.

private func apply(configuration: DatePickerContentConfiguration) {

    // Only apply configuration if new configuration and current configuration are not the same
    guard currentConfiguration != configuration else {
        return
    }
    
    // Replace current configuration with new configuration
    currentConfiguration = configuration
    
    // Set date picker's date
    if case let DatePickerItem.picker(date) = configuration.item! {
        datePicker.date = date
    }
}

Setting Up the Collection View

With the custom cell in place, we can now apply the concept presented in “Building an Expandable List Using UICollectionView: Part 1” to construct a collection view with 2 types of cells — Header cell and picker cell.

Cell Registration and Data Source

As you might have guessed, we will need to create 2 types of cell registrations. The first one is the headerCellRegistration.

let headerCellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, DatePickerItem> { [unowned self]
    (cell, indexPath, item) in
    
    // Extract date from DatePickerItem
    if case let DatePickerItem.header(date) = item {
        
        // Show date on cell
        var content = cell.defaultContentConfiguration()
        content.text = dateFormatter.string(from: date)

        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)]
}

The code above is pretty much self-explanatory. However, do note that you must set the cell accessory to .outlineDisclosure so that the header cell will expand/collapse when being tapped.

Next, let’s look at pickerCellRegistration.

let pickerCellRegistration = UICollectionView.CellRegistration<DatePickerCell, DatePickerItem> { (cell, indexPath, item) in
    
    // Set `DatePickerItem` to date picker cell
    cell.item = item
}

If you have implemented the custom cell correctly, setting a DatePickerItem to the cell is all you need to do. The cell will automatically create a content configuration that generates a content view with a date picker.

With that, we can now set up the data source accordingly.

dataSource = UICollectionViewDiffableDataSource<Section, DatePickerItem>(collectionView: collectionView) {
    (collectionView, indexPath, datePickerItem) -> UICollectionViewCell? in
    
    switch datePickerItem {
    case .header(_):
        
        // Dequeue header cell
        let cell = collectionView.dequeueConfiguredReusableCell(using: headerCellRegistration,
                                                                for: indexPath,
                                                                item: datePickerItem)
        return cell
        
    case .picker(_):
        
        // Dequeue symbol cell
        let cell = collectionView.dequeueConfiguredReusableCell(using: pickerCellRegistration,
                                                                for: indexPath,
                                                                item: datePickerItem)
        return cell
    }
}

Constructing the Data Source Snapshot

This is the most crucial part that defines the structure of our collection view list. Here’s a diagram that illustrates the snapshot structure that we need to construct.

Section snapshot structure for expandable date picker in collection view list
The section snapshot structure

By referring to the above diagram, we can construct the data source snapshot like this:

var dataSourceSnapshot = NSDiffableDataSourceSnapshot<Section, DatePickerItem>()

// Define collection view section
dataSourceSnapshot.appendSections([.main])
dataSource.apply(dataSourceSnapshot)

// Create a section snapshot
var sectionSnapshot = NSDiffableDataSourceSectionSnapshot<DatePickerItem>()

// We will show current date in date picker & header cell
let now = Date()

// Define header and set it as parent
let header = DatePickerItem.header(now)
sectionSnapshot.append([header])

// Define picker and set it as child of `header`
let picker = DatePickerItem.picker(now)
sectionSnapshot.append([picker], to: header)

// Expand this section by default
sectionSnapshot.expand([header])

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

At this stage, if you run the code, you should be able to see a single section collection view with a header and picker cell. When you tap on the header cell, the picker cell will expand/collapse accordingly. Cool!

But wait… If you try to scroll the date picker, you will notice that the date in the header cell is not being updated. How can we go about solving this problem?


Updating the Header Cell

This is where things get interesting. In order to reflect the date picker changes in the header cell, we need to:

  1. Observe the date picker value changed event.
  2. Reload the header cell.

Observe the Date Picker Value Changed Event

In iOS 14, Apple added a addAction(_:for:) method to all UIControl. Using this method, developers can create a UIAction object that defines certain behaviour, and then assigns it to a UIControl for a specific event.

This is a perfect fit for our case! We can first create an action object in the view controller, and then pass the action object to the picker cell and assign it to the date picker.

Let’s start by updating the DatePickerItem enum.

enum DatePickerItem: Hashable {
    case header(Date)
    case picker(Date, UIAction)
}

Notice that I have added an associated value of type UIAction to the enum case picker. We will use it to hold the UIAction object we create later on.

Due to the new action associated value, we have introduced a new warning within the cell provider closure, let’s fix that before we proceed any further.

dataSource = UICollectionViewDiffableDataSource<Section, DatePickerItem>(collectionView: collectionView) {
    (collectionView, indexPath, datePickerItem) -> UICollectionViewCell? in
    
    // ...
    // ...
        
    case .picker(_, _):
        
    // ...
    // ...
    
}

Lastly, head over to the line where you instantiate the DatePickerItem.picker enum and pass in a UIAction object.

let action = UIAction(handler: { [unowned self] (action) in
    
    // Make sure sender is a date picker
    guard let picker = action.sender as? UIDatePicker else {
        return
    }
    
    // Reload header cell with date picker's date
    reloadHeader(with: picker.date)
})
let picker = DatePickerItem.picker(now, action)
sectionSnapshot.append([picker], to: header)

Note that we are calling a reloadHeader(with:) function within the action handler. We will get back to that later. For now, let’s just implement it with a print statement.

private func reloadHeader(with date: Date) {
    print(date)
}

That’s all the changes for the view controller.

Now, head over to the DatePickerContentView class and update the apply(configuration:) function so that it will assign the action object to the date picker’s value changed event.

private func apply(configuration: DatePickerContentConfiguration) {

    // Only apply configuration if new configuration and current configuration are not the same
    guard currentConfiguration != configuration else {
        return
    }
    
    // Replace current configuration with new configuration
    currentConfiguration = configuration
    
    if case let DatePickerItem.picker(date, action) = configuration.item! {
        // Set date picker's date
        datePicker.date = date
        
        // Set date picker action (value changed)
        datePicker.addAction(action, for: .valueChanged)
    }
}

That’s about it, run the code and try changing the date picker’s date. You should see the date picker’s date being printed in the Xcode debug console.

Reload the Header Cell

This is the last part of the puzzle where we need to update the date displayed in the header cell based on the date picker date. We will apply the concept you learned from my previous article “The Modern Ways to Reload Your Table and Collection View Cells” but with a bit of twist.

Since the DatePickerItem enum is of value type, we need to replace the header item in the section snapshot with a new header item. However, if we do that, we will break the entire parent-child relationship between the header item and date picker item, this is because deleting the header item (parent) will delete the date picker item (child) altogether.

To go about this situation, we will have to reconstruct the section snapshot structure after we replaced the header item. Here’s how:

private func reloadHeader(with date: Date) {

    let sectionSnapshot = dataSource.snapshot(for: .main)
    
    // Obtain reference to the header item and date picker item
    guard
        let oldHeaderItem = sectionSnapshot.rootItems.first,
        let datePickerItem = sectionSnapshot.snapshot(of: oldHeaderItem).items.first else {
        return
    }
    
    // Create new header item with new date
    let newHeaderItem = DatePickerItem.header(date)

    // Create a new copy of section snapshot for modification
    var newSectionSnapshot = sectionSnapshot

    // Replace the header item (by insert new item and then delete old item)
    newSectionSnapshot.insert([newHeaderItem], before: oldHeaderItem)
    
    // Important: Delete `oldHeaderItem` must come before append `datePickerItem` to avoid 'NSInternalInconsistencyException' with reason: 'Supplied identifiers are not unique.'
    newSectionSnapshot.delete([oldHeaderItem])
    
    // Reconstruct section snapshot by appending `datePickerItem` to `newHeaderItem`
    newSectionSnapshot.append([datePickerItem], to: newHeaderItem)
    
    // Expand the section
    newSectionSnapshot.expand([newHeaderItem])
    
    // Apply new section snapshot to `main` section
    dataSource.apply(newSectionSnapshot, to: .main, animatingDifferences: false)
    
}

One aspect to be noted here is that we must first delete the oldHeaderItem before appending the datePickerItem to avoid ‘NSInternalInconsistencyException‘ with reason: ‘Supplied identifiers are not unique.’.

This is because all items within a section snapshot must be unique, and the child of the oldHeaderItem is the datePickerItem, which is the same datePickerItem that we are going to append.

There you have it, you can now run the code and see everything comes together!

Here’s the full sample code on GitHub.


Further Readings


Wrapping Up

I hope you will find this article helpful. If you like this article, feel free to follow me on Twitter, and subscribe to my monthly newsletter so that you won’t miss any new articles published on this site.

Thanks for reading. 👨🏻‍💻


👋🏻 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.