How to make Supplementary View float in UICollectionView as Section Headers do in UITableView plain style

IphoneUitableviewViewUicollectionviewSupplementary

Iphone Problem Overview


I'm struggling to achieve a "floating section header" effect with UICollectionView. Something that's been easy enough in UITableView (default behavior for UITableViewStylePlain) seems impossible in UICollectionView without lots of hard work. Am I missing the obvious?

Apple provides no documentation on how to achieve this. It seems that one has to subclass UICollectionViewLayout and implement a custom layout just to achieve this effect. This entails quite a bit of work, implementing the following methods:

Methods to Override

Every layout object should implement the following methods:

collectionViewContentSize
layoutAttributesForElementsInRect:
layoutAttributesForItemAtIndexPath:
layoutAttributesForSupplementaryViewOfKind:atIndexPath: (if your layout supports supplementary views)
layoutAttributesForDecorationViewOfKind:atIndexPath: (if your layout supports decoration views)
shouldInvalidateLayoutForBoundsChange:

However its not clear to me how to make the supplementary view float above the cells and "stick" to the top of the view until the next section is reached. Is there a flag for this in the layout attributes?

I would have used UITableView but I need to create a rather complex hierarchy of collections which is easily achieved with a collection view.

Any guidance or sample code would be greatly appreciated!

Iphone Solutions


Solution 1 - Iphone

In iOS9, Apple was kind enough to add a simple property in UICollectionViewFlowLayout called sectionHeadersPinToVisibleBounds.

With this, you can make the headers float like that in table views.

let layout = UICollectionViewFlowLayout()
layout.sectionHeadersPinToVisibleBounds = true
layout.minimumInteritemSpacing = 1
layout.minimumLineSpacing = 1
super.init(collectionViewLayout: layout)

Solution 2 - Iphone

Either implement the following delegate methods:

 collectionView:layout:sizeForItemAtIndexPath:
 collectionView:layout:insetForSectionAtIndex:
 collectionView:layout:minimumLineSpacingForSectionAtIndex:
 collectionView:layout:minimumInteritemSpacingForSectionAtIndex:
 collectionView:layout:referenceSizeForHeaderInSection:
 collectionView:layout:referenceSizeForFooterInSection:

In your view controller that has your :cellForItemAtIndexPath method (just return the correct values). Or, instead of using the delegate methods, you may also set these values directly in your layout object, e.g. [layout setItemSize:size];.

Using either of these methods will enable you to set your settings in Code rather than IB as they're removed when you set a Custom Layout. Remember to add <UICollectionViewDelegateFlowLayout> to your .h file, too!

Create a new Subclass of UICollectionViewFlowLayout, call it whatever you want, and make sure the H file has:

#import <UIKit/UIKit.h>

@interface YourSubclassNameHere : UICollectionViewFlowLayout

@end

Inside the Implementation File make sure it has the following:

- (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];
            
            NSIndexPath *firstObjectIndexPath = [NSIndexPath indexPathForItem:0 inSection:section];
            NSIndexPath *lastObjectIndexPath = [NSIndexPath indexPathForItem:MAX(0, (numberOfItemsInSection - 1)) inSection:section];
        
            UICollectionViewLayoutAttributes *firstObjectAttrs;
            UICollectionViewLayoutAttributes *lastObjectAttrs;
        
            if (numberOfItemsInSection > 0) {
                firstObjectAttrs = [self layoutAttributesForItemAtIndexPath:firstObjectIndexPath];
                lastObjectAttrs = [self layoutAttributesForItemAtIndexPath:lastObjectIndexPath];
            } else {
                firstObjectAttrs = [self layoutAttributesForSupplementaryViewOfKind:UICollectionElementKindSectionHeader
                                                                    atIndexPath:firstObjectIndexPath];
                lastObjectAttrs = [self layoutAttributesForSupplementaryViewOfKind:UICollectionElementKindSectionFooter
                                                                   atIndexPath:lastObjectIndexPath];
            }
            
            CGFloat headerHeight = CGRectGetHeight(layoutAttributes.frame);
            CGPoint origin = layoutAttributes.frame.origin;
            origin.y = MIN(
                           MAX(
                               contentOffset.y + cv.contentInset.top,
                               (CGRectGetMinY(firstObjectAttrs.frame) - headerHeight)
                               ),
                           (CGRectGetMaxY(lastObjectAttrs.frame) - headerHeight)
                           );
            
            layoutAttributes.zIndex = 1024;
            layoutAttributes.frame = (CGRect){
                .origin = origin,
                .size = layoutAttributes.frame.size
            };
            
        }
        
    }
    
    return answer;
    
}

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

