UICollectionViewLayout unexpected animations when cells contain AutoLayout views with custom height

The code for the issue is attached below.

Hello,

I am trying to implement a custom UICollectionViewLayout that does the following:

Everything works great for the most part, however I have encountered some unexpected animations when applying a new snapshot:

As you can see, any cell that contains a custom view with a height set with AutoLayout is scaled vertically before animating to it's intended height. Here is a simple Xcode project that demonstrates the issue. Tap on the plus sign in the top right corner and watch the cells.

Example project: https://we.tl/t-9Y25NHzxiI

Custom UICollectionViewLayout code:

final class CustomLayout: UICollectionViewLayout {

    struct PMCardContainerLayoutCell: Equatable {
        var column: Int
        var row: Int
    }

    // Configurable properties
    public var numberOfColumns: Int = 6
    public var cellHeight: Double = 100
    public var cellSpacing: Double = 20
    public var rowSpacing: Double = 20
    public var sectionInsets: NSDirectionalEdgeInsets = .zero

    public var layoutAttributes: [IndexPath: UICollectionViewLayoutAttributes] = [:]

    override func prepare() {
        super.prepare()

        guard let collectionView else {
            return
        }

        var updatedLayoutAttributes: [IndexPath: UICollectionViewLayoutAttributes] = [:]

        let columnWidth: Double = (collectionView.bounds.width - cellSpacing * Double(numberOfColumns - 1) - sectionInsets.leading - sectionInsets.trailing) / Double(numberOfColumns)

        let numberOfSections: Int = collectionView.numberOfSections

        for section in 0..<numberOfSections {

            var occupiedCells: [PMCardContainerLayoutCell] = []

            var currentColumn: Int = 0
            var currentRow: Int = 0

            let numberOfItems: Int = collectionView.numberOfItems(inSection: section)

            for item in 0..<numberOfItems {

                let itemIndexPath = IndexPath(item: item, section: section)

                let itemAttributes = UICollectionViewLayoutAttributes(forCellWith: itemIndexPath)
                let itemSpanColumn = 1
                let itemHeight = layoutAttributes[itemIndexPath]?.bounds.height ?? 140
                let itemSpanRow = Int(ceil(itemHeight / (cellHeight + rowSpacing)))
                let itemWidth = columnWidth * Double(itemSpanColumn) + cellSpacing * (Double(itemSpanColumn) - 1)

                while true {
                    var itemDoesFit: Bool = true

                    if currentColumn + itemSpanColumn > numberOfColumns {
                        currentColumn = 0
                        currentRow += 1
                    }

                    for cell in 0..<itemSpanColumn {
                        if occupiedCells.contains(.init(column: currentColumn + cell, row: currentRow)) {
                            itemDoesFit = false
                        }
                    }

                    if itemDoesFit {
                        break
                    }

                    currentColumn += itemSpanColumn
                }

                if itemSpanRow > 1 {
                    for row in 1..<itemSpanRow {
                        for column in 0..<itemSpanColumn {
                            occupiedCells.append(.init(column: currentColumn + column, row: currentRow + row))
                        }
                    }
                }

                let originX = sectionInsets.leading + columnWidth * Double(currentColumn) + cellSpacing * Double(currentColumn)
                let originY =  + cellHeight * Double(currentRow) + rowSpacing * Double(currentRow)

                itemAttributes.frame = CGRect(
                    x: originX,
                    y: originY,
                    width: itemWidth,
                    height: itemHeight
                )
                itemAttributes.zIndex = itemIndexPath.section * 10 + itemIndexPath.item

                updatedLayoutAttributes[itemIndexPath] = itemAttributes

                currentColumn += itemSpanColumn
            }
        }

        layoutAttributes = updatedLayoutAttributes
    }

    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        var allAttributes: [UICollectionViewLayoutAttributes] = []

        for (_, attributes) in layoutAttributes {
            if (rect.intersects(attributes.frame)) {
                allAttributes.append(attributes)
            }
        }

        return allAttributes
    }

    override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        return layoutAttributes[indexPath]
    }

    override var collectionViewContentSize: CGSize {
        guard let collectionView else {
            return .zero
        }
        let contentHeight: CGFloat = layoutAttributes.map({ $0.value.frame.maxY }).max() ?? 0
        return CGSize(width: collectionView.bounds.width, height: contentHeight)
    }

    override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
        return true
    }

    override func shouldInvalidateLayout(forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> Bool {
        return originalAttributes.frame.height != preferredAttributes.frame.height
    }

    override func invalidationContext(forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutInvalidationContext {
        layoutAttributes[preferredAttributes.indexPath]?.frame.size = preferredAttributes.frame.size
        let context = super.invalidationContext(forPreferredLayoutAttributes: preferredAttributes, withOriginalAttributes: originalAttributes)
        return context
    }

    public override func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) {
        super.invalidateLayout(with: context)

        if context.invalidateEverything || context.invalidateDataSourceCounts {
            layoutAttributes.removeAll()
        }
    }
}

