Search code examples
iosswiftuicollectionviewuicollectionviewlayouttvos

UICollectionView sticky header in swift


I'm trying to create a sticky supplementary header, which stays on top all the time and won't response to scrolling events. The solutions I found so far still react on bounch scrolling and are fixed using a custom flowLayout, which will probably be the fix for mine issue as well.

The reason I want it this way is that the header is used on other places and should be reusable. I'm hoping this could be solved this way and I don't have to create a separated view.

As I'm doing this in Swift, it would be great to have an example in Swift.


Solution

  • The final solution I found:

    Using this custom flow layout it was possible to fix this sticky header:

    class StickyHeaderCollectionViewFlowLayout: UICollectionViewFlowLayout {
    
        override func layoutAttributesForElementsInRect(rect: CGRect) -> [AnyObject]? {
    
            var superAttributes: [UICollectionViewLayoutAttributes]? = super.layoutAttributesForElementsInRect(rect) as? [UICollectionViewLayoutAttributes]
    
            if superAttributes == nil {
                // If superAttributes couldn't cast, return
                return super.layoutAttributesForElementsInRect(rect)
            }
    
            let contentOffset = collectionView!.contentOffset
            var missingSections = NSMutableIndexSet()
    
            for layoutAttributes in superAttributes! {
                if (layoutAttributes.representedElementCategory == .Cell) {
                    if let indexPath = layoutAttributes.indexPath {
                        missingSections.addIndex(layoutAttributes.indexPath.section)
                    }
                }
            }
    
            for layoutAttributes in superAttributes! {
                if let representedElementKind = layoutAttributes.representedElementKind {
                    if representedElementKind == UICollectionElementKindSectionHeader {
                        if let indexPath = layoutAttributes.indexPath {
                            missingSections.removeIndex(indexPath.section)
                        }
                    }
                }
            }
    
            missingSections.enumerateIndexesUsingBlock { idx, stop in
                let indexPath = NSIndexPath(forItem: 0, inSection: idx)
                if let layoutAttributes = self.layoutAttributesForSupplementaryViewOfKind(UICollectionElementKindSectionHeader, atIndexPath: indexPath) {
                    superAttributes!.append(layoutAttributes)
                }
            }
    
            for layoutAttributes in superAttributes! {
                if let representedElementKind = layoutAttributes.representedElementKind {
                    if representedElementKind == UICollectionElementKindSectionHeader {
                        let section = layoutAttributes.indexPath!.section
                        let numberOfItemsInSection = collectionView!.numberOfItemsInSection(section)
    
                        let firstCellIndexPath = NSIndexPath(forItem: 0, inSection: section)!
                        let lastCellIndexPath = NSIndexPath(forItem: max(0, (numberOfItemsInSection - 1)), inSection: section)!
    
    
                        let (firstCellAttributes: UICollectionViewLayoutAttributes, lastCellAttributes: UICollectionViewLayoutAttributes) = {
                            if (self.collectionView!.numberOfItemsInSection(section) > 0) {
                                return (
                                    self.layoutAttributesForItemAtIndexPath(firstCellIndexPath),
                                    self.layoutAttributesForItemAtIndexPath(lastCellIndexPath))
                            } else {
                                return (
                                    self.layoutAttributesForSupplementaryViewOfKind(UICollectionElementKindSectionHeader, atIndexPath: firstCellIndexPath),
                                    self.layoutAttributesForSupplementaryViewOfKind(UICollectionElementKindSectionFooter, atIndexPath: lastCellIndexPath))
                            }
                            }()
    
                        let headerHeight = CGRectGetHeight(layoutAttributes.frame)
                        var origin = layoutAttributes.frame.origin
    
                        origin.y = min(contentOffset.y, (CGRectGetMaxY(lastCellAttributes.frame) - headerHeight))
                        // Uncomment this line for normal behaviour:
                        // origin.y = min(max(contentOffset.y, (CGRectGetMinY(firstCellAttributes.frame) - headerHeight)), (CGRectGetMaxY(lastCellAttributes.frame) - headerHeight))
    
                        layoutAttributes.zIndex = 1024
                        layoutAttributes.frame = CGRect(origin: origin, size: layoutAttributes.frame.size)
                    }
                }
            }
    
            return superAttributes
        }
    
        override func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool {
            return true
        }
    
    }
    

    To create a layout where the headers are sticky like traditional, change this line:

    origin.y = min(contentOffset.y, (CGRectGetMaxY(lastCellAttrs.frame) - headerHeight))
    

    to this line:

    origin.y = min(max(contentOffset.y, (CGRectGetMinY(firstCellAttrs.frame) - headerHeight)), (CGRectGetMaxY(lastCellAttrs.frame) - headerHeight))
    

    Hoping this is useful for others!

    Update

    Updated to fix a crash (thanks to Robert Atkins!) and some updates to Swift 1.2

    tvOS & iOS 9

    tvOS and iOS 9 introduced the property sectionHeadersPinToVisibleBounds which can be used