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.
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?
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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With