Choose "Custom" in Interface Builder for the Flow Layout, choose your "YourSubclassNameHere" Class that you just created. And Run!

(Note: the code above may not respect contentInset.bottom values, or especially large or small footer objects, or collections that have 0 objects but no footer.)

Solution 3 - Iphone

If you have a single header view that you want pinned to the top of your UICollectionView, here's a relatively simple way to do it. Note this is meant to be as simple as possible - it assumes you are using a single header in a single section.

//Override UICollectionViewFlowLayout class
@interface FixedHeaderLayout : UICollectionViewFlowLayout
@end

@implementation FixedHeaderLayout
//Override shouldInvalidateLayoutForBoundsChange to require a layout update when we scroll 
- (BOOL) shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds {
    return YES;
}

//Override layoutAttributesForElementsInRect to provide layout attributes with a fixed origin for the header
- (NSArray *) layoutAttributesForElementsInRect:(CGRect)rect {

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

    //see if there's already a header attributes object in the results; if so, remove it
    NSArray *attrKinds = [result valueForKeyPath:@"representedElementKind"];
    NSUInteger headerIndex = [attrKinds indexOfObject:UICollectionElementKindSectionHeader];
    if (headerIndex != NSNotFound) {
        [result removeObjectAtIndex:headerIndex];
    }

    CGPoint const contentOffset = self.collectionView.contentOffset;
    CGSize headerSize = self.headerReferenceSize;
    
    //create new layout attributes for header
    UICollectionViewLayoutAttributes *newHeaderAttributes = [UICollectionViewLayoutAttributes layoutAttributesForSupplementaryViewOfKind:UICollectionElementKindSectionHeader withIndexPath:[NSIndexPath indexPathForItem:0 inSection:0]];
    CGRect frame = CGRectMake(0, contentOffset.y, headerSize.width, headerSize.height);  //offset y by the amount scrolled
    newHeaderAttributes.frame = frame;
    newHeaderAttributes.zIndex = 1024;
    
    [result addObject:newHeaderAttributes];

    return result;
}
@end

See: https://gist.github.com/4613982

Solution 4 - Iphone

Here is my take on it, I think it's a lot simpler than what a glimpsed above. The main source of simplicity is that I'm not subclassing flow layout, rather rolling my own layout (much easier, if you ask me).

Please Note I am assuming you are already capable of implementing your own custom UICollectionViewLayout that will display cells and headers without floating implemented. Once you have that implementation written, only then will the code below make any sense. Again, this is because the OP was asking specifically about the floating headers part.

a few bonuses:

  1. I am floating two headers, not just one
  2. Headers push previous headers out of the way
  3. Look, swift!

note:

  1. supplementaryLayoutAttributes contains all header attributes without floating implemented
  2. I am using this code in prepareLayout, since I do all computation upfront.
  3. don't forget to override shouldInvalidateLayoutForBoundsChange to true!

// float them headers
let yOffset = self.collectionView!.bounds.minY
let headersRect = CGRect(x: 0, y: yOffset, width: width, height: headersHeight)

var floatingAttributes = supplementaryLayoutAttributes.filter {
    $0.frame.minY < headersRect.maxY
}

// This is three, because I am floating 2 headers
// so 2 + 1 extra that will be pushed away
var index = 3
var floatingPoint = yOffset + dateHeaderHeight

while index-- > 0 && !floatingAttributes.isEmpty {
    
    let attribute = floatingAttributes.removeLast()
    attribute.frame.origin.y = max(floatingPoint, attribute.frame.origin.y)
    
    floatingPoint = attribute.frame.minY - dateHeaderHeight
}

