UICollectionView with a sticky header
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...
Padin215
Updated on November 14, 2020Comments
-
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:
I have my inserts:
collectionViewFlowLayout.sectionInset = UIEdgeInsetsMake(16, 16, 16, 16);
With the sticky header, it is moved down by 16 pixles:
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: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. about 10 yearsYou should also respect
contentInset
for yourframe
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 over 9 yearsAlso 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 initialanswer
. -
trdavidson about 9 yearsThis does not work when the first section has no rows. It will create the space of the section header under the section header.
-
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 almost 9 yearsI used the exact same code as above but it doesn't seem to be working. Unsure why.
-
luk2302 about 7 yearsabsolutely perfect solution when you are using a flow layout :)
-
Tim Fuqua over 5 yearsGood 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 over 5 yearsWorks but this seems to use 100% cpu. Scroll performance drops with this flag
-
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 about 5 yearsThis only works in combination with a CollectionViewFlowLayout. Not with a CollectionViewLayout.
-
Bibek about 5 years@YannickWinters yes it's for UICollectionViewFlowLayout. If you use custom UICollectionViewLayout you need to write extra code.
-
Thomas Mary almost 4 yearsobj-c answer :
[(UICollectionViewFlowLayout *)myCollectionView.collectionViewLayout setSectionHeadersPinToVisibleBounds:YES];