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.
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
.
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 typeNSMutableIndexSet
- 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.
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
.