68

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!

Virja Rahul
  • 405
  • 2
  • 4
  • 12
100grams
  • 3,314
  • 3
  • 27
  • 25

13 Answers13

69

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)
Pang
  • 8,605
  • 144
  • 77
  • 113
ipraba
  • 16,095
  • 3
  • 54
  • 58
47

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

toblerpwn
  • 5,210
  • 7
  • 36
  • 46
topLayoutGuide
  • 1,397
  • 10
  • 22
  • 3
    If `numberOfItemsInSection == 0`, you will get a `EXC_ARITHMETIC code=EXC_I386_DIV` (divide-by-zero?) crash on this line: `UICollectionViewLayoutAttributes *firstCellAttrs = [self layoutAttributesForItemAtIndexPath:firstCellIndexPath];`. I've also noticed that the `contentOffset` parts of this code are not respecting the collection view's top-inset value in my implementation (may be related to the previous issue).. Will propose a code change/gist/etc. – toblerpwn Apr 16 '13 at 04:36
  • 3
    https://gist.github.com/toblerpwn/5393460 - feel free to iterated, particularly on the limitations noted in the gist. :) – toblerpwn Apr 16 '13 at 05:12
  • Great help, thanks. This worked perfectly with the PSTCollectionView compatibility classes by renaming the relevant classes (UICollectionView/PSTCollectionView, ... etc.) https://github.com/steipete/PSTCollectionView – Chris Blunt Sep 02 '13 at 14:29
  • Does not work for me, used your code by copy pasting. The headers are not sticky. My header is defined in the `UICollectionViewFlowLayout` class in the `init` method: `self.headerReferenceSize = CGSizeMake(320,140)`. – jerik Nov 11 '13 at 13:18
  • If it works for everybody else so there's something you're doing. Try declaring your headers in the appropriate Delegate method. – topLayoutGuide Nov 12 '13 at 00:30
  • 1
    Shouldn't this be doable without invalidating the layout on every bouns change? – mattsson Nov 20 '13 at 15:36
  • @mattsson I have no idea really. I just used what Apple gave me and came up with this. Feel free to alter it, but provide your edits here to avoid the creation of any more questions based on this subject :) – topLayoutGuide Nov 26 '13 at 04:34
  • Thanks. I ended up using a separate UICollectionView for the sticky header, since invalidating layout on every bounds change with a collection view containing 1000+ items destroys performance. – mattsson Dec 10 '13 at 08:41
  • Here's a nice writeup on this technique: http://blog.radi.ws/post/32905838158/sticky-headers-for-uicollectionview-using – louielouie Mar 17 '14 at 21:48
  • There is a bug in the code for the "if (numberOfItemsInSection > 0)" else clause situation. In that case the "- headerHeight" should not occur. I am willing to post the corrected code but am not sure a new answer is appropriate. – Andrew Raphael Mar 18 '14 at 17:11
  • Certainly it is appropriate. This solution isn't perfect, and does need improvement. Feel free :) – topLayoutGuide Mar 19 '14 at 06:55
  • I can't get this to work: in the [missingSections enumerateIndexesUsingBlock... part, often layoutAttributes is nil (it seems to get recursively called?). I am registering the class in the vc's ViewDidLoad, like this: `[self.collectionView registerClass:[BHTimelineTitleReusableView class] forSupplementaryViewOfKind:UICollectionElementKindSectionHeader withReuseIdentifier:SectionTitleIdentifier];`... any hints? – xaphod Aug 28 '14 at 14:34
  • 1
    @cocotutch have you encountered any issues with using this since the iOS 8 SDK launched? My cell layouts within this Flow are defaulting to a size of 50x50 even though I've defined `collectionView:layout:sizeForItemAtIndexPath:`. More specifically, the inner cell content is 50x50, but the cell's size is still correct. Only occurs when building against iOS 8 SDK but running on iOS 7.0.X. Building against iOS 7.1 SDK and running on 7.0.X works fine. – Matt Baker Sep 19 '14 at 14:50
  • @cocotutch looks like this: http://stackoverflow.com/questions/25804588/auto-layout-in-uicollectionviewcell-not-working?rq=1 – Matt Baker Sep 19 '14 at 15:05
8

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

MikeV
  • 1,190
  • 1
  • 12
  • 17
7

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
}
Mazyod
  • 21,361
  • 9
  • 86
  • 147
  • Not sure what this means, the code is incomplete and the notes don't fill in all the gaps. – SafeFastExpressive May 03 '15 at 15:37
  • @RandyHill This code isn't meant to be a generic solution, rather it's a guide towards how I achieved floating headers. If you have trouble understanding any part of the implementation, just ask. (I'll add an extra note, which I think is the source of your confusion). – Mazyod May 03 '15 at 16:40
  • 1
    Never mind, this is clear in relation to the question. I've removed my down vote. – SafeFastExpressive May 03 '15 at 23:59
  • Do you have any example project maybe? I'm trying to implement 2 floating headers and can't make it work :-( – gasparuff Oct 15 '15 at 08:45
4

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

(collectionView.collectionViewLayout as? UICollectionViewFlowLayout)?.sectionHeadersPinToVisibleBounds = true
Mohammad Zaid Pathan
  • 14,352
  • 6
  • 84
  • 112
4

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

    UICollectionViewFlowLayout *flowLayout = 
    (UICollectionViewFlowLayout*)_yourcollectionView.collectionViewLayout;
    [flowLayout setSectionHeadersPinToVisibleBounds:YES];
GeneCode
  • 7,148
  • 7
  • 42
  • 75
3

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

Constantino Tsarouhas
  • 6,736
  • 6
  • 43
  • 54
2

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/

vigorouscoding
  • 376
  • 3
  • 9
2

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.

1

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.

Derrick Hathaway
  • 1,037
  • 11
  • 14
  • The header and footer views in this code behave differently compared to table view section headers and footers. They always stick even if scrolled farther than the end (so that it bounces). – Ortwin Gentz Nov 29 '13 at 21:53
1

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.

WeZZard
  • 3,387
  • 1
  • 19
  • 26
0

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.

Timothy Moose
  • 9,773
  • 3
  • 31
  • 44
  • I tried that. Actually if you already have a UICollectionView with a working data delegate, then moving to this is hard, because your datamodel must be modelled as an TLIndexDataModel. I gave up on this. – xaphod Aug 28 '14 at 13:58
  • @xaphod It does not actually. From the GitHub readme: "Requires TLIndexPathTools for internal implementation. The collection view itself does not necessarily need to use TLIndexPathTools, but the sample projects do." – Timothy Moose Aug 28 '14 at 14:57
  • Yes, I read that too. In practice, it is not correct. Try it. – xaphod Aug 28 '14 at 19:01
  • @xaphod I should mention that I wrote the library. I'll look into adding a sample project that doesn't use TLIPT. The only requirement for using this library is that you've got to implement the `VCollectionViewGridLayoutDelegate` delegate methods, which themselves don't require TLIPT. The main intent of this library, however, was to work around some animation issues with flow layout. The sticky headers feature is secondary, so there may be better options for those only interested in sticky headers. – Timothy Moose Aug 28 '14 at 19:07
  • Ah ok -- well I did try hard NOT to use TLIPT at all, but I could see that in the layout class, that TLIPT was being used. I couldn't see around it. You are correct that all I'm after is sticky headers... but I haven't had any success yet (the above solution from cocotutch doesn't work for me either, indicating the problem is err, on my end) – xaphod Aug 29 '14 at 07:32
0

Swift 5.0

Put the following in your viewDidLoad:

if let layout = collectionView?.collectionViewLayout as? UICollectionViewFlowLayout {
    layout.sectionHeadersPinToVisibleBounds = true
}
TimBigDev
  • 371
  • 6
  • 6