iosswiftuicollectionview

How to use header with dynamic heigh / AutoLayout in UICollectionView in iOS 18?


Although I found several tutorials / questions about dynamically sizing cells UICollectionView I was not able to achieve the same for the header view.

TL;DR How to autosize a UICollectionView header? Fetching a mock header in collectionView:layout:referenceSizeForHeaderInSection crashes in iOS 18 and using preferredLayoutAttributesFitting in UICollectionReusableView subclass has no effect.

The goal:

A UICollectionReusableView subclass containing two UILabel for title and content:

+------------------+
|        20        |      
|20 TitleLabel   20|
|        10        |
|20 ContentLabel 20|
|        20        |
+------------------+

The ContentLabel is configured to show multiple lines. So, the header should auto-size according to the text in ContentLabel.

Solution before iOS 18 crashes now

Until know I used the following code in SomeViewController to size the header view to its content:

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
    // Query mock header to fetch its size
    let indexPath = IndexPath(row: 0, section: section)
    let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: HeaderId, for: indexPath)

     // ...
    
    return headerView.systemLayoutSizeFitting(CGSize(width: collectionView.frame.width, height: UIView.layoutFittingCompressedSize.height), withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel)
}

This worked fine until compiling the project with Xcode 16 / iOS 18. Now the app crashes since it is not allowed to dequeue a view manually (discussed here)

preferredLayoutAttributesFitting

I found several sources, indicating that using preferredLayoutAttributesFitting within the headerView subclass of UICollectionReusableView should do the trick. However, no matter what I return here, it has absolutely no effect.

Full example

class SomeViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
    let CellId = "CellId"
    let HeaderId = "HeaderId"
    
    
    @IBOutlet weak var collectionView: UICollectionView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: CellId)
        collectionView.register(HeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: HeaderId)
    }
    
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return 2
    }
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return section == 0 ? 5 : 10
    }
        
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CellId, for: indexPath)
        cell.backgroundColor = indexPath.section == 0 ? .red : .blue
        return cell
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        return CGSize(width: 50, height: 50)
    }
    
    func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
        let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: HeaderId, for: indexPath)
        
        if let headerView = view as? HeaderView {
            if indexPath.section == 0 {
                headerView.configure(title: "Section 1", info: "This is section 1")
            } else {
                headerView.configure(title: "Section 2", info: "This is section 2 with a longer text. The height of the header is automatically adjusted to fit the text. And we make the text even longer to see how it works. And we make the text even longer to see how it works. And we make the text even longer to see how it works.")
            }
        }
        
        return view
    }
       
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
        // This is the only place which allows to change the header height at runtime.
        // Returning different sizes for different sections is no problem. However,
        // without dequeuing a mock header and measuring its height, one can only
        // return an estimate.
        //
        // Without this method all headers are created with the reference height given
        // in the flow layout.
        return CGSize(width: collectionView.frame.width, height: 200)
    }
}


class HeaderView: UICollectionReusableView {
    let titleLabel: UILabel = {
        let label = UILabel()
        label.translatesAutoresizingMaskIntoConstraints = false
        label.font = .preferredFont(forTextStyle: .headline)
        label.textColor = .black
        label.numberOfLines = 0
        label.backgroundColor = .white
        return label
    }()
    
    let infoLabel: UILabel = {
        let label = UILabel()
        label.translatesAutoresizingMaskIntoConstraints = false
        label.font = .preferredFont(forTextStyle: .body)
        label.textColor = .black
        label.numberOfLines = 0
        label.backgroundColor = .white
        return label
    }()
    
    // Initialisierung
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupView()
    }
    
    // Setup der View und Constraints
    private func setupView() {
        backgroundColor = .green
        
        addSubview(titleLabel)
        addSubview(infoLabel)
        
        NSLayoutConstraint.activate([
            // Title-Label Constraints
            titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 20),
            titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20),
            titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20),
            
            // Info-Label Constraints
            infoLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 10),
            infoLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20),
            infoLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20),
            infoLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20)
        ])
    }
    
    // Methoden zur Konfiguration der Labels
    func configure(title: String, info: String) {
        titleLabel.text = title
        infoLabel.text = info
    }
    
    /*override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
        let autoLayoutAttributes = super.preferredLayoutAttributesFitting(layoutAttributes)
        
        // No matter what is returned here, the result has no effect. So this
        // example returns a fixed value instead a calculated one using 
        // systemLayoutSizeFitting
        autoLayoutAttributes.frame = CGRect(origin: autoLayoutAttributes.frame.origin, size: CGSize(width: autoLayoutAttributes.frame.width, height: 50))

        return autoLayoutAttributes
        
    }*/
}

