Andrei Herford
Andrei Herford

Reputation: 18765

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
        
    }*/
}

Upvotes: 0

Views: 102

Answers (1)

duckSern1108
duckSern1108

Reputation: 1115

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

Upvotes: 1

Related Questions