1

My goal is to have dynamically sized cells in a UICollectionView. In addition, when the scrollable contentWidth of the UICollectionView is less than the bounds of the Collection, I want the items centred within the collection.

Goal

Approach thus far:

I am trying to do this in the cleanest way possible. I have self-sizing UICollectionViewCells where Auto-Layout can determine the size of the cell from the contraints of the content. For this, my cells override the preferredLayoutAttributesFitting function, returning the appropriate width for their content.

My custom UICollectionViewFlowLayout subclass uses this width information, provided by the UICollectionViewCell to properly size up the cells.

The only methods overridden on my custom UICollectionViewFlowLayout are:

  • layoutAttributesForItem(at...)
  • layoutAttributesForElementsIn(rect...)
  • shouldInvalidateLayout(forBoundsChange...)

The dynamic sizing of the UICollectionViewCells works flawlessly.

The problem:

Using this approach, I have no prior knowledge of the size of each cell (each can be different). I'd like to implement the UICollectionView's delegate method to provide an inset to center my content within the collection view. For this, I'm thinking I should implement and override

collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets

The problem lies in the fact that the inset method is called early in the layout process, where I have no knowledge of the contentSize that will be determined during layout.

Here's a stack of the various layout methods, for a single complete layout pass, in the order that they run:

prepare started
prepare finished
layoutAttributesForElementsInRect started
layoutAttributesForElementsInRect finished
layoutAttributesForElementsInRect started
layoutAttributesForElementsInRect finished
layoutAttributesForElementsInRect started
layoutAttributesForElementsInRect finished
prepare started
insetForSectionAt 0 called <--- The datasource has knowledge of the presence of cells but the cells themselves are not yet dequeued
prepare finished
layoutAttributesForElementsInRect started
layoutAttributesForItemAt [0, 0] started
layoutAttributesForItemAt [0, 0] finished
layoutAttributesForItemAt [0, 1] started
layoutAttributesForItemAt [0, 1] finished
layoutAttributesForItemAt [0, 2] started
layoutAttributesForItemAt [0, 2] finished
layoutAttributesForElementsInRect finished
layoutAttributesForElementsInRect started
layoutAttributesForItemAt [0, 0] started
layoutAttributesForItemAt [0, 0] finished
layoutAttributesForItemAt [0, 1] started
layoutAttributesForItemAt [0, 1] finished
layoutAttributesForItemAt [0, 2] started
layoutAttributesForItemAt [0, 2] finished
layoutAttributesForElementsInRect finished
prepare started
prepare finished
layoutAttributesForElementsInRect started
<--- At this point Cells DO return valid sizes through Autolayout
layoutAttributesForItemAt [0, 0] started
layoutAttributesForItemAt [0, 0] finished
layoutAttributesForItemAt [0, 1] started
layoutAttributesForItemAt [0, 1] finished
layoutAttributesForItemAt [0, 2] started
layoutAttributesForItemAt [0, 2] finished
layoutAttributesForElementsInRect finished

For a single layout pass, multiple calls are made to the various layout methods but only at the very end is the size information from Auto-layout available from the different cells to pass on to the layout.

I noticed that the sectionInset delegate function is getting called early, before the cells have been properly dequeued and allowed to run their Autolayout constraints. Therefore, it is impossible to inform the inset with a correct value at the time it is called.

Using such an approach, is it possible to force a call to recalculate the sectionInsetAt after the layout pass or should I change the technique completely?

Things I have tried:

I tried querying the collectionViewLayout's collectionViewContentSize from within the insetForSectionAt delegate function. Unfortunately, this cannot be used during a layout pass as the layout is currently in progress. It causes a crash with error:

[CollectionView] An attempt to update layout information was detected while already in the process of computing the layout (i.e. reentrant call). This will result in unexpected behaviour or a crash. This may happen if a layout pass is triggered while calling out to a delegate. UICollectionViewFlowLayout instance is (<App.ViewportToolbarFlowLayout: 0x7f8a5261add0>)

I tried using the collectionView's contentSize method to get at the size during the execution of the insetForSectionAt delegate method, but it does not provide correct data at the moment it runs within the layout process.

FJ de Brienne
  • 243
  • 1
  • 11

1 Answers1

0

Posting the answer here. I found this answer by Alex Koshy which helped me a lot.

Basically, the work has to be performed in the layoutAttributesForElementsIn(rect...) since it is the only place where the full layout picture is ever available. I've adapted Alex's code to handle both horizontal and vertical UICollectionViews (which may not be what everyone wants but is what I needed). I use the scrollDirection property of the layout to determine whether elements will be centred horizontally (when scrollDirection is .horizontal) or vertically (when scrollDirection is .vertical).

