Luminary

Evadne Wu :: Human Interface & Technical Delivery Specialist
GitHubTwitter

Sticky Headers for UICollectionView using UICollectionViewFlowLayout

Summary: Describes a solution creating sticky headers for UICollectionView using UICollectionElementKindSectionHeader-kind elements that scrolls with the content, which works like the standard UITableView headers.


Congratulations if you’re using UICollectionView: you’re in a world of surprise and delight.

Default header and footer implementation in UICollectionViewFlowLayout scrolls everything in one go, so headers may go off-screen as you scroll. These rules keep them on-screen:

  • The header should be positioned so it can never go further up than one header height above the first cell in the section.
  • The header should be positioned so it can never go further down than one header header height above the lower bounds of the last cell in the section.
  • The header should be positioned so it usually stays around the top edge, referencing the content offset of the collection view.

The solution is to implement a UICollectionViewFlowLayout subclass and override two methods, -shouldInvalidateLayoutForBoundsChange: and -layoutAttributesForElementsInRect:.

When you set up the layout to be queried every time for a bounds change, it will be queried whenever the user scrolls, because scrolling in UIScrollView is implemented by changing the origin of the underlying bounds of the layer.

It is also very important to override -layoutAttributesForElementsInRect:, which serves two purposes. You must adjust layout attributes for each header element, and you need to manually insert attributes for missing headers because by default, UICollectionViewFlowLayout only emits attributes for headers that are in the bounds. (UICollectionView looks at all the attributes and maps them to existing or newly-created views.)

Here’s the code. Note that the zIndex is raised for headers, which is important because now they are supposed to overlap the cells in the same section. Without the zIndex configured, everything will be laid using the same zIndex and the front/back order will be undefined.

- (NSArray *) layoutAttributesForElementsInRect:(CGRect)rect {

    NSMutableArray *answer = [[super layoutAttributesForElementsInRect:rect] mutableCopy];
    UICollectionView * const cv = self.collectionView;
    CGPoint const contentOffset = cv.contentOffset;

    NSMutableIndexSet *missingSections = [NSMutableIndexSet indexSet];
    for (UICollectionViewLayoutAttributes *layoutAttributes in answer) {
        if (layoutAttributes.representedElementCategory == UICollectionElementCategoryCell) {
            [missingSections addIndex:layoutAttributes.indexPath.section];
        }
    }
    for (UICollectionViewLayoutAttributes *layoutAttributes in answer) {
        if ([layoutAttributes.representedElementKind isEqualToString:UICollectionElementKindSectionHeader]) {
            [missingSections removeIndex:layoutAttributes.indexPath.section];
        }
    }

    [missingSections enumerateIndexesUsingBlock:^(NSUInteger idx, BOOL *stop) {

        NSIndexPath *indexPath = [NSIndexPath indexPathForItem:0 inSection:idx];

        UICollectionViewLayoutAttributes *layoutAttributes = [self layoutAttributesForSupplementaryViewOfKind:UICollectionElementKindSectionHeader atIndexPath:indexPath];

        [answer addObject:layoutAttributes];

    }];

    for (UICollectionViewLayoutAttributes *layoutAttributes in answer) {

        if ([layoutAttributes.representedElementKind isEqualToString:UICollectionElementKindSectionHeader]) {

            NSInteger section = layoutAttributes.indexPath.section;
            NSInteger numberOfItemsInSection = [cv numberOfItemsInSection:section];

            NSIndexPath *firstCellIndexPath = [NSIndexPath indexPathForItem:0 inSection:section];
            NSIndexPath *lastCellIndexPath = [NSIndexPath indexPathForItem:MAX(0, (numberOfItemsInSection - 1)) inSection:section];

            UICollectionViewLayoutAttributes *firstCellAttrs = [self layoutAttributesForItemAtIndexPath:firstCellIndexPath];
            UICollectionViewLayoutAttributes *lastCellAttrs = [self layoutAttributesForItemAtIndexPath:lastCellIndexPath];

            CGFloat headerHeight = CGRectGetHeight(layoutAttributes.frame);
            CGPoint origin = layoutAttributes.frame.origin;
            origin.y = MIN(
                MAX(
                    contentOffset.y,
                    (CGRectGetMinY(firstCellAttrs.frame) - headerHeight)
                ),
                (CGRectGetMaxY(lastCellAttrs.frame) - headerHeight)
            );

            layoutAttributes.zIndex = 1024;
            layoutAttributes.frame = (CGRect){
                .origin = origin,
                .size = layoutAttributes.frame.size
            };

        }

    }

    return answer;

}

- (BOOL) shouldInvalidateLayoutForBoundsChange:(CGRect)newBound {

    return YES;

}