Solution

  • You can use UICollectionViewCompositionalLayout and .estimated to have autorisizing section header. Here is an example:

    class SomeViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
        let CellId = "CellId"
        let HeaderId = "HeaderId"
        
        
        private lazy var collectionView = UICollectionView(frame: .init(), collectionViewLayout: UICollectionViewFlowLayout())
        
        override func viewDidLoad() {
            super.viewDidLoad()
            view.addSubview(collectionView)
            collectionView.dataSource = self
            collectionView.delegate = self
            collectionView.collectionViewLayout = createLayout()
            collectionView.translatesAutoresizingMaskIntoConstraints = false
            NSLayoutConstraint.activate([
                // Title-Label Constraints
                collectionView.topAnchor.constraint(equalTo: view.topAnchor, constant: 0),
                collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0),
                collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0),
                collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 0),
            ])
            collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: CellId)
            collectionView.register(HeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: HeaderId)
        }
        
        private func createLayout() -> UICollectionViewCompositionalLayout {
            let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .absolute(50), heightDimension: .absolute(50)))
            let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .estimated(1)), subitems: [item])
            group.interItemSpacing = .fixed(8)
            let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .estimated(1)), elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
            let section = NSCollectionLayoutSection(group: group)
            section.interGroupSpacing = 8
            section.boundarySupplementaryItems = [header]
            return .init(section: section)
        }
        
        func numberOfSections(in collectionView: UICollectionView) -> Int {
            return 2
        }
        
        func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
            return section == 0 ? 5 : 10
        }
            
        func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CellId, for: indexPath)
            cell.backgroundColor = indexPath.section == 0 ? .red : .blue
            return cell
        }
        
        func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
            return CGSize(width: 50, height: 50)
        }
        
        func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
            let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: HeaderId, for: indexPath)
            
            if let headerView = view as? HeaderView {
                if indexPath.section == 0 {
                    headerView.configure(title: "Section 1", info: "This is section 1")
                } else {
                    headerView.configure(title: "Section 2", info: "This is section 2 with a longer text. The height of the header is automatically adjusted to fit the text. And we make the text even longer to see how it works. And we make the text even longer to see how it works. And we make the text even longer to see how it works.")
                }
            }
            
            return view
        }
           
        
        func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
            // This is the only place which allows to change the header height at runtime.
            // Returning different sizes for different sections is no problem. However,
            // without dequeuing a mock header and measuring its height, one can only
            // return an estimate.
            //
            // Without this method all headers are created with the reference height given
            // in the flow layout.
            return CGSize(width: collectionView.frame.width, height: 200)
        }
    }
    
    
    class HeaderView: UICollectionReusableView {
        let titleLabel: UILabel = {
            let label = UILabel()
            label.translatesAutoresizingMaskIntoConstraints = false
            label.font = .preferredFont(forTextStyle: .headline)
            label.textColor = .black
            label.numberOfLines = 0
            label.backgroundColor = .white
            return label
        }()
        
        let infoLabel: UILabel = {
            let label = UILabel()
            label.translatesAutoresizingMaskIntoConstraints = false
            label.font = .preferredFont(forTextStyle: .body)
            label.textColor = .black
            label.numberOfLines = 0
            label.backgroundColor = .white
            return label
        }()
        
        // Initialisierung
        override init(frame: CGRect) {
            super.init(frame: frame)
            setupView()
        }
        
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            setupView()
        }
        
        // Setup der View und Constraints
        private func setupView() {
            backgroundColor = .green
            
            addSubview(titleLabel)
            addSubview(infoLabel)
            
            NSLayoutConstraint.activate([
                // Title-Label Constraints
                titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 20),
                titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20),
                titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20),
                
                // Info-Label Constraints
                infoLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 10),
                infoLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20),
                infoLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20),
                infoLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20)
            ])
        }
        
        // Methoden zur Konfiguration der Labels
        func configure(title: String, info: String) {
            titleLabel.text = title
            infoLabel.text = info
        }
        
        /*override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
            let autoLayoutAttributes = super.preferredLayoutAttributesFitting(layoutAttributes)
            
            // No matter what is returned here, the result has no effect. So this
            // example returns a fixed value instead a calculated one using
            // systemLayoutSizeFitting
            autoLayoutAttributes.frame = CGRect(origin: autoLayoutAttributes.frame.origin, size: CGSize(width: autoLayoutAttributes.frame.width, height: 50))
    
            return autoLayoutAttributes
            
        }*/
    }
    

    enter image description here