You are currently viewing UICollectionView List with Interactive Custom Header

UICollectionView List with Interactive Custom Header

In my previous article, we had discussed how to add a declarative header and footer to the UICollectionView List. This week, let’s take one step further and talk about how you can add a custom header to your collection view declaratively.

This is a continuation of my previous article “Declarative UICollectionView List Header and Footer“, therefore do make sure to check it out before proceeding.


The Sample App

UICollectionView List with Interactive Custom Header
The Sample App

To keep things as simple as possible, we will create a simple custom header that consists of a title label and an info button. When tap on the info button, the app will display an alert showing detailed information about that particular section.


Creating the Custom Header

First things first, let’s create the custom header by subclassing the UICollectionReusableView.

class InteractiveHeader: UICollectionReusableView {
    
    let titleLabel = UILabel()
    let infoButton = UIButton()
    
    // Callback closure to handle info button tap
    var infoButtonDidTappedCallback: (() -> Void)?

    override init(frame: CGRect) {
        super.init(frame: frame)
        configure()
    }
    
    required init?(coder: NSCoder) {
        fatalError("Not implemented")
    }
}

Do note that the init(frame:)method is compulsory when it comes to creating a declarative custom header. Without it, the collection view will not be able to dequeue it as a reusable supplementary view.

Also note that we have defined the infoButtonDidTappedCallback closure to handle the info button tap event. We will get into that in a bit.

The configure() method that we call within the init(frame:)method will take care of laying out all the custom header UI elements. Here’s the implementation:

extension InteractiveHeader {
    
    func configure() {
        
        // Add a stack view to section container
        let stackView = UIStackView()
        stackView.axis = .horizontal
        stackView.distribution = .fill
        addSubview(stackView)
        stackView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            stackView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
            stackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
            stackView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
            stackView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor),
        ])

        // Setup label and add to stack view
        titleLabel.font = UIFont.preferredFont(forTextStyle: .headline)
        stackView.addArrangedSubview(titleLabel)

        // Set button image
        let largeConfig = UIImage.SymbolConfiguration(scale: .large)
        let infoImage = UIImage(systemName: "info.circle.fill", withConfiguration: largeConfig)?.withTintColor(.systemPink, renderingMode: .alwaysOriginal)
        infoButton.setImage(infoImage, for: .normal)
        
        // Set button action
        infoButton.addAction(UIAction(handler: { [unowned self] (_) in
            // Trigger callback when button tapped
            self.infoButtonDidTappedCallback?()
        }), for: .touchUpInside)
        
        // Add button to stack view
        stackView.addArrangedSubview(infoButton)
        
    }
}

As can be seen from the above code, we are using a UIStackView to layout both the label and the button. The following diagram illustrates how the auto layout constraints are being configured from within the custom header.

Collection view custom header auto layout constraints
Custom header auto layout constraints configuration

Also, note that we are using the UIButton closure-based API (introduced in iOS 14) to set up the infoButton‘s touchUpInside event. When the button is being tapped, we will trigger the infoButtonDidTappedCallback to send the tap event back to the view controller.


Displaying the Custom Header

In order to display the custom header, we must first set the collection view layout header mode to .supplementary.

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

After that, create a supplementary registration object using the InteractiveHeader class and make sure to set its element kind to UICollectionView.elementKindSectionHeader.

let headerRegistration = UICollectionView.SupplementaryRegistration
<InteractiveHeader>(elementKind: UICollectionView.elementKindSectionHeader) {
    [unowned self] (headerView, elementKind, indexPath) in

    // 1
    // Obtain header item using index path
    let headerItem = self.dataSource.snapshot().sectionIdentifiers[indexPath.section]
    
    // 2
    headerView.titleLabel.text = headerItem.title
    
    // 3
    headerView.infoButtonDidTappedCallback = { [unowned self] in

        // Show an alert when user tap on infoButton
        let symbolCount = headerItem.symbols.count
        let alert = UIAlertController(title: "Info", message: "This section has \(symbolCount) symbols.", preferredStyle: .alert)
        let okAction = UIAlertAction(title: "OK", style: .default, handler: nil)
        alert.addAction(okAction)
        
        self.present(alert, animated: true)
    }
}

Within the supplementary view handler, we:

  1. Obtain headerItem for that particular section from the collection view’s data source.
  2. Set the section title to the custom header’s titleLabel.
  3. Implement the custom header’s infoButtonDidTappedCallback. For simplicity’s sake, we will display a simple alert when the info button is tapped.

Lastly, we must define the data source’s supplementary view provider.

dataSource.supplementaryViewProvider = { [unowned self]
    (collectionView, elementKind, indexPath) -> UICollectionReusableView? in
    
    // Dequeue header view
    return self.collectionView.dequeueConfiguredReusableSupplementary(
        using: headerRegistration, for: indexPath)
}

Do note how we dequeue a reusable supplementary view using the headerRegistration object we created not long ago.

With that, the custom header is now ready to be shown on the collection view. Build and run your sample app to see everything in action.


Adjusting the Custom Header’s Height

At this stage, you should see that our custom header’s height is a little bit too small, causing the title label and info button too close to the section.

Collection view custom header's height too small
Header’s height too small

To go about this, you must first understand that a collection view header is self-sizing, meaning UIKit will calculate the header view size based on its content.

With that in mind, we can easily adjust the header height by adding a top and bottom padding to the custom header’s stack view.

Collection view custom header adjust header's height
Custom header auto layout constraints with top and bottom padding

Now head back to the InteractiveHeader class and update the configure() method.

// Adjust top anchor constant & priority
let topAnchor = stackView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor, constant: 15)
topAnchor.priority = UILayoutPriority(999)

// Adjust bottom anchor constant & priority
let bottomAnchor = stackView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor, constant: -10)
bottomAnchor.priority = UILayoutPriority(999)

NSLayoutConstraint.activate([
    stackView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
    stackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
    topAnchor,
    bottomAnchor,
])

As seen from the above code, we can increase the stack view’s top and bottom padding by adjusting the stack view’s top and bottom anchor constant.

On top of that, we also change the top and bottom anchor priority to 999 in order to avoid the unsatisfiable constraints error. To learn more about this, you can refer to the “Dealing with Cell Height” section of “Designing Custom UICollectionViewListCell in Interface Builder“.

Now run you sample code again to see the header’s height updated to what we want.


Designing with Interface Builder

As mentioned earlier, the init(frame:)method is compulsory when implementing the custom header class, which also means that the awakeFromNib() method won’t be called during initialization. Because of that, we will not be able to use the traditional interface builder way to design the custom header.

The workaround for this is actually quite simple. Take our InteractiveHeader as an example, within its configure() method, instead of constructing the header’s layout programmatically, just load a custom UIView that is connected to a Xib file into the header’s content view.

I have covered the core concept of how you can use the interface builder to design a UIView in my previous article “Designing Custom UICollectionViewListCell in Interface Builder“. Thus I will leave this as an exercise for you!

Hint: The SFSymbolNameContentView‘s loadNib() method is a good starting point.


Wrapping Up

Even though this article only covers how to create a custom UICollectionView list header, you can basically apply the exact same technique to create your own custom footer.

You can find the full sample code of this article on Github.

If you like this article, feel free to follow me on 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.