UICollectionViewLayout子类中合法获取单元格尺寸计算布局的方法
问题描述
我正在编写自定义UICollectionViewLayout子类,想要完全控制单元格的布局排列。原本的思路是先让单元格自适应内容得到初始尺寸,再基于这个尺寸调整框架。于是在prepare()方法里通过集合视图的dataSource获取所有单元格,用systemLayoutSizeFitting(_:)计算尺寸:
let allCells = allIndedxPaths.compactMap { collectionView.dataSource?.collectionView(collectionView, cellForItemAt: $0) } let allItemSizes = allCells.map { cell in cell.systemLayoutSizeFitting(.init(width: contentSize.width, height: UIView.noIntrinsicMetric)) }
视觉效果没问题,但每次布局控制台都会输出重入警告:
[CollectionView] An attempt to prepare a layout while a prepareLayout call was already in progress (i.e. reentrant call) has been ignored. Please file a bug.
显然布局准备阶段不能直接调用数据源的cellForItemAt:方法,想知道怎么合法获取布局所需的数据源信息,尤其是准确的单元格尺寸。
补充背景:
- Apple官方的
UICollectionViewFlowLayout支持自动单元格尺寸(estimatedItemSize),说明存在合理实现方式。 - Apple官方文档提到,布局对象在少数情况下可以依赖数据源的信息(比如地图布局获取坐标),说明有合法查询数据源的途径。
解决方案
方案1:让数据源提供尺寸计算所需的原始数据
这是最推荐的方式,避免直接操作Cell实例。让数据源暴露获取单元格内容的接口,布局类通过这些内容自行计算尺寸,或者让数据源直接提供预计算好的尺寸。
比如定义一个协议:
protocol TagListLayoutDataSource: UICollectionViewDataSource { func collectionView(_ collectionView: UICollectionView, contentForItemAt indexPath: IndexPath) -> String func collectionView(_ collectionView: UICollectionView, fontForItemAt indexPath: IndexPath) -> UIFont }
然后在布局类里通过这个协议获取内容,计算尺寸:
override func prepare() { guard let collectionView = collectionView, let dataSource = collectionView.dataSource as? TagListLayoutDataSource else { return } contentSize = CGSize(width: collectionView.bounds.width, height: 0) let itemCount = collectionView.numberOfItems(inSection: 0) var allItemSizes: [CGSize] = [] for item in 0..<itemCount { let indexPath = IndexPath(item: item, section: 0) let text = dataSource.collectionView(collectionView, contentForItemAt: indexPath) let font = dataSource.collectionView(collectionView, fontForItemAt: indexPath) // 计算文本尺寸 let size = text.size(withAttributes: [.font: font]) // 加上Cell内部的padding let cellSize = CGSize(width: size.width + 16, height: size.height + 8) allItemSizes.append(cellSize) } // 后续的frame计算逻辑不变... }
方案2:使用估算尺寸 + 动态更新布局
参考UICollectionViewFlowLayout的estimatedItemSize逻辑,先设置一个估算尺寸,在Cell显示后获取真实尺寸,再更新布局。
- 先在布局类里添加估算尺寸属性:
var estimatedItemSize: CGSize = CGSize(width: 100, height: 30)
- 在
prepare()里先用估算尺寸计算初始布局:
override func prepare() { guard let collectionView = collectionView else { return } contentSize = CGSize(width: collectionView.bounds.width, height: 0) let itemCount = collectionView.numberOfItems(inSection: 0) let allItemSizes = Array(repeating: estimatedItemSize, count: itemCount) frames = computeFrames(for: allItemSizes) // 缓存属性... contentSize.height = frames.map(\.value.maxY).max() ?? 0 }
- 监听Cell的显示,获取真实尺寸后更新布局:
可以在Cell的layoutSubviews里触发布局更新:
class TagCell: UICollectionViewCell { override func layoutSubviews() { super.layoutSubviews() guard let collectionView = superview as? UICollectionView, let layout = collectionView.collectionViewLayout as? SelfSizingTagListLayout, let indexPath = collectionView.indexPath(for: self) else { return } let realSize = systemLayoutSizeFitting(CGSize(width: collectionView.bounds.width, height: UIView.noIntrinsicMetric)) // 如果真实尺寸和估算的不一样,触发布局更新 if layout.cachedAttributes[indexPath]?.size != realSize { collectionView.performBatchUpdates(nil) } } }
这种方式的好处是不需要提前计算所有Cell的尺寸,只需要处理可见的Cell,性能更好。
方案3:手动实例化Cell计算尺寸
如果必须通过Cell的systemLayoutSizeFitting计算尺寸,不要调用数据源的cellForItemAt:,而是手动实例化Cell并配置内容,再计算尺寸:
override func prepare() { guard let collectionView = collectionView else { return } contentSize = CGSize(width: collectionView.bounds.width, height: 0) let itemCount = collectionView.numberOfItems(inSection: 0) var allItemSizes: [CGSize] = [] for item in 0..<itemCount { let indexPath = IndexPath(item: item, section: 0) // 手动实例化Cell let cell = TagCell() // 从数据源获取内容并配置Cell if let dataSource = collectionView.dataSource as? TagListLayoutDataSource { dataSource.collectionView(collectionView, configureCell: cell, at: indexPath) } // 计算尺寸 let size = cell.systemLayoutSizeFitting(CGSize(width: contentSize.width, height: UIView.noIntrinsicMetric)) allItemSizes.append(size) } // 后续的frame计算逻辑不变... }
这里需要给数据源添加一个配置Cell的协议方法:
protocol TagListLayoutDataSource: UICollectionViewDataSource { func collectionView(_ collectionView: UICollectionView, configureCell cell: UICollectionViewCell, at indexPath: IndexPath) }
这种方式避免了重入问题,但需要确保手动实例化的Cell和数据源返回的Cell配置逻辑一致,同时如果Cell数量多的话,性能可能不如方案1。
总结
优先选择方案1,让数据源提供原始内容或预计算尺寸,这是最符合CollectionView设计模式的方式;如果无法修改数据源,考虑方案3手动实例化Cell计算尺寸;如果追求性能,尤其是Cell数量多的场景,推荐方案2的估算尺寸+动态更新。
内容的提问来源于stack exchange,提问作者Mischa

