Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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?

like image 969
Michael Waterfall Avatar asked Apr 13 '18 20:04

Michael Waterfall


1 Answers

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

like image 182
J.Hunter Avatar answered Oct 11 '22 00:10

J.Hunter