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