Even though UICollectionView is incredibly flexible and versatile, trivial things are sometimes difficult to accomplish. UITableView, on the other hand, has more configuration options, but it is harder to customize. Sticky section headers, for example, are built into table views. Adding them to a collection view requires a bit of extra work.

If your project targets iOS 9 or higher, then you are in luck. Since iOS 9, the UICollectionViewFlowLayout class has two properties, sectionHeadersPinToVisibleBounds and sectionFootersPinToVisibleBounds, that make it very easy to make section headers and section footers sticky. It is no coincidence that these properties are members of the UICollectionViewFlowLayout class. It is UICollectionViewFlowLayout we need to subclass to add sticky section headers.

Project Setup

To get us started, I created a sample application that you can clone or download from GitHub. If you build and run the application, you notice that the collection view mimics a table view. The section headers are given a random color to make it easier to see the effect of the sticky section headers.

This is a collection view mimicking a table view.

Subclassing UICollectionViewFlowLayout

The star player of this tutorial is the UICollectionViewFlowLayout class. To implement sticky section headers, we need to create a UICollectionViewFlowLayout subclass. Create a new class, name it StickyHeadersCollectionViewFlowLayout, and make sure it inherits from UICollectionViewFlowLayout.

Create a UICollectionViewFlowLayout subclass.

We need to override three methods of the UICollectionViewFlowLayout class:

  • shouldInvalidateLayout(forBoundsChange:)
  • layoutAttributesForElements(in:)
  • layoutAttributesForSupplementaryView(ofKind:at:)

Overriding shouldInvalidateLayout(forBoundsChange:) is easy. Whenever the bounds of the collection view change, we need to invalidate the layout of the collection view.

override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
    return true
}

The other two methods we need to override are related to the layout attributes of the elements of the collection view.

Layout Attributes for Elements

The implementation of layoutAttributesForElements(in:) is not difficult, but there is a caveat we need to watch out for. This is what the implementation looks like.

override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
    guard let layoutAttributes = super.layoutAttributesForElements(in: rect) else { return nil }

    // Helpers
    let sectionsToAdd = NSMutableIndexSet()
    var newLayoutAttributes = [UICollectionViewLayoutAttributes]()

    for layoutAttributesSet in layoutAttributes {
        if layoutAttributesSet.representedElementCategory == .cell {
            // Add Layout Attributes
            newLayoutAttributes.append(layoutAttributesSet)

            // Update Sections to Add
            sectionsToAdd.add(layoutAttributesSet.indexPath.section)

        } else if layoutAttributesSet.representedElementCategory == .supplementaryView {
            // Update Sections to Add
            sectionsToAdd.add(layoutAttributesSet.indexPath.section)
        }
    }

    for section in sectionsToAdd {
        let indexPath = IndexPath(item: 0, section: section)

        if let sectionAttributes = self.layoutAttributesForSupplementaryView(ofKind: UICollectionElementKindSectionHeader, at: indexPath) {
            newLayoutAttributes.append(sectionAttributes)
        }
    }

    return newLayoutAttributes
}

We are not going to reinvent the wheel. We extend and customize the functionality of the UICollectionViewFlowLayout class. That is why we start by asking the superclass for the layout attributes of the elements in the given rectangle. That is the starting point.

guard let layoutAttributes = super.layoutAttributesForElements(in: rect) else { return nil }

Next, we create two helpers:

  • a constant, sectionsToAdd, of type NSMutableIndexSet
  • a variable, newLayoutAttributes, of type [UICollectionViewLayoutAttributes]

As the name implies, we use newLayoutAttributes to store the new layout attributes of the elements in. The sectionsToAdd constant stores the sections for which we need to add the layout attributes. Why that is becomes clear in a moment.

// Helpers
let sectionsToAdd = NSMutableIndexSet()
var newLayoutAttributes = [UICollectionViewLayoutAttributes]()

We loop over layoutAttributes and add the layout attributes of the elements of type .cell to newLayoutAttributes. For every element of type .cell, we ask it what section it belongs to. We add the index of the section to sectionsToAdd. For elements of type .supplementaryView, we ask it for its section and add that to sectionsToAdd.

