Reputation: 5039
I have a UICollectionViewLayout
grid with three columns. Each item in the column has a cell full of text. I would like all the columns to be the same height as the tallest item in the group. Using UICollectionViewCompositionalLayout
I'm having a hard time getting the desired results.
Any ideas on how to achieve this? All the code is below. The createLayout
function is the most relevant. Please note I am using LoremSwiftum to generate the random text.
Expected behavior:
I was able to mock this expected behavior by changing the NSCollectionLayoutSize
to be an absolute value of 500. This is not what I want. Each cell should estimate its size, and use the largest value of the group.
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(500))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(500))
Entire Code:
import UIKit
import LoremSwiftum
class ViewController: UIViewController {
enum Section {
case main
}
var dataSource: UICollectionViewDiffableDataSource<Section, Int>! = nil
var collectionView: UICollectionView! = nil
let sectionBackgroundDecorationElementKind = "section-background-element-kind"
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.title = "Two-Column Grid"
configureHierarchy()
configureDataSource()
}
/// - Tag: TwoColumn
func createLayout() -> UICollectionViewLayout {
let spacing = CGFloat(10)
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(44))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(44))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 3)
group.interItemSpacing = .fixed(spacing)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = spacing
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10)
let layout = UICollectionViewCompositionalLayout(section: section)
return layout
}
func configureHierarchy() {
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createLayout())
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
collectionView.backgroundColor = .systemBackground
view.addSubview(collectionView)
}
func configureDataSource() {
let cellRegistration = UICollectionView.CellRegistration<TextCell, Int> { (cell, indexPath, identifier) in
// Populate the cell with our item description.
cell.label.text = Lorem.sentences(Bool.random() ? 1 : 3)
}
dataSource = UICollectionViewDiffableDataSource<Section, Int>(collectionView: collectionView) {
(collectionView: UICollectionView, indexPath: IndexPath, identifier: Int) -> UICollectionViewCell? in
// Return the cell.
return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: identifier)
}
// initial data
var snapshot = NSDiffableDataSourceSnapshot<Section, Int>()
snapshot.appendSections([.main])
snapshot.appendItems(Array(0..<94))
dataSource.apply(snapshot, animatingDifferences: false)
}
}
class TextCell: UICollectionViewCell {
let label = UILabel()
let bottomLabel = UILabel()
let container = UIView()
static let reuseIdentifier = "text-cell-reuse-identifier"
override init(frame: CGRect) {
super.init(frame: frame)
configure()
}
required init?(coder: NSCoder) {
fatalError("not implemented")
}
func configure() {
label.numberOfLines = 0
label.adjustsFontForContentSizeCategory = true
label.font = UIFont.preferredFont(forTextStyle: .title1)
label.backgroundColor = .systemPurple
label.textColor = .white
bottomLabel.numberOfLines = 0
bottomLabel.adjustsFontForContentSizeCategory = true
bottomLabel.font = UIFont.preferredFont(forTextStyle: .title1)
bottomLabel.text = "Bottom of cell"
bottomLabel.backgroundColor = .systemRed
bottomLabel.textColor = .white
let stack = UIStackView(arrangedSubviews: [label, bottomLabel])
stack.translatesAutoresizingMaskIntoConstraints = false
stack.axis = .vertical
stack.distribution = .equalSpacing
stack.spacing = 20
contentView.addSubview(stack)
backgroundColor = .systemBlue.withAlphaComponent(0.75)
let inset = CGFloat(10)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: inset),
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -inset),
stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: inset),
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -inset),
])
}
}
Upvotes: 12
Views: 2629
Reputation: 5039
I ended up having to create a UICollectionViewCompositionalLayout
subclass, but the solution works really well.
Using the new subclass, keep a weak reference to the layout in your cell so you can use it in preferredLayoutAttributesFitting
.
final class EqualHeightsUICollectionViewCompositionalLayout: UICollectionViewCompositionalLayout {
private var heights = [Int: [IndexPath: CGFloat]]()
private var largests = [Int: CGFloat]()
private let columns: Int
init(section: NSCollectionLayoutSection, columns: Int) {
assert(columns > 0, "Columns should never be 0")
self.columns = max(columns, 1)
super.init(section: section)
}
init(columns: Int, sectionProvider: @escaping UICollectionViewCompositionalLayoutSectionProvider) {
assert(columns > 0, "Columns should never be 0")
self.columns = max(columns, 1)
super.init(sectionProvider: sectionProvider)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let attributes = super.layoutAttributesForElements(in: rect) else {
return nil
}
for attribute in attributes {
updateLayoutAttributesHeight(layoutAttributes: attribute)
}
return attributes
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
guard let attributes = super.layoutAttributesForItem(at: indexPath) else {
return nil
}
updateLayoutAttributesHeight(layoutAttributes: attributes)
return attributes
}
private func updateLayoutAttributesHeight(layoutAttributes: UICollectionViewLayoutAttributes) {
let height = layoutAttributes.frame.height
let indexPath = layoutAttributes.indexPath
let row = indexPath.item / columns
// Update the heights dictionary
if var rowHeight = heights[row] {
rowHeight[indexPath] = height
heights[row] = rowHeight
} else {
heights[row] = [indexPath: height]
}
// Calculate the largest height in the row
let largestRowHeight = largests[row] ?? 0
let newLargestHeight = max(largestRowHeight, height)
// Only adjust the frame if the new height is significantly different
// from the current largest height.
let heightDifference = abs(newLargestHeight - largestRowHeight)
// Do not check for equality, height is a floating number.
guard heightDifference > 0.5 else {
return
}
largests[row] = newLargestHeight
let size = CGSize(
width: layoutAttributes.frame.width,
height: newLargestHeight
)
layoutAttributes.frame = CGRect(origin: layoutAttributes.frame.origin, size: size)
}
}
class TextCell: UICollectionViewCell {
let label = UILabel()
weak var layout: EqualHeightsUICollectionViewCompositionalLayout?
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .systemBlue.withAlphaComponent(0.75)
configure()
}
required init?(coder: NSCoder) {
fatalError("not implemented")
}
override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
let attribute = super.preferredLayoutAttributesFitting(layoutAttributes)
layout?.updateLayoutAttributesHeight(layoutAttributes: attribute)
return attribute
}
private func configure() {
let bottomLabel = UILabel()
label.numberOfLines = 0
label.font = UIFont.preferredFont(forTextStyle: .title1)
label.backgroundColor = .systemPurple
label.textColor = .white
label.setContentHuggingPriority(.defaultHigh, for: .vertical)
bottomLabel.numberOfLines = 0
bottomLabel.font = UIFont.preferredFont(forTextStyle: .title1)
bottomLabel.text = "Bottom of cell"
bottomLabel.backgroundColor = .systemRed
bottomLabel.textColor = .white
bottomLabel.setContentHuggingPriority(.defaultLow, for: .vertical)
let stackView = UIStackView(arrangedSubviews: [label, bottomLabel])
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.spacing = 20
contentView.addSubview(stackView)
let padding: CGFloat = 15
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: padding),
stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -padding),
stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding),
stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -padding),
])
}
}
Upvotes: 8
Reputation: 5039
For WWDC23, Apple update UICollectionViewCompositionalLayout to have this ability. Just use uniformAcrossSiblings(estimate:) as the height dimension. You can read more about it in the documentation.
Upvotes: 9