Michael Waterfall
Michael Waterfall

Reputation: 20569

Custom UICollectionViewLayout w/ auto-sizing cells breaks with larger estimated item heights

I'm building a custom UICollectionViewLayout that supports auto-sizing cells and I've hit an issue when the estimated item height is larger than the final heights. When the preferred layout attributes triggers a partial invalidation some cells below become visible, not all of them are getting the correct frames applied.

On the image below, the left screenshot shows the initial rendering with a large estimated height, and the right image shows where the estimated height is less than the final height.

This issue occurs on iOS 10 and 11.

With a smaller estimated height, the content size increases during layout and the preferred layout attributes does not cause more items to move into the visible rect. The collection view handles this situation perfectly.

The logic of the invalidation and frame calculation seems valid, so I'm not sure why the collection view is not handling the case where partial invalidation causes new items to come into view.

When inspecting deeper it appears that the final views that are due to be moved into view are being invalidated and being asked to calculate their size, but their final attributes are not being applied.

enter image description here

Here's the layout code of a very stripped down version of the custom layout for demonstration purposes that exhibits this glitch:

/// Simple demo layout, only 1 section is supported
/// This is not optimised, it is purely a simplified version
/// of a more complex custom layout that demonstrates
/// the glitch.
public class Layout: UICollectionViewLayout {

    public var estimatedItemHeight: CGFloat = 50
    public var spacing: CGFloat = 10

    var contentWidth: CGFloat = 0
    var numberOfItems = 0
    var heightCache = [Int: CGFloat]()

    override public func prepare() {
        super.prepare()

        self.contentWidth = self.collectionView?.bounds.width ?? 0
        self.numberOfItems = self.collectionView?.numberOfItems(inSection: 0) ?? 0
    }

    override public var collectionViewContentSize: CGSize {
        // Get frame for last item an duse maxY
        let lastItemIndex = self.numberOfItems - 1
        let contentHeight = self.frame(for: IndexPath(item: lastItemIndex, section: 0)).maxY

        return CGSize(width: self.contentWidth, height: contentHeight)
    }

    override public func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        // Not optimal but works, get all frames for all items and calculate intersection
        let attributes: [UICollectionViewLayoutAttributes] = (0 ..< self.numberOfItems)
            .map { IndexPath(item: $0, section: 0) }
            .compactMap { indexPath in
                let frame = self.frame(for: indexPath)
                guard frame.intersects(rect) else {
                    return nil
                }
                let attributesForItem = self.layoutAttributesForItem(at: indexPath)
                return attributesForItem
            }
        return attributes
    }

    override public func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
        attributes.frame = self.frame(for: indexPath)
        return attributes
    }

    public func frame(for indexPath: IndexPath) -> CGRect {
        let heightsTillNow: CGFloat = (0 ..< indexPath.item).reduce(0) {
            return $0 + self.spacing + (self.heightCache[$1] ?? self.estimatedItemHeight)
        }
        let height = self.heightCache[indexPath.item] ?? self.estimatedItemHeight
        let frame = CGRect(
            x: 0,
            y: heightsTillNow,
            width: self.contentWidth,
            height: height
        )
        return frame
    }

    override public func shouldInvalidateLayout(forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> Bool {
        let index = originalAttributes.indexPath.item
        let shouldInvalidateLayout = self.heightCache[index] != preferredAttributes.size.height

        return shouldInvalidateLayout
    }

    override public func invalidationContext(forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutInvalidationContext {
        let context = super.invalidationContext(forPreferredLayoutAttributes: preferredAttributes, withOriginalAttributes: originalAttributes)

        let index = originalAttributes.indexPath.item
        let oldContentSize = self.collectionViewContentSize

        self.heightCache[index] = preferredAttributes.size.height

        let newContentSize = self.collectionViewContentSize
        let contentSizeDelta = newContentSize.height - oldContentSize.height

        context.contentSizeAdjustment = CGSize(width: 0, height: contentSizeDelta)

        // Everything underneath has to be invalidated
        let indexPaths: [IndexPath] = (index ..< self.numberOfItems).map {
            return IndexPath(item: $0, section: 0)
        }
        context.invalidateItems(at: indexPaths)

        return context
    }

}

Here's the cell's preferred layout attributes calculation (note we're letting the layout decide and fix the width, and we're asking autolayout to calculate the height of the cell given the width).

public class Cell: UICollectionViewCell {

    // ...

    public override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
        let finalWidth = layoutAttributes.bounds.width

        // With the fixed width given by layout, calculate the height using autolayout
        let finalHeight = systemLayoutSizeFitting(
            CGSize(width: finalWidth, height: 0),
            withHorizontalFittingPriority: .required,
            verticalFittingPriority: .fittingSizeLevel
        ).height

        let finalSize = CGSize(width: finalWidth, height: finalHeight)
        layoutAttributes.size = finalSize
        return layoutAttributes
    }
}

Is there something obvious that is causing this within the layout logic?

Upvotes: 13

Views: 2498

Answers (2)

J.Hunter
J.Hunter

Reputation: 586

I have duplicated the issue via set

estimatedItemHeight = 500

in demo code. I have a question about your logic to calculating frame for every cell: all the height in self.heightCache are zero, so the statement

return $0 + self.spacing + (self.heightCache[$1] ?? self.estimatedItemHeight)

in the function frame is same as

return $0 + self.spacing + self.estimatedItemHeight

I think maybe you should check this code

self.heightCache[index] = preferredAttributes.size.height

in function

invalidationContext(forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutInvalidationContext

as preferredAttributes.size.height always is zero

and the

finalHeight

is also zero in the class

Cell

Upvotes: 4

Tom Hamming
Tom Hamming

Reputation: 10981

I am likewise trying to build a custom UICollectionViewLayout subclass for UITableView-style layout, and I'm hitting a slightly different problem. But I've found that in shouldInvalidateLayoutForPreferredLayoutAttributes, if you return based on whether the preferred's height matches the original's height (instead of whether the preferred height matches what you have in your cache), it will properly apply the layout attributes and all your cells will have the right heights.

But then you get missing cells, because you don't always get re-queried for layoutAttributesForElementsInRect after self-sizing has occurred and modified your cell heights (and therefore y positions).

See an example project here on GitHub.

Edit: My question got answered, and the example on GitHub is working now.

Upvotes: 0

Related Questions