4

I'm using UICollectionViewCompositionalLayout to create a vertically scrolling list of horizontally scrolling (orthogonally scrolling) lists in my iOS app.

When I specify an estimated height for both the item and the group, the collection view adjusts its height when taller items scroll into view.

If on the other hand, I specify an estimated height for the group and a fractional height of 1.0 for the item, the height seems to be treated as an absolute value, regardless of constraints and compression resistance that are set.

Here's my createSection function:

func createSection(sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection {
    let itemSize = NSCollectionLayoutSize(
        widthDimension: .absolute(90),
        heightDimension: .estimated(44)
    )
    let item = NSCollectionLayoutItem(layoutSize: itemSize)
    let groupSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(90 / layoutEnvironment.container.effectiveContentSize.width),
        heightDimension: .estimated(44)
    )
    let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
    let section = NSCollectionLayoutSection(group: group)
    section.interGroupSpacing = 16.0
    section.orthogonalScrollingBehavior = .continuous
    return section
}

I tried subclassing UICollectionViewCompositionalLayout and overriding layoutAttributesForElementsInRect to adjust attributes based on the tallest item in each section. This resulted in flickering during scrolling, however, which I suspect is due to prepare being called continuously on bounds change.

I don't know the height of the view in each cell in advance. However, I've ensured that they are correctly constrained and that compression resistance is set to required on the vertical axis.

Expected Behaviour: The height of each cell to be dynamically adjusted based on its content, without causing layout changes or flickering when cells of different heights come into view.

Actual Behavior: Specifying estimated height for both item and group causes layout changes upon scrolling, while setting fractional height for the item results in the group's estimated height being treated as an absolute value. Overriding layoutAttributesForElementsInRect and adjusting there causes flickering.

How can I handle dynamic cell heights in UICollectionViewCompositionalLayout without causing these layout issues? I would greatly appreciate any advice or potential solutions.

Here's a Swift Playground demonstrating the problem: https://github.com/aodhol/CompositionalProblem/tree/main

Screenshots showing estimated sizes too large and too small:

Estimate too big Estimate too small

Aodh
  • 662
  • 1
  • 7
  • 24

1 Answers1

1

In the iOS world, the UICollectionViewCompositionalLayout isn't exactly designed to handle cells with varying sizes when scrolling in the direction of a group. When you've set the orthogonal scrolling behavior, it's generally understood that all items in the orthogonal scrolling group are the same size.

But don't worry, we've got a workaround. Instead of using UICollectionViewCompositionalLayout for both the outer vertical list and inner horizontal list, we can utilize UICollectionViewFlowLayout for the inner list. This approach is quite flexible and can handle variable cell sizes. However, keep in mind that if you have a huge amount of data, this might not be the most performant solution.

Let's refactor your createSection function like this:

func createSection(sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection {
    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: 1)
    let section = NSCollectionLayoutSection(group: group)
    section.interGroupSpacing = 16.0
    return section
}

THEN, when handling your UICollectionViewDataSource, you'd dequeue and setup your cell as usual. But here's the twist - inside each cell, you'd have a nested UICollectionView using a UICollectionViewFlowLayout:

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: MyCell.identifier, for: indexPath) as! MyCell

    let flowLayout = UICollectionViewFlowLayout()
    flowLayout.scrollDirection = .horizontal
    flowLayout.estimatedItemSize = CGSize(width: 90, height: 44)
    
    cell.nestedCollectionView.collectionViewLayout = flowLayout
    cell.nestedCollectionView.dataSource = self
    cell.nestedCollectionView.delegate = self
    cell.nestedCollectionView.reloadData()
    
    return cell
}

Finally, make sure your cells can handle the nested UICollectionView:

class MyCell: UICollectionViewCell {
    static let identifier = "MyCell"
    
    let nestedCollectionView: UICollectionView = {
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout())
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        return collectionView
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        contentView.addSubview(nestedCollectionView)
        
        NSLayoutConstraint.activate([
            nestedCollectionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            nestedCollectionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            nestedCollectionView.topAnchor.constraint(equalTo: contentView.topAnchor),
            nestedCollectionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
        ])
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

This solution should offer the behaviour you're looking for, but remember it requires managing two collection views. Also, you might encounter a few additional challenges that need to be addressed. This workaround might be a little slower if you have a ton of cells, so it's not something to use willy nilly.

KFDoom
  • 772
  • 1
  • 6
  • 19
  • Another solution could be to use UITableViews for the inner lists instead of UICollectionViews, since UITableViews handle dynamic cell heights more naturally. This would involve using a different set of APIs, but could potentially lead to a simpler and more performant implementation if the inner lists don't require the flexibility of UICollectionView. – KFDoom Aug 02 '23 at 14:14
  • Thank for this! I attempted your approach but it didn't quite work for me (I modified my original example with the snippets you provided above but perhaps I missed something: https://github.com/aodhol/CompositionalProblem/tree/flow-layout-approach). The other thing is that I had hoped for a pure UICompositionalLayout solution otherwise I can't see what benefit using Compositional Layout would be if using a nested UICollectionView).. – Aodh Aug 20 '23 at 13:27