UICollectionView with a sticky header

47,516

Solution 1

Fix by Todd Laney to handle Horizontal and Vertical scrolling and to take into account the sectionInsets:

https://gist.github.com/evadne/4544569

@implementation StickyHeaderFlowLayout

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

    NSMutableArray *answer = [[super layoutAttributesForElementsInRect:rect] mutableCopy];

    NSMutableIndexSet *missingSections = [NSMutableIndexSet indexSet];
    for (NSUInteger idx=0; idx<[answer count]; idx++) {
        UICollectionViewLayoutAttributes *layoutAttributes = answer[idx];

        if (layoutAttributes.representedElementCategory == UICollectionElementCategoryCell) {
            [missingSections addIndex:layoutAttributes.indexPath.section];  // remember that we need to layout header for this section
        }
        if ([layoutAttributes.representedElementKind isEqualToString:UICollectionElementKindSectionHeader]) {
            [answer removeObjectAtIndex:idx];  // remove layout of header done by our super, we will do it right later
            idx--;
        }
    }

    // layout all headers needed for the rect using self code
    [missingSections enumerateIndexesUsingBlock:^(NSUInteger idx, BOOL *stop) {
        NSIndexPath *indexPath = [NSIndexPath indexPathForItem:0 inSection:idx];
        UICollectionViewLayoutAttributes *layoutAttributes = [self layoutAttributesForSupplementaryViewOfKind:UICollectionElementKindSectionHeader atIndexPath:indexPath];
        if (layoutAttributes != nil) {
            [answer addObject:layoutAttributes];
        }
    }];

    return answer;
}

- (UICollectionViewLayoutAttributes *)layoutAttributesForSupplementaryViewOfKind:(NSString *)kind atIndexPath:(NSIndexPath *)indexPath {
    UICollectionViewLayoutAttributes *attributes = [super layoutAttributesForSupplementaryViewOfKind:kind atIndexPath:indexPath];
    if ([kind isEqualToString:UICollectionElementKindSectionHeader]) {
        UICollectionView * const cv = self.collectionView;
        CGPoint const contentOffset = cv.contentOffset;
        CGPoint nextHeaderOrigin = CGPointMake(INFINITY, INFINITY);

        if (indexPath.section+1 < [cv numberOfSections]) {
            UICollectionViewLayoutAttributes *nextHeaderAttributes = [super layoutAttributesForSupplementaryViewOfKind:kind atIndexPath:[NSIndexPath indexPathForItem:0 inSection:indexPath.section+1]];
            nextHeaderOrigin = nextHeaderAttributes.frame.origin;
        }

        CGRect frame = attributes.frame;
        if (self.scrollDirection == UICollectionViewScrollDirectionVertical) {
            frame.origin.y = MIN(MAX(contentOffset.y, frame.origin.y), nextHeaderOrigin.y - CGRectGetHeight(frame));
        }
        else { // UICollectionViewScrollDirectionHorizontal
            frame.origin.x = MIN(MAX(contentOffset.x, frame.origin.x), nextHeaderOrigin.x - CGRectGetWidth(frame));
        }
        attributes.zIndex = 1024;
        attributes.frame = frame;
    }
    return attributes;
}

- (UICollectionViewLayoutAttributes *)initialLayoutAttributesForAppearingSupplementaryElementOfKind:(NSString *)kind atIndexPath:(NSIndexPath *)indexPath {
    UICollectionViewLayoutAttributes *attributes = [self layoutAttributesForSupplementaryViewOfKind:kind atIndexPath:indexPath];
    return attributes;
}
- (UICollectionViewLayoutAttributes *)finalLayoutAttributesForDisappearingSupplementaryElementOfKind:(NSString *)kind atIndexPath:(NSIndexPath *)indexPath {
    UICollectionViewLayoutAttributes *attributes = [self layoutAttributesForSupplementaryViewOfKind:kind atIndexPath:indexPath];
    return attributes;
}

- (BOOL) shouldInvalidateLayoutForBoundsChange:(CGRect)newBound {
    return YES;
}

@end

Solution 2

Simplest solution for iOS 9 + as it doesn't need of writing subclass of UICollectionViewFlowLayout.

In viewDidLoad of viewController with collectionView use following code:

let layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout // casting is required because UICollectionViewLayout doesn't offer header pin. Its feature of UICollectionViewFlowLayout
layout?.sectionHeadersPinToVisibleBounds = true

Solution 3

This is really a good solution and works perfectly. However, since we have to return YES from shouldINvalidateLayoutForBoundsChange, this basically calls prepareLayout every time the view scrolls. Now, IF your prepareLayout has the responsibility of creating the layout attributes, which is quite common, this will immensely affect the scroll performance.

One solution, which worked for me, is to not create the layout attributes in prepareLayout but instead do it in a separate method which you call explicitly before calling invalidateLayout. UICollectionView calls prepareLayout as and when it feels it needs to know about the layout and hence this solution will take care of those cases also.

Solution 4

You just need to create a new UICollectionViewFlowLayout with this code:

class StickyHeaderLayout: UICollectionViewFlowLayout {

    override init() {
        super.init()
        self.sectionFootersPinToVisibleBounds = true
        self.sectionHeadersPinToVisibleBounds = true
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        self.sectionFootersPinToVisibleBounds = true
        self.sectionHeadersPinToVisibleBounds = true
    }

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

        for attribute in attributes {
            adjustAttributesIfNeeded(attribute)
        }
        return attributes
    }

    override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        guard let attributes = super.layoutAttributesForSupplementaryView(ofKind: elementKind, at: indexPath) else { return nil }
        adjustAttributesIfNeeded(attributes)
        return attributes
    }

    func adjustAttributesIfNeeded(_ attributes: UICollectionViewLayoutAttributes) {
        switch attributes.representedElementKind {
        case UICollectionElementKindSectionHeader?:
            adjustHeaderAttributesIfNeeded(attributes)
        case UICollectionElementKindSectionFooter?:
            adjustFooterAttributesIfNeeded(attributes)
        default:
            break
        }
    }

    private func adjustHeaderAttributesIfNeeded(_ attributes: UICollectionViewLayoutAttributes) {
        guard let collectionView = collectionView else { return }
        guard attributes.indexPath.section == 0 else { return }

        if collectionView.contentOffset.y < 0 {
            attributes.frame.origin.y = collectionView.contentOffset.y
        }
    }

    private func adjustFooterAttributesIfNeeded(_ attributes: UICollectionViewLayoutAttributes) {
        guard let collectionView = collectionView else { return }
        guard attributes.indexPath.section == collectionView.numberOfSections - 1 else { return }

        if collectionView.contentOffset.y + collectionView.bounds.size.height > collectionView.contentSize.height {
            attributes.frame.origin.y = collectionView.contentOffset.y + collectionView.bounds.size.height - attributes.frame.size.height
        }
    }

}

Solution 5

This code works for me

    -(NSArray *)layoutAttributesForElementsInRect:(CGRect)rect {
        NSMutableArray *answer = [[super layoutAttributesForElementsInRect:rect] mutableCopy];
        UICollectionView * const cv = self.collectionView;
        //CLS_LOG(@"Number of sections = %d", [cv numberOfSections]);
        CGPoint const contentOffset = cv.contentOffset;

    //CLS_LOG(@"Adding missing sections");
    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];

    }];

    NSInteger numberOfSections = [cv numberOfSections];

    //CLS_LOG(@"For loop");
    for (UICollectionViewLayoutAttributes *layoutAttributes in answer) {

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

            NSInteger section = layoutAttributes.indexPath.section;
            //CLS_LOG(@"Customizing layout attribute for header in section %d with number of items = %d", section, [cv numberOfItemsInSection:section]);

            if (section < numberOfSections) {
                NSInteger numberOfItemsInSection = [cv numberOfItemsInSection:section];

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

                BOOL cellsExist;
                UICollectionViewLayoutAttributes *firstObjectAttrs;
                UICollectionViewLayoutAttributes *lastObjectAttrs;

                if (numberOfItemsInSection > 0) { // use cell data if items exist
                    cellsExist = YES;
                    firstObjectAttrs = [self layoutAttributesForItemAtIndexPath:firstObjectIndexPath];
                    lastObjectAttrs = [self layoutAttributesForItemAtIndexPath:lastObjectIndexPath];
                } else { // else use the header and footer
                    cellsExist = NO;
                    firstObjectAttrs = [self layoutAttributesForSupplementaryViewOfKind:UICollectionElementKindSectionHeader
                                                                            atIndexPath:firstObjectIndexPath];
                    lastObjectAttrs = [self layoutAttributesForSupplementaryViewOfKind:UICollectionElementKindSectionFooter
                                                                           atIndexPath:lastObjectIndexPath];

                }

                CGFloat topHeaderHeight = (cellsExist) ? CGRectGetHeight(layoutAttributes.frame) : 0;
                CGFloat bottomHeaderHeight = CGRectGetHeight(layoutAttributes.frame);
                CGRect frameWithEdgeInsets = UIEdgeInsetsInsetRect(layoutAttributes.frame,
                                                                   cv.contentInset);

                CGPoint origin = frameWithEdgeInsets.origin;

                origin.y = MIN(
                               MAX(
                                   contentOffset.y + cv.contentInset.top,
                                   (CGRectGetMinY(firstObjectAttrs.frame) - topHeaderHeight)
                                   ),
                               (CGRectGetMaxY(lastObjectAttrs.frame) - bottomHeaderHeight)
                               );

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

    }

    return answer;

}