Solution 5 - Iphone

If already set flow layout in Storyboard or Xib file then try this,

(collectionView.collectionViewLayout as? UICollectionViewFlowLayout)?.sectionHeadersPinToVisibleBounds = true

Solution 6 - Iphone

In case anybody is looking for a solution in Objective-C, put this in viewDidload:

    UICollectionViewFlowLayout *flowLayout = 
    (UICollectionViewFlowLayout*)_yourcollectionView.collectionViewLayout;
    [flowLayout setSectionHeadersPinToVisibleBounds:YES];

Solution 7 - Iphone

@iPrabu had an excellent answer with sectionHeadersPinToVisibleBounds. I’ll just add that you can set this property in Interface Builder as well:

  1. Select the flow layout object in the document navigator. (If collapsed, expand it first using the toolbar button in the lower-left corner of the editor.)

Selecting the flow layout object in the document navigator.

  1. Open the Identity inspector and add a user-defined runtime attribute with key path sectionHeadersPinToVisibleBounds, type Boolean, and the checkbox checked.

Setting the runtime attribute in the Identity inspector.

The default header view has a transparent background. You might want to make it (partially) opaque or add a blur effect view.

Solution 8 - Iphone

I ran into the same problem and found this in my google results. First I would like to thank cocotutch for sharing his solution. However, I wanted my UICollectionView to scroll horizontally and the headers to stick to the left of the screen, so I had to change the solution a bit.

Basically I just changed this:

        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
        };

to this:

		if (self.scrollDirection == UICollectionViewScrollDirectionVertical) {
			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
			};
		} else {
			CGFloat headerWidth = CGRectGetWidth(layoutAttributes.frame);
			CGPoint origin = layoutAttributes.frame.origin;
			origin.x = MIN(
						   MAX(contentOffset.x, (CGRectGetMinX(firstCellAttrs.frame) - headerWidth)),
						   (CGRectGetMaxX(lastCellAttrs.frame) - headerWidth)
						   );
			
			layoutAttributes.zIndex = 1024;
			layoutAttributes.frame = (CGRect){
				.origin = origin,
				.size = layoutAttributes.frame.size
			};
		}

See: https://gist.github.com/vigorouscoding/5155703 or http://www.vigorouscoding.com/2013/03/uicollectionview-with-sticky-headers/

Solution 9 - Iphone

I ran this with vigorouscoding's code. However that code did not consider sectionInset.

So I changed this code for vertical scroll

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

to

origin.y = MIN(
           MAX(contentOffset.y, (CGRectGetMinY(firstCellAttrs.frame) - headerHeight - self.sectionInset.top)),
           (CGRectGetMaxY(lastCellAttrs.frame) - headerHeight + self.sectionInset.bottom)
           );

If you guys want code for horizontal scroll, refer to code aove.

Solution 10 - Iphone

I've added a sample on github that is pretty simple, I think.

Basically the strategy is to provide a custom layout that invalidates on bounds change and provide layout attributes for the supplementary view that hug the current bounds. As others have suggested. I hope the code is useful.

Solution 11 - Iphone

There is a bug in cocotouch's post. When there is no items in section and the section footer were set to be not displayed, the section header will go outside of the collection view and the user will be not able to see it.

In fact change:

if (numberOfItemsInSection > 0) {
    firstObjectAttrs = [self layoutAttributesForItemAtIndexPath:firstObjectIndexPath];
    lastObjectAttrs = [self layoutAttributesForItemAtIndexPath:lastObjectIndexPath];
} else {
    firstObjectAttrs = [self layoutAttributesForSupplementaryViewOfKind:UICollectionElementKindSectionHeader
                                                            atIndexPath:firstObjectIndexPath];
    lastObjectAttrs = [self layoutAttributesForSupplementaryViewOfKind:UICollectionElementKindSectionFooter
                                                           atIndexPath:lastObjectIndexPath];
}

into:

if (numberOfItemsInSection > 0) {
    firstObjectAttrs = [self layoutAttributesForItemAtIndexPath:firstObjectIndexPath];
    lastObjectAttrs = [self layoutAttributesForItemAtIndexPath:lastObjectIndexPath];
} else {
    firstObjectAttrs = [self layoutAttributesForSupplementaryViewOfKind:UICollectionElementKindSectionHeader
                                                            atIndexPath:firstObjectIndexPath];
    lastObjectAttrs = [self layoutAttributesForSupplementaryViewOfKind:UICollectionElementKindSectionFooter
                                                           atIndexPath:lastObjectIndexPath];
    if (lastObjectAttrs == nil) {
        lastObjectAttrs = firstObjectAttrs;
    }
}

will solve this issue.

Solution 12 - Iphone

Swift 5.0

Put the following in your viewDidLoad:

if let layout = collectionView?.collectionViewLayout as? UICollectionViewFlowLayout {
	layout.sectionHeadersPinToVisibleBounds = true
}

Solution 13 - Iphone

VCollectionViewGridLayout does sticky headers. It is a vertical scrolling simple grid layout based on TLIndexPathTools. Try running the Sticky Headers sample project.

This layout also has much better batch update animation behavior than UICollectionViewFlowLayout. There are a couple of sample projects provided that let you toggle between the two layouts to demonstrate the improvement.

Solution 14 - Iphone

I seem to have stumbled upon an elegant solution that others may find useful :)

    let layout = UICollectionViewCompositionalLayout(
        sectionProvider: { (_, layoutEnvironment) -> NSCollectionLayoutSection? in
            var configuration = UICollectionLayoutListConfiguration(appearance: .grouped)
            configuration.headerMode = .supplementary
            configuration.showsSeparators = true
            configuration.backgroundColor = UIColor.systemGray6

            var section = NSCollectionLayoutSection.list(
                using: configuration,
                layoutEnvironment: layoutEnvironment
            )
            let sectionSize = NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1.0),
                heightDimension: .estimated(1)
            )
            let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(
                layoutSize: sectionSize,
                elementKind: UICollectionView.elementKindSectionHeader,
                alignment: .top
            )
            sectionHeader.pinToVisibleBounds = true
            section.boundarySupplementaryItems = [sectionHeader]
            return section
    })
    let frame = CGRect(x: 0.0, y: 0.0, width: 300.0, height: 500.0)
    let view = UICollectionView(frame: frame, collectionViewLayout: layout)

The problem I always had with other solutions was not finding a way to pin the header and have it animate pushing another header out of view. In the code below, none of the instances gives you access to the pinToVisibleBounds property like the solution above:

    var configuration = UICollectionLayoutListConfiguration(appearance: .grouped)
    configuration.headerMode = .supplementary
    configuration.showsSeparators = true
    configuration.backgroundColor = UIColor.systemGray6
    let layout = UICollectionViewCompositionalLayout.list(using: configuration)

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
Question100gramsView Question on Stackoverflow
Solution 1 - IphoneiprabaView Answer on Stackoverflow
Solution 2 - IphonetopLayoutGuideView Answer on Stackoverflow
Solution 3 - IphoneMikeVView Answer on Stackoverflow
Solution 4 - IphoneMazyodView Answer on Stackoverflow
Solution 5 - IphoneMohammad Zaid PathanView Answer on Stackoverflow
Solution 6 - IphoneGeneCodeView Answer on Stackoverflow
Solution 7 - IphoneConstantino TsarouhasView Answer on Stackoverflow
Solution 8 - IphonevigorouscodingView Answer on Stackoverflow
Solution 9 - Iphoneuser2273634View Answer on Stackoverflow
Solution 10 - IphoneDerrick HathawayView Answer on Stackoverflow
Solution 11 - IphoneWeZZardView Answer on Stackoverflow
Solution 12 - IphoneTimBigDevView Answer on Stackoverflow
Solution 13 - IphoneTimothy MooseView Answer on Stackoverflow
Solution 14 - IphoneACosmicFlamingoView Answer on Stackoverflow