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!
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
}
}