Anyone have any idea what I am doing wrong?

Thank you!

Answered by Lulupard in 799595022

It seems I have solved the issue by using item IDs instead of IndexPaths to cache layout attributes.

Here is the final UICollectionViewLayout code (rough draft, not cleaned up):

final class CustomLayout: UICollectionViewLayout {

    // Configurable properties
    public var numberOfColumns: Int = 6
    public var cellHeight: Double = 200
    public var cellSpacing: Double = 20
    public var rowSpacing: Double = 20
    public var sectionInsets: NSDirectionalEdgeInsets = .zero

    public var layoutAttributes: [String: UICollectionViewLayoutAttributes] = [:]

    private var collectionViewDataSource: UICollectionViewDiffableDataSource<String, String>? {
        return collectionView?.dataSource as? UICollectionViewDiffableDataSource<String, String>
    }

    override func prepare() {
        super.prepare()

        guard
            let collectionView,
            let collectionViewDataSource
        else {
            return
        }

        var updatedLayoutAttributes: [String: UICollectionViewLayoutAttributes] = [:]

        let columnWidth: Double = (collectionView.bounds.width - cellSpacing * Double(numberOfColumns - 1) - sectionInsets.leading - sectionInsets.trailing) / Double(numberOfColumns)

        let numberOfSections: Int = collectionView.numberOfSections

        for section in 0..<numberOfSections {

            var currentColumn: Int = 0
            var currentRow: Int = 0

            let numberOfItems: Int = collectionView.numberOfItems(inSection: section)

            for item in 0..<numberOfItems {

                let itemIndexPath = IndexPath(item: item, section: section)
                let itemAttributes = UICollectionViewLayoutAttributes(forCellWith: itemIndexPath)

                guard let itemID = collectionViewDataSource.itemIdentifier(for: itemIndexPath) else {
                    return
                }

                let itemHeight = layoutAttributes[itemID]?.bounds.height ?? 140
                let itemWidth = columnWidth

                if currentColumn + 1 > numberOfColumns {
                    currentColumn = 0
                    currentRow += 1
                }

                let originX = sectionInsets.leading + columnWidth * Double(currentColumn) + cellSpacing * Double(currentColumn)
                let originY = sectionInsets.top + cellHeight * Double(currentRow) + rowSpacing * Double(currentRow)

                

                if let existingAttributes = layoutAttributes[itemID] {
                    print("Using existing attributes for: \(existingAttributes.indexPath)")
                    itemAttributes.frame = CGRect(
                        x: originX,
                        y: originY,
                        width: existingAttributes.frame.width,
                        height: existingAttributes.frame.height
                    )
                }
                else {
                    itemAttributes.frame = CGRect(
                        x: originX,
                        y: originY,
                        width: itemWidth,
                        height: itemHeight
                    )
                }

                itemAttributes.zIndex = itemIndexPath.item

                updatedLayoutAttributes[itemID] = itemAttributes

                currentColumn += 1
            }
        }

        layoutAttributes = updatedLayoutAttributes
    }

    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        var allAttributes: [UICollectionViewLayoutAttributes] = []

        for (_, attributes) in layoutAttributes {
            if (rect.intersects(attributes.frame)) {
                allAttributes.append(attributes)
            }
        }

        return allAttributes
    }

    override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        guard let collectionViewDataSource else {
            return nil
        }

        guard let itemID = collectionViewDataSource.itemIdentifier(for: indexPath) else {
            return nil
        }

        return layoutAttributes[itemID]
    }

    override var collectionViewContentSize: CGSize {
        guard let collectionView else {
            return .zero
        }
        let contentHeight: CGFloat = layoutAttributes.map({ $0.value.frame.maxY }).max() ?? 0
        return CGSize(width: collectionView.bounds.width, height: contentHeight)
    }

    override func shouldInvalidateLayout(forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> Bool {
        return originalAttributes.frame.height.rounded() != preferredAttributes.frame.height.rounded()
    }

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

        guard let collectionViewDataSource else {
            return context
        }

        guard let itemID = collectionViewDataSource.itemIdentifier(for: preferredAttributes.indexPath) else {
            return context
        }

        layoutAttributes[itemID]?.frame.size = preferredAttributes.frame.size

        context.invalidateItems(at: [preferredAttributes.indexPath])
        return context
    }
}
Accepted Answer

It seems I have solved the issue by using item IDs instead of IndexPaths to cache layout attributes.

Here is the final UICollectionViewLayout code (rough draft, not cleaned up):

final class CustomLayout: UICollectionViewLayout {

    // Configurable properties
    public var numberOfColumns: Int = 6
    public var cellHeight: Double = 200
    public var cellSpacing: Double = 20
    public var rowSpacing: Double = 20
    public var sectionInsets: NSDirectionalEdgeInsets = .zero

    public var layoutAttributes: [String: UICollectionViewLayoutAttributes] = [:]

    private var collectionViewDataSource: UICollectionViewDiffableDataSource<String, String>? {
        return collectionView?.dataSource as? UICollectionViewDiffableDataSource<String, String>
    }

    override func prepare() {
        super.prepare()

        guard
            let collectionView,
            let collectionViewDataSource
        else {
            return
        }

        var updatedLayoutAttributes: [String: UICollectionViewLayoutAttributes] = [:]

        let columnWidth: Double = (collectionView.bounds.width - cellSpacing * Double(numberOfColumns - 1) - sectionInsets.leading - sectionInsets.trailing) / Double(numberOfColumns)

        let numberOfSections: Int = collectionView.numberOfSections

        for section in 0..<numberOfSections {

            var currentColumn: Int = 0
            var currentRow: Int = 0

            let numberOfItems: Int = collectionView.numberOfItems(inSection: section)

            for item in 0..<numberOfItems {

                let itemIndexPath = IndexPath(item: item, section: section)
                let itemAttributes = UICollectionViewLayoutAttributes(forCellWith: itemIndexPath)

                guard let itemID = collectionViewDataSource.itemIdentifier(for: itemIndexPath) else {
                    return
                }

                let itemHeight = layoutAttributes[itemID]?.bounds.height ?? 140
                let itemWidth = columnWidth

                if currentColumn + 1 > numberOfColumns {
                    currentColumn = 0
                    currentRow += 1
                }

                let originX = sectionInsets.leading + columnWidth * Double(currentColumn) + cellSpacing * Double(currentColumn)
                let originY = sectionInsets.top + cellHeight * Double(currentRow) + rowSpacing * Double(currentRow)

                

                if let existingAttributes = layoutAttributes[itemID] {
                    print("Using existing attributes for: \(existingAttributes.indexPath)")
                    itemAttributes.frame = CGRect(
                        x: originX,
                        y: originY,
                        width: existingAttributes.frame.width,
                        height: existingAttributes.frame.height
                    )
                }
                else {
                    itemAttributes.frame = CGRect(
                        x: originX,
                        y: originY,
                        width: itemWidth,
                        height: itemHeight
                    )
                }

                itemAttributes.zIndex = itemIndexPath.item

                updatedLayoutAttributes[itemID] = itemAttributes

                currentColumn += 1
            }
        }

        layoutAttributes = updatedLayoutAttributes
    }

    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        var allAttributes: [UICollectionViewLayoutAttributes] = []

        for (_, attributes) in layoutAttributes {
            if (rect.intersects(attributes.frame)) {
                allAttributes.append(attributes)
            }
        }

        return allAttributes
    }

    override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        guard let collectionViewDataSource else {
            return nil
        }

        guard let itemID = collectionViewDataSource.itemIdentifier(for: indexPath) else {
            return nil
        }

        return layoutAttributes[itemID]
    }

    override var collectionViewContentSize: CGSize {
        guard let collectionView else {
            return .zero
        }
        let contentHeight: CGFloat = layoutAttributes.map({ $0.value.frame.maxY }).max() ?? 0
        return CGSize(width: collectionView.bounds.width, height: contentHeight)
    }

    override func shouldInvalidateLayout(forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> Bool {
        return originalAttributes.frame.height.rounded() != preferredAttributes.frame.height.rounded()
    }

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

        guard let collectionViewDataSource else {
            return context
        }

        guard let itemID = collectionViewDataSource.itemIdentifier(for: preferredAttributes.indexPath) else {
            return context
        }

        layoutAttributes[itemID]?.frame.size = preferredAttributes.frame.size

        context.invalidateItems(at: [preferredAttributes.indexPath])
        return context
    }
}
UICollectionViewLayout unexpected animations when cells contain AutoLayout views with custom height
 
 
Q