for layoutAttributesSet in layoutAttributes {
    if layoutAttributesSet.representedElementCategory == .cell {
        // Add Layout Attributes
        newLayoutAttributes.append(layoutAttributesSet)

        // Update Sections to Add
        sectionsToAdd.add(layoutAttributesSet.indexPath.section)

    } else if layoutAttributesSet.representedElementCategory == .supplementaryView {
        // Update Sections to Add
        sectionsToAdd.add(layoutAttributesSet.indexPath.section)
    }
}

For every index of sectionsToAdd, we create an index path and ask the flow layout for the layout attributes of the corresponding supplementary view. Note that we are only taking section headers into account. If the flow layout returns layout attributes for the section header, we add it to newLayoutAttributes. Finally, we return newLayoutAttributes.

for section in sectionsToAdd {
    let indexPath = IndexPath(item: 0, section: section)

    if let sectionAttributes = self.layoutAttributesForSupplementaryView(ofKind: UICollectionElementKindSectionHeader, at: indexPath) {
        newLayoutAttributes.append(sectionAttributes)
    }
}

Why do we jump through these hoops? Why is it necessary to modify the layout attributes in layoutAttributesForElements(in:)? Remember that we extend the functionality of the UICollectionViewFlowLayout class. If we don't modify the layout attributes in layoutAttributesForElements(in:) the sticky section headers would disappear the moment they move off-screen. In other words, we need to investigate which section headers should be visible, calculate their layout attributes, and add them to newLayoutAttributes.

Layout Attributes for Supplementary Views

The layoutAttributesForSupplementaryView(ofKind:at:) method contains the logic for making the headers sticky. The idea underlying sticky section headers is simple. The section header always sticks to the top of the visible area of the section. This logic is reflected in the implementation of layoutAttributesForSupplementaryView(ofKind:at:).

override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
    guard let layoutAttributes = super.layoutAttributesForSupplementaryView(ofKind: elementKind, at: indexPath) else { return nil }
    guard let boundaries = boundaries(forSection: indexPath.section) else { return layoutAttributes }
    guard let collectionView = collectionView else { return layoutAttributes }

    // Helpers
    let contentOffsetY = collectionView.contentOffset.y
    var frameForSupplementaryView = layoutAttributes.frame

    let minimum = boundaries.minimum - frameForSupplementaryView.height
    let maximum = boundaries.maximum - frameForSupplementaryView.height

    if contentOffsetY < minimum {
        frameForSupplementaryView.origin.y = minimum
    } else if contentOffsetY > maximum {
        frameForSupplementaryView.origin.y = maximum
    } else {
        frameForSupplementaryView.origin.y = contentOffsetY
    }

    layoutAttributes.frame = frameForSupplementaryView

    return layoutAttributes
}

The first and third guard statements don't need an explanation. The second guard statement does. We ask the flow layout for the boundaries of the section that corresponds with the index path of the supplementary view. We use a helper method, boundaries(forSection:), to calculate the boundaries. Before we continue, we need to take a look at its implementation.

func boundaries(forSection section: Int) -> (minimum: CGFloat, maximum: CGFloat)? {
    // Helpers
    var result = (minimum: CGFloat(0.0), maximum: CGFloat(0.0))

    // Exit Early
    guard let collectionView = collectionView else { return result }

    // Fetch Number of Items for Section
    let numberOfItems = collectionView.numberOfItems(inSection: section)

    // Exit Early
    guard numberOfItems > 0 else { return result }

    if let firstItem = layoutAttributesForItem(at: IndexPath(item: 0, section: section)),
       let lastItem = layoutAttributesForItem(at: IndexPath(item: (numberOfItems - 1), section: section)) {
        result.minimum = firstItem.frame.minY
        result.maximum = lastItem.frame.maxY

        // Take Header Size Into Account
        result.minimum -= headerReferenceSize.height
        result.maximum -= headerReferenceSize.height

        // Take Section Inset Into Account
        result.minimum -= sectionInset.top
        result.maximum += (sectionInset.top + sectionInset.bottom)
    }

    return result
}

