4

I’ve been trying to create a UICollectionView header that would stick on top of my collection view. I’m using UICollectionViewCompositionalLayout.

I’ve tried multiple approaches: using a cell, using a section header and try to mess with insets and offsets to position it correctly relative to my content… And even adding a view on top of the collection view that would listen to the collection view’s scroll view’s contentOffset to position itself at the right place. But none of these approaches are satisfying. They all feel like a hack.

I’ve been doing some research and apparently you’d have to sublcass UICollectionViewLayout which is super tedious and seems overkill to just have a header, but one that is global to the whole collection view.

Clément Cardonnel
  • 4,232
  • 3
  • 29
  • 36

1 Answers1

13

TL;DR

UICollectionViewCompositionalLayout has a configuration property which you can set by creating an UICollectionViewCompositionalLayoutConfiguration object. This object has some really nice and useful functionality such as the boundarySupplementaryItems property.

From the docs:

An array of the supplementary items that are associated with the boundary edges of the entire layout, such as global headers and footers.

Bingo. Set this property and do the necessary wiring in your datasource and you should have your global header.

Code Example

Here, I'm declaring a global header in my layout. The header is a segmented control inside a visual effect view, but yours can be any subclass of UICollectionReusableView.

enum SectionLayoutKind: Int, CaseIterable {
    case description
}

private var collectionView: UICollectionView! = nil

override func viewDidLoad() {
    super.viewDidLoad()

    collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createLayout())
}

static func descriptionSection() -> NSCollectionLayoutSection {
    // Instantiate and return a `NSCollectionLayoutSection` object.
}

func createLayout() -> UICollectionViewLayout {
    let layout = UICollectionViewCompositionalLayout {
        (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
        // Create your section
        // add supplementaries such as header and footers that are relative to the section…
        guard let layoutKind = SectionLayoutKind(rawValue: sectionIndex) else { return nil }
        
        let section: NSCollectionLayoutSection
        switch layoutKind {
        case .description:
            section = Self.descriptionSection()
        }
        
        return section
    }
    
    /*
     ✨ Magic starts HERE:
    */
    let globalHeaderSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(44))
    let globalHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: globalHeaderSize, elementKind: Constants.HeaderKind.globalSegmentedControl, alignment: .top)
    // Set true or false depending on the desired behavior
    globalHeader.pinToVisibleBounds = true
    
    let config = UICollectionViewCompositionalLayoutConfiguration()
    /*
     If you want to do spacing between sections.
     That's another big thing this config object does.
     If you try to define section spacing at the section level with insets,
     the spacing is between the items and the standard headers.
    */
    config.interSectionSpacing = 20
    config.boundarySupplementaryItems = [globalHeader]
    layout.configuration = config
    /*
     End of magic. ✨
    */
    
    return layout
}

struct Constants {
    struct HeaderKind {
        static let space = "SpaceCollectionReusableView"
        static let globalSegmentedControl = "segmentedControlHeader"
    }
}

Supplementary code for the data source part:

let globalHeaderRegistration = UICollectionView.SupplementaryRegistration<SegmentedControlReusableView>(elementKind: Constants.HeaderKind.globalSegmentedControl) { (header, elementKind, indexPath) in
    // Opportunity to further configure the header
    header.segmentedControl.addTarget(self, action: #selector(self.onSegmentedControlValueChanged(_:)), for: .valueChanged)
}

dataSource.supplementaryViewProvider = { (view, kind, indexPath) in
    if kind == Constants.HeaderKind.globalSegmentedControl {
        return self.collectionView.dequeueConfiguredReusableSupplementary(using: globalHeaderRegistration, for: indexPath)
    } else {
        // return another registration object
    }
}
Ely
  • 8,259
  • 1
  • 54
  • 67
Clément Cardonnel
  • 4,232
  • 3
  • 29
  • 36
  • Good answer on the layout configuration for global headers. You can add footers as well with this, just by changing the alignment and adding an extra item. Note that `SupplementaryRegistration` is iOS 14 only, you still have to use the old registration/reuse APIs on 13. – Marc Etcheverry Oct 07 '20 at 22:33
  • Also note that global supplementary boundary items in the bottom position for a layout configuration have layout problems, so I resorted to just using a standard boundary item in the last section. – Marc Etcheverry Oct 09 '20 at 06:17
  • What is `globalHeader` could you add more details please? – Markon Dec 18 '20 at 08:35
  • @Markon I suspect `globalHeader` is an `NSCollectionLayoutBoundarySupplementaryItem` that he initialised right after he creates the `globalHeaderSize`. I think he deleted the assignment accidentally. – mrtnlst Dec 18 '20 at 11:06
  • @Markon, mrtnlst, you guys are right, I don't know why it wasn't there. I hope the addition will make things clearer and that I didn't forget to include other useful declarations. It's already bit some time and I started to forgot the details. – Clément Cardonnel Dec 22 '20 at 07:24