UICollectionView dynamic header size

2.5k Views Asked by At

I have a collectionView with a header designed in a .xib file. It has a simple label and it's text supports dynamicType.

How can I set the height of that header to be dynamic based on that label and the auto layout constraints in Storyboard?

So far, I've got this:

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
    let kind = UICollectionView.elementKindSectionHeader
    let indexPath = IndexPath(row: 0, section: section)
    if let headerView = collectionView.supplementaryView(forElementKind: kind, at: indexPath) as? SectionHeaderView {
        headerView.layoutIfNeeded()
        headerView.setNeedsLayout()
        let size = headerView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
        return size
    }
    return CGSize(width: 0, height: 0)
}

But it does not show any header.

SectionHeaderView.xib looks like this: header.xib

CollectionView looks like this: you see 3 sections, but you don't see a header. collectionView

What can I do to let AutoLayout determine the correct hight of the header?

2

There are 2 best solutions below

2
On

Use a custom flow layout to perfectly manage the height of a header in a collection view with the Dynamic Type feature.

A header element is seen as a supplementary element for a collection view and the referenceSizeForHeaderInSection 'method' is only used for initialization: it's not called with the Dynamic Type feature.

The solution hereafter is based on the layoutAttributesForElements method of the custom layout that will be able to adapt the header height thanks to the UIFontMetrics scaledValue.

All that is fired by the invalidateLayout method called in the traitCollectionDidChange triggered when the user changes the font size.

STEP 1 ⟹ create a simple custom header class as follows for instance:

class MyHeaderClass: UICollectionReusableView {

    override init(frame: CGRect) { super.init(frame: frame) }

    required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) }
}

STEP 2 ⟹ create a new empty .xib adding a reusable view and name it the exact same name as the class it refers to: don't forget to change its class name in the Identity Inspector.

STEP 3 ⟹ register the .xib file in the controller:

collectionView.register(UINib(nibName: collectionViewHeaderFooterReuseIdentifier bundle: nil),
                        forSupplementaryViewOfKind: UICollectionElementKindSectionHeader,
                        withReuseIdentifier:collectionViewHeaderFooterReuseIdentifier)

STEP 4 ⟹ support this new cell in your data source (a header is a supplementary element for a collection view):

func collectionView(_ collectionView: UICollectionView,
                    viewForSupplementaryElementOfKind kind: String,
                    at indexPath: IndexPath) -> UICollectionReusableView {

    if (kind == UICollectionView.elementKindSectionHeader) {
        let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind,
                                                                         withReuseIdentifier: collectionViewHeaderReuseIdentifier,
                                                                         for: indexPath) as! MyHeader
        headerView.myLabel.text = "Your Header Title"
        return headerView
    } else {
        return UICollectionReusableView(frame: CGRect.null) }
}

... and in your delegate (this intializes the header size and makes it appear):

func collectionView(_ collectionView: UICollectionView,
                    layout collectionViewLayout: UICollectionViewLayout,
                    referenceSizeForHeaderInSection section: Int) -> CGSize {

    return CGSize(width: collectionView.frame.width, height: headerHeight)
}

.. after adding a global var headerHeight: CGFloat = 90.0 for initialization.

STEP 5 ⟹ create the custom flow layout to adapt the header height to the new font size:

class FlowLayout: UICollectionViewFlowLayout {

    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {

        let layoutAttributes = super.layoutAttributesForElements(in: rect)

        layoutAttributes?.forEach({ (attribute) in
            if (attribute.representedElementKind == UICollectionView.elementKindSectionHeader) {

                headerHeight = UIFontMetrics.default.scaledValue(for: 22.0)
                attribute.frame.size.height = headerHeight
            }
        })

        return layoutAttributes
    }
}

Don't forget to update the storyboard in Interface Builder: enter image description here STEP 6 ⟹ inform the controller to trigger a layout update when the user changes the font size:

override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
    if (previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory) {
        collectionView?.collectionViewLayout.invalidateLayout()
    }
}

Following this rationale, the correct height of the headers is automatically set up according to the headers font size ⟹ I suggest to use the Xcode 11 new feature to test the Dynamic Type very quickly.

0
On

Assuming you've got the proper constraints setup for your header, and you return a dynamically deduced height in your referenceSizeForHeaderInSection:

Observe UIContentSizeCategory.didChangeNotification in your class:

NotificationCenter.default.addObserver(self, selector: #selector(fontSizeChanged), name: UIContentSizeCategory.didChangeNotification, object: nil)

Use your fontSizeChanged function for updation:

@objc private func fontSizeChanged(_ sender: Any) {
    //If you're dealing with minimal data, you can simply 'reloadData()'
    //If not, I'm sure there are other efficient ways to make this work. I'm just exposing the provision
    self.collectionView.reloadData()
}

Oh, and make sure you:

deinit {
    NotificationCenter.default.removeObserver(self)
}