Here is the code:

override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
    guard let collectionView = collectionView else { return nil }

    guard let superLayoutAttributes = super.layoutAttributesForElements(in: rect) else { return nil }
    let computedAttributes = superLayoutAttributes.compactMap { layoutAttribute -> UICollectionViewLayoutAttributes? in
        return layoutAttribute.representedElementCategory == .cell ? layoutAttributesForItem(at: layoutAttribute.indexPath) : layoutAttribute
    }
    guard let attributes = NSArray(array: computedAttributes, copyItems: true) as? [UICollectionViewLayoutAttributes] else { return nil }

    let interItemSpacing = minimumInteritemSpacing

    switch scrollDirection {
    case .horizontal:
        let leftPadding: CGFloat = self.sectionInset.left
        var leftMargin: CGFloat = leftPadding // Modified to determine origin.x for each item
        var maxY: CGFloat = -1.0 // Modified to determine origin.y for each item
        var rowSizes: [[CGFloat]] = [] // Tracks the starting and ending x-values for the first and last item in the row
        var currentRow: Int = 0 // Tracks the current row
        attributes.forEach { layoutAttribute in
            // Each layoutAttribute represents its own item
            if layoutAttribute.frame.origin.y >= maxY {

                // This layoutAttribute represents the left-most item in the row
                leftMargin = leftPadding

                currentRow += rowSizes.isEmpty ? 0 : 1
                // Register its origin.x in rowSizes for use later
                rowSizes.append([leftMargin, 0])
            }
            layoutAttribute.frame.origin.x = leftMargin
            leftMargin += layoutAttribute.frame.width + interItemSpacing
            maxY = max(layoutAttribute.frame.maxY, maxY)
            // Add right-most x value for last item in the row
            rowSizes[currentRow][1] = leftMargin - interItemSpacing
        }
        // At this point, all cells are left aligned
        // Reset tracking values and add extra left padding to center align entire row
        leftMargin = leftPadding
        maxY = -1.0
        currentRow = 0
        attributes.forEach { (layoutAttribute) in
            if layoutAttribute.frame.origin.y >= maxY {

                // This layoutAttribute represents the left-most item in the row
                leftMargin = leftPadding

                // Need to bump it up by an appended margin
                let rowWidth = rowSizes[currentRow][1] - rowSizes[currentRow][0] // last.x - first.x
                let appendedMargin = (collectionView.frame.width - leftPadding  - rowWidth - leftPadding) / 2
                leftMargin += appendedMargin

                currentRow += 1
            }

            layoutAttribute.frame.origin.x = leftMargin

            leftMargin += layoutAttribute.frame.width + interItemSpacing
            maxY = max(layoutAttribute.frame.maxY, maxY)
        }

    case .vertical:
        let topPadding: CGFloat = self.sectionInset.top
        var topMargin: CGFloat = topPadding
        var maxX: CGFloat = -1.0 // Modified to determine origine.x for each item
        var colSizes: [[CGFloat]] = [] // Tracks the starting and ending y-values for the first and last item in the column
        var currentCol: Int = 0
        attributes.forEach { (layoutAttribute) in
            if layoutAttribute.frame.origin.x >= maxX {
                topMargin = topPadding

                currentCol += colSizes.isEmpty ? 0 : 1
                colSizes.append([topMargin, 0])
            }

            layoutAttribute.frame.origin.y = topMargin
            topMargin += layoutAttribute.frame.height + interItemSpacing
            maxX = max(layoutAttribute.frame.maxX, maxX)
            colSizes[currentCol][1] = topMargin - interItemSpacing
        }
        topMargin = topPadding
        maxX = -1.0
        currentCol = 0
        attributes.forEach { (layoutAttribute) in
            if layoutAttribute.frame.origin.x >= maxX {

                topMargin = topPadding

                let colWidth = colSizes[currentCol][1] - colSizes[currentCol][0]
                let appendedMargin = (collectionView.frame.height - topPadding - colWidth - topPadding) / 2
                topMargin += appendedMargin

                currentCol += 1
            }

            layoutAttribute.frame.origin.y = topMargin

            topMargin += layoutAttribute.frame.height + interItemSpacing
            maxX = max(layoutAttribute.frame.maxX, maxX)
        }
    }

    return attributes
}
FJ de Brienne
  • 243
  • 1
  • 11