The first thing to note is the return value, a tuple of type (CGFloat, CGFloat). The implementation starts with the creation of a variable, the result of the method.

// Helpers
var result = (minimum: CGFloat(0.0), maximum: CGFloat(0.0))

The first guard statement doesn't need an explanation. If the collectionView property of the flow layout is nil, we immediately return the result.

// Exit Early
guard let collectionView = collectionView else { return result }

We ask the collection view for the number of items in the section. If the number of items is equal to zero, we immediately return the result variable.

// Fetch Number of Items for Section
let numberOfItems = collectionView.numberOfItems(inSection: section)

// Exit Early
guard numberOfItems > 0 else { return result }

The remainder of the method is easier than it looks. We ask the flow layout for the layout attributes of the first and the last item of the section. We use the frame of the layout attributes to update the result's minimum and maximum values.

if let firstItem = layoutAttributesForItem(at: IndexPath(item: 0, section: section)),
   let lastItem = layoutAttributesForItem(at: IndexPath(item: (numberOfItems - 1), section: section)) {
    result.minimum = firstItem.frame.minY
    result.maximum = lastItem.frame.maxY

    ...
}

We also need to take the height of the section header into account. We ask the flow layout for the reference size of the header and update the result's minimum and maximum values accordingly.

// Take Header Size Into Account
result.minimum -= headerReferenceSize.height
result.maximum -= headerReferenceSize.height

We also take the section inset into account as you can see below.

// Take Section Inset Into Account
result.minimum -= sectionInset.top
result.maximum += (sectionInset.top + sectionInset.bottom)

With boundaries(forSection:) implemented, we can continue implementing layoutAttributesForSupplementaryView(ofKind:at:).

We create two helpers:

  • the contentOffsetY constant stores the vertical content offset of the collection view
  • the frameForSupplementaryView variable stores the frame of the supplementary view
// Helpers
let contentOffsetY = collectionView.contentOffset.y
var frameForSupplementaryView = layoutAttributes.frame

Next, we calculate the minimum and maximum vertical position of the section header.

let minimum = boundaries.minimum - frameForSupplementaryView.height
let maximum = boundaries.maximum - frameForSupplementaryView.height

We then update the frame of the supplementary view depending on the vertical content offset of the collection view. We have three possible scenarios:

  • If the vertical content offset is smaller than the minimum vertical position of the section header, we position the section header at its minimum vertical position.
  • If the vertical content offset is greater than the maximum vertical position of the section header, we position the section header at its maximum vertical position.
  • If none of the above scenarios is true, it means the vertical content offset falls within the boundaries of the section. This means we need to position the section header at the top of the collection view, which corresponds with the current vertical content offset.
if contentOffsetY < minimum {
    frameForSupplementaryView.origin.y = minimum
} else if contentOffsetY > maximum {
    frameForSupplementaryView.origin.y = maximum
} else {
    frameForSupplementaryView.origin.y = contentOffsetY
}

We update the frame of the layout attributes with the updated frame of the section header and return the result.

And that's it. If you break everything down, you realize that adding support for sticky section headers isn't rocket science. Build and run the application to give it a try.

This is a collection view with sticky section headers.

Limitations

The implementation of StickyHeadersCollectionViewFlowLayout has a few limitations, though. The solution we implemented only supports vertically scrolling collection views. Adding support for both scroll directions isn't too difficult, though. I leave that up to you to implement.

As the name implies, the StickyHeadersCollectionViewFlowLayout class only supports sticky section headers. Section footers are not sticky.

Advantages of a Custom Implementation

Even if you are developing an application that targets iOS 9 or higher, it is interesting to implement sticky section headers yourself instead of relying on Apple's implementation. Last week, for example, I needed a collection view that hid the first section header and gradually faded the section header in the moment the user started scrolling the collection view. You can only accomplish that behavior by subclassing UICollectionViewFlowLayout.

Even though table views are incredibly useful, Apple has learned that they are sometimes too limited. That is why the UICollectionView class, which was introduced several years later, takes a different approach. Simple layouts are easy and complex layouts are possible. That is the idea behind UICollectionView and UICollectionViewLayout.