- (BOOL) shouldInvalidateLayoutForBoundsChange:(CGRect)newBound {
    return YES;
}

Try this guys...

Share:
47,516
Padin215
Author by

Padin215

Updated on November 14, 2020

Comments

  • Padin215
    Padin215 over 3 years

    I found a blog on how to make sticky headers and it works great. Only thing is I don't think it takes into account the sectionInserts.

    This is how its intended to look:

    enter image description here

    I have my inserts:

    collectionViewFlowLayout.sectionInset = UIEdgeInsetsMake(16, 16, 16, 16);
    

    With the sticky header, it is moved down by 16 pixles:

    enter image description here

    I tried tinking with the original code and I think the issue is with the last part:

    layoutAttributes.frame = (CGRect){
        .origin = CGPointMake(origin.x, origin.y),
        .size = layoutAttributes.frame.size
    

    If i change it to origin.y - 16, the header will start in the right location but when pushed up, 16 pixels of the head go off screen:

    enter image description here

    I'm not sure how to get it to take into account sectionInsects. Can anybody help?

    Here is the code in full from the blog:

    - (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;
    }
    
  • de.
    de. about 10 years
    You should also respect contentInset for your frame calculation, e.g. frame.origin.y = MIN(MAX(contentOffset.y + cv.contentInset.top, frame.origin.y), nextHeaderOrigin.y - CGRectGetHeight(frame)); . Chances are good that in iOS7 they are not 0 and then your header would stick under the navigation bar.
  • MrJre
    MrJre over 9 years
    Also note that the header won't show when a section has 0 items, while the space for it still shows. You could opt not showing the space for it, but alternatively you can just show the header even when the section has 0 items. In this case you should also add those sections to the missingSections set for all the headers you encounter in the initial answer.
  • trdavidson
    trdavidson about 9 years
    This does not work when the first section has no rows. It will create the space of the section header under the section header.
  • trdavidson
    trdavidson about 9 years
    @MrJe - how would you fix the case where a section has no rows? I'm unable to incorporate you comment unfortunately..thanks!
  • p0lAris
    p0lAris almost 9 years
    I used the exact same code as above but it doesn't seem to be working. Unsure why.
  • luk2302
    luk2302 about 7 years
    absolutely perfect solution when you are using a flow layout :)
  • Tim Fuqua
    Tim Fuqua over 5 years
    Good Swift 4 solution. Was simpler than other solutions I'd been trying, and this one didn't have some of the issues I was having with other solutions.
  • BangOperator
    BangOperator over 5 years
    Works but this seems to use 100% cpu. Scroll performance drops with this flag
  • Bibek
    Bibek over 5 years
    @BangOperator there must be other issue in your app, probably on collection view cells where you are performing CPU usage demanding tasks.
  • Yannick Winters
    Yannick Winters about 5 years
    This only works in combination with a CollectionViewFlowLayout. Not with a CollectionViewLayout.
  • Bibek
    Bibek about 5 years
    @YannickWinters yes it's for UICollectionViewFlowLayout. If you use custom UICollectionViewLayout you need to write extra code.
  • Thomas Mary
    Thomas Mary almost 4 years
    obj-c answer : [(UICollectionViewFlowLayout *)myCollectionView.collectionViewLayout setSectionHeadersPinToVisibleBounds:YES];