Say you're using a completely typical layout:

let layout = UICollectionViewCompositionalLayout { (_, _) -> NSCollectionLayoutSection? in
    let layoutSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1/CGFloat(3)),
        heightDimension: .absolute(rowHeight)
    )
    let item = NSCollectionLayoutItem(layoutSize: layoutSize)

    let groupSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0),
        heightDimension: .absolute(rowHeight)
    )
    let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

    return NSCollectionLayoutSection(group: group)
}

So that is a 3 column layout:

enter image description here

However, occasionally there's an item in the section (and there's only one section) that is full width (or perhaps any specific size):

if daData[indexPath.item].whatever = .special { ...

enter image description here

Solutions could be something like ...

(A) ...

func collectionView(_ collectionView: UICollectionView,
      layout collectionViewLayout: UICollectionViewLayout,
      sizeForItemAt indexPath: IndexPath) -> CGSize {
   if .. return .. the default from the compositional layout
   else .. return some specific size

But I believe that's not possible / I don't know how to return the "super" of #sizeForItemAt (and I don't know if that would indeed be the value from the compositional layout).

(B) ...

let layoutSize = NSCollectionLayoutSize(
  widthDimension: .fractionalWidth(1/  item == 666 ? 1 : 3 ),

but I don't know how to use the indexPath inside compositional layout (you can use the section but not the index path, as far as I know)

(C) ...

    widthDimension: estimated: .fractionalWidth(1/3),
    ...
    groupSize .. similar idea

But I don't know how to set the width as both estimated and fractional.

Perhaps combined with #sizeForItemAt, but I don't know how to combine a column number compositional layout with estimated sizes with #sizeForItemAt

Is there a way?

In short ideally:

  • most items would have a width creating two columns, but,
  • based on the datasource, the occasional item would have a one-column width.

footnotes

In practice you have to deal with rotation (or other animating shapes). You can do this amazingly elegantly in compositional layout ...

// let columnCount = self.bounds.width < self.bounds.height ? CGFloat(2) : CGFloat(3)
// more specifically, often best to use the whole screen ... in case of for example a slide-up panel
var columnCount = CGFloat(2)
if let glass = self.findViewController()?.view.bounds {
    if glass.width > glass.height {
        columnCount = CGFloat(3)
    }
}

let layoutSize = NSCollectionLayoutSize(
    widthDimension: .fractionalWidth(1/columnCount), .. etc

remembering to

override func viewWillLayoutSubviews() {
    super.viewWillLayoutSubviews()
    collection.collectionViewLayout.invalidateLayout() // for rotation changes
}
1

There are 1 best solutions below

2
HangarRash On

Here's a reasonably straightforward approach that uses UICollectionViewFlowLayout. This sample lets you show cells in a fixed number of columns but allows some items to be shown full width.

The trick to showing cells in fixed columns with a flow layout is calculating the proper item size. The calculated width needs to take into account the collection view's width, the safe area insets, the section insets, and the iter-item spacing between cells in a row.

Comments in the code provide more details.

// Dummy data structure
struct ItemData {
    let isFullSize: Bool
}

class ViewController: UICollectionViewController {
    let layout: UICollectionViewFlowLayout = .init()

    var data = [ItemData]()
    let columns = 3
    let rowHeight: CGFloat = 75
    var columnWidth: CGFloat = 0.0
    var fullWidth: CGFloat = 0.0

    init() {
        // Setup the layout as desired
        layout.scrollDirection = .vertical
        layout.minimumInteritemSpacing = 10
        layout.minimumLineSpacing = 10
        layout.sectionInset = .init(top: 0, left: 10, bottom: 0, right: 10)
        layout.sectionInsetReference = .fromSafeArea

        super.init(collectionViewLayout: layout)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()

        collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "cell")

        // Generate data where one specific item is shown full size
        data = Array(repeating: ItemData(isFullSize: false), count: 18)
        data[5] = ItemData(isFullSize: true)
    }

    private func updateSizes(for size: CGSize) {
        // Account for the view's safeAreaInsets and the layout's sectionInsets
        let cvWidth: CGFloat = size.width - view.safeAreaInsets.left - view.safeAreaInsets.right - layout.sectionInset.left - layout.sectionInset.right
        fullWidth = cvWidth.rounded(.towardZero)
        // Account for the layout's minimumInteritemSpacing
        columnWidth = ((fullWidth - layout.minimumInteritemSpacing * CGFloat(columns - 1)) / CGFloat(columns)).rounded(.towardZero)

        self.collectionView.collectionViewLayout.invalidateLayout()
    }

    // Adjust for updates to the safe area
    override func viewSafeAreaInsetsDidChange() {
        super.viewSafeAreaInsetsDidChange()

        updateSizes(for: view.bounds.size)
    }

    // Adjust for initial display
    override func viewIsAppearing(_ animated: Bool) {
        super.viewIsAppearing(animated)

        updateSizes(for: view.bounds.size)
    }

    // Adjust for device rotation or iPad split screen
    override func viewWillTransition(to size: CGSize, with coordinator: any UIViewControllerTransitionCoordinator) {
        super.viewWillTransition(to: size, with: coordinator)

        updateSizes(for: size)
    }
}

extension ViewController: UICollectionViewDelegateFlowLayout {
    override func numberOfSections(in collectionView: UICollectionView) -> Int {
        return 1
    }

    override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return data.count
    }

    override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        // Show a trivial green square for each cell
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)

        cell.backgroundColor = .green

        return cell
    }

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        // Set the item's width based on whether it's full size or not
        let item = data[indexPath.row]
        if item.isFullSize {
            return CGSize(width: fullWidth, height: rowHeight)
        } else {
            return CGSize(width: columnWidth, height: rowHeight)
        }
    }
}

enter image description here

As you can see, there is one arguable "flaw" with this use of a flow layout - rows that have only 1 or 2 cells don't keep those cells aligned to the left. This is solved by subclassing UICollectionViewFlowLayout and using the subclass.

See Left Align Cells in UICollectionView for lots of solutions. The following example is based on this answer:

class LeftAlignedFlowLayout: UICollectionViewFlowLayout {
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        guard let att = super.layoutAttributesForElements(in:rect) else {return []}
        var x: CGFloat = sectionInset.left
        var y: CGFloat = -1.0

        for a in att {
            if a.representedElementCategory != .cell { continue }

            if a.frame.origin.y >= y { x = sectionInset.left }
            a.frame.origin.x = x
            x += a.frame.width + minimumInteritemSpacing
            y = a.frame.maxY
        }
        return att
    }
}

Change:

let layout: UICollectionViewFlowLayout = .init()

to:

let layout: LeftAlignedFlowLayout = .init()

and now you get:

enter image description here