具有自动调整大小的单元格的自定义 UICollectionViewLayout 会因估计的项目高度较大而中断

Posted

技术标签:

【中文标题】具有自动调整大小的单元格的自定义 UICollectionViewLayout 会因估计的项目高度较大而中断【英文标题】:Custom UICollectionViewLayout w/ auto-sizing cells breaks with larger estimated item heights 【发布时间】:2018-04-13 20:41:32 【问题描述】:

我正在构建一个支持自动调整单元格大小的自定义 UICollectionViewLayout,当估计的项目高度大于最终高度时,我遇到了一个问题。当首选布局属性触发部分失效时,下面的某些单元格变得可见,但并非所有单元格都应用了正确的框架。

在下图中,左侧屏幕截图显示了估计高度较大的初始渲染,右侧图像显示了估计高度小于最终高度的位置。

此问题出现在 ios 10 和 11 上。

估计高度越小,布局期间内容大小会增加,首选布局属性不会导致更多项目移动到可见矩形中。集合视图完美地处理了这种情况。

失效和框架计算的逻辑似乎是有效的,所以我不确定为什么集合视图没有处理部分失效导致新项目进入视图的情况。

当更深入地检查时,似乎应该被移入视图的最终视图正在失效并被要求计算它们的大小,但它们的最终属性没有被应用。

这是一个非常精简的自定义布局版本的布局代码,用于展示此故障的演示:

/// 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
    


这是单元格的首选布局属性计算(请注意,我们让布局决定并固定宽度,并且我们要求自动布局计算给定宽度的单元格的高度)。

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
    

在布局逻辑中是否存在明显的原因?

【问题讨论】:

我想我有一个非常相似的问题here。你解决过你的问题吗? 另外,我 discovered 在单元格上调用 systemLayoutSizeFittingSize 不一定能达到您的预期。但是在contentView 上调用它。 systemLayoutSizeFitting 是做什么的?我读过文档,但不明白。在 WWDC 上也找不到任何东西... 【参考方案1】:

我已经通过 set 复制了这个问题

estimatedItemHeight = 500

在演示代码中。我对你为每个cell: 计算框架的逻辑有疑问,self.heightCache 中的所有高度都为零,所以声明

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

在函数frame中与

return $0 + self.spacing + self.estimatedItemHeight

我想也许你应该检查一下这段代码

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

在函数中

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

因为preferredAttributes.size.height 总是为零

最终高度

在类中也是零

单元格

【讨论】:

【参考方案2】:

我同样在尝试为UITableView 样式的布局构建一个自定义的UICollectionViewLayout 子类,并且我遇到了slightly different problem。但我发现在shouldInvalidateLayoutForPreferredLayoutAttributes 中,如果您根据首选高度是否与原始高度匹配(而不是首选高度是否与您缓存中的高度匹配)返回,它将正确应用布局属性和您的所有单元格将具有正确的高度。

但是随后您会丢失单元格,因为在发生自动调整大小并修改单元格高度(以及因此 y 位置)之后,您并不总是被重新查询 layoutAttributesForElementsInRect

在 GitHub 上查看示例项目 here。

编辑:我的问题得到了解答,GitHub 上的示例现在正在运行。

【讨论】:

以上是关于具有自动调整大小的单元格的自定义 UICollectionViewLayout 会因估计的项目高度较大而中断的主要内容,如果未能解决你的问题,请参考以下文章

UIImageView 在具有不同高度的自定义 UITableViewCell 内自动调整大小

具有自动布局的自定义单元格的动态高度

故事板中的自定义单元格行高度设置没有响应

动态调整自定义 xib 单元格的大小

如何在 TableView 中调整自定义单元格的大小?

具有自我调整大小的单元格的 UICollectionViewFlowLayout 在 reloadData 后崩溃