最近遇到一个需求对UICollectionView自定义layout的状况,之前对这块不太熟悉,研讨了一下,在此记载一下结论。
一、背景
考虑以下场景:
- 每个cell自己依据内部状况决议布局高度
- 每个cell的算高比较耗时,DataSource中数据较多时,为确保用户体会,希望能按需算高(即只算屏幕内显现的,而不是从第一条数据开端算)
- 有设置DataSource以后从中心某个cell开端显现的需求
二、问题
通过一番调研,发现UITableView、UICollectionViewFlowLayout等的预估高度都是只预估尾部未展现cell的高度,而顶部不论是否显现都有必要核算实在高度。
也便是说,假如DataSource中有100条数据,而我们希望从第50条开端显现的话,前 0~49个cell即便并不展现,也需求核算实在高度。
在每个cell算高开销较大的状况下,这无疑极大地拖慢了显现和翻滚时刻。
也便是说现有预估高度机制不能满足顶部刺进数据的状况,无法细粒度操控算高规模。
三、方案
UICollectionView支持自定义Layout,只需求承继 UICollectionViewLayout并完成相关办法即可。UIKit供给了许多回调机遇和更新机制,我们能不能定制一下布局流程,完成一个按需算高的自定义layout呢?
3.1 UICollectionViewLayout 子类重载点
下面先来看一下一个自定义 UICollectionViewLayout 都有哪些要害办法能够完成:
@interface UICollectionViewLayout (UISubclassingHooks)
- (void)prepareLayout;
- (nullable NSArray<__kindof UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect;
- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds;
- (UICollectionViewLayoutInvalidationContext *)invalidationContextForBoundsChange:(CGRect)newBounds;
- (void)invalidateLayoutWithContext:(UICollectionViewLayoutInvalidationContext *)context;
@property(nonatomic, readonly) CGSize collectionViewContentSize;
@end
网上参考了许多自定义布局的比如,自己也做了一些试验,看起来的“最佳实践”是:
-
prepareLayout
是布局更新的第一个回调,最好在这个办法里核算出下次布局更新前一切可能会用到的布局信息 -
collectionViewContentSize
用于回来整体内容巨细,一般prepareLayout之后就会调用了,其内容应由prepareLayout中一切的元素高度决议 -
layoutAttributesForElementsInRect
在翻滚进程中会不断被调用,它的完成应该是只查询prepareLayout
中缓存下来的落在参数rect
中的cell的布局特点(而不是依据rect做实时核算) -
shouldInvalidateLayoutForBoundsChange
会在bounds特点产生更新的时分被调用(如顶部下拉),能够依据实践需求场景,决议此次bounds更新是否要触发从头布局。假如回来YES,则后边UIKit会顺次调用invalidationContextForBoundsChange
生成一个invalidationContext实例(重载此办法可定制生成的实例), 然后调用invalidateLayoutWithContext
(参数为方才生成的invalidationContext实例),然后在下一次布局时(runloop结束前)触发prepareLayout
。
bounds更新有两种状况:origin更新和size更新:
- size更新便是宽高产生了改变,这时一般都需求从头布局;
- bounds.origin一般状况下是(0, 0),但在其翻滚超出鸿沟的时分,这个bounds会接连屡次被设置(这部分不太熟悉的能够查一下frame和bounds的差异)。
-
invalidationContextForBoundsChange
这个回调中能够回来自定义的invalidationContext的实例,并设置相关特点,以供后边invalidateLayoutWithContext运用 -
ininvalidateLayoutWithContext
的参数可能是体系结构的context实例,也可能是上面自己结构的自定义context实例,在此办法中要依据不同实例和特点做布局缓存铲除操作
3.2 按需算高具体机制
先考虑下拉的状况,我们举一个具体的比如。
假定DataSource中一共有100条数据,默许从第50条数据开端显现,到第55条停止。也即首屏应该只核算[50, 55]的高度。
由上一节描述的各重载点的回调机遇可知,应该在 prepareLayout 办法中核算第50~55条数据的高度,为了记载当时需求显现的 indexPath 起点和结尾,我们需求给 layout 方针添加两个特点:
@interface CustomLayout : UICollectionViewLayout
@property (nonatomic, strong) NSIndexPath *firstVisibleIndexPath;
@property (nonatomic, strong) NSIndexPath *lastVisibleIndexPath;
...
@property (nonatomic, weak) id<CustomLayoutDelegate> delegate;
@end
那么在 preapreLayout 办法中,就能够对这两个indexPath之间的一切数据进行算高:
- (void)preapareLayout {
...
//有缓存则运用缓存,直接回来
if (_cachedAttributes.count) {
return;
}
...
//创建布局特点缓存,供后边查询
_cachedAttributes = [NSMutableArray array];
//累计高度,用于设置各cell布局特点中的y和整体的collectionViewContentSize
double cumulatedHeight = 0;
//遍历 firstVisibleIndexPath 和 lastVisibleIndexPath 之间一切indexPath
for (NSIndexPath *indexPath in ...) {
//核算高度,需由delegate完成
CGSize itemSize = [self.delegate itemSizeAtIndexPath:indexPath];
cumulatedHeight += itemSize.height;
UICollectionViewLayoutAttributes *attr = [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];
attr.frame = CGRectMake(0, cumulatedHeight, itemSize.width, itemSize.height);
//缓存成果
[_cachedAttributes addObject:attr];
}
//记载总高度
_totalHeight = cumulatedHeight;
}
在 collectionContentViewSize 中直接回来方才记载的 _totalHeight 特点即可:
- (CGSize)collectionContentViewSize
{
return CGSizeMake(CGRectGetWidth(self.collectionView.bounds), _totalHeight);
}
下一步,体系就会回调 layoutAttributesForElementsInRect 了。这是个非常重要的办法,它直接决议了各个cell在屏幕上的方位。此办法回调时传入的 rect 参数是UIKit决议的,其考虑了预取、翻滚方向等多种要素,目测其宽高是一个跟屏幕巨细相关的值(倍数),触顶下拉时rect.orign.y会是一个负值。
这个办法里应该能够做一些tricky的事,但考虑到其调用频次、参数、机遇的杂乱性,参考上一节的“最佳实践”,在这儿不做什么杂乱的事,只查询prepareLayout生成的缓存数据:
- (NSArray<__kindof UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect
{
NSMutableArray *result = [NSMutableArray array];
for ( attr in _cachedAttributes) {
//有交集则添加到成果中
if (CGRectIntersects(attr.frame, rect)) {
[result addObject:attr];
}
}
return result;
}
假如其他相关部分现已都连好了,这样一运行,就能在屏幕上看到布局好的第 50~55 条数据了。
但是怎么处理下拉时动态展现第 49 条呢?留意,这儿的动态展现包含了几点根本需求:
- 界面不能跳,第49条数据所在的cell要逐渐拉出来
- 翻滚惯性要坚持,假如用户做了个惯性下滚,前面的49、48、47等要顺次出来,不能中心卡住
这儿就要用到 shouldInvalidateLayoutForBoundsChange 了。
当collectionView现已触顶,用户持续下拉时,体系就会回调 shouldInvalidateLayoutForBoundsChange,并传入一个 origin.y < 0 的 newBounds,这儿我们只需求判断这种拉到鸿沟的状况,并回来YES告诉体系更新布局即可:
- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds
{
if (newBounds.origin.y < 0) {
return YES;
}
return [super shouldInvalidateLayoutForBoundsChange:newBounds];
}
如前所述,此办法回来 YES 后,UIKit紧接着就会回调 invalidationContextForBoundsChange 用于生成一个定制化的invalidationContext。
这儿,我们需求依据newBounds中给出的偏移算出布局更新后新的firstVisibleIndexPath,也便是说要往前加载几个indexPath才干填满newBounds.origin.y,在我们的比如中,这个targetIndexPath将是 49。
留意,这儿有两个要害点,一个是要找到新的起始indexPath 49并存到invalidationContext中,另一个是要设置invalidationContext的contentOffsetAdjustment特点以确保新布局下的展现方位与现在完全一致,这样才干看起来是比较顺畅的翻滚。
具体一点说,假定当时boundsChange的时分 newBounds.origin.y = -20(也便是用户下拉了20个point),而 cell 49 的高度为 200。那么布局更新后(也便是下次 prepareLayout 调用完成后),contentOffset有必要是是180,才干确保布局更新前后cell 50的方位是不变的。
- (UICollectionViewLayoutInvalidationContext *)invalidationContextForBoundsChange:(CGRect)newBounds
{
if (newBounds.origin.y < 0) {
CustomInvalidationContext *invalidationContext = [[CustomINvalidationContext alloc] init];
//下拉出来的空白区域高度
double gap = -newBounds.origin.y;
//往前遍历一直找到targetIndexPath,使得targetIndexPath与firstVisibleIndexPath之间的高度 >= gap
NSIndexPath *indexPath = self.firstVisibleIndexPath;
double cumulatedLength = 0;
while (cumulatedLength < gap) {
//找indexPath的上一个indexPath,需求由delegate完成
NSIndexPath *nextIndexPath = [self.delegate previousIndexPathForIndexPath:indexPath];
if (nextIndexPath) {
//核算高度,需由delegate完成
CGSize itemSize = [self.delegate itemSizeAtIndexPath:nextIndexPath];
cumulatedLength += itemSize.height;
indexPath = nextIndexPath;
} else {
//前面已无其他indexPath,跳出
break;
}
}
//记载方针indexPath
invalidationContext.targetStartIndexPath = indexPath;
//需求在这儿设置contentOffsetAdjustment,以在新一轮布局更新(prepareLayout)前设置新布局下的contentOffset,确保视觉上不会看到跳动
invalidationContext.contentOffsetAdjustment = CGPointMake(0, cumulatedHeight);
return invalidationContext;
}
return [super ...];
}
紧接着,UIKit会回调 invalidateLayoutWithContext 办法,这儿传入的context便是方才生成的CustomInvalidationContext实例。需求在这儿更新self.firstVisibleIndexPath,并铲除 _cachedAttributes:
- (void)invalidateLayoutWithContext:(UICollectionViewLayoutInvalidationContext *)context
{
[super invalidateLayoutWithContext:context];
if (context.invalidateEverything || context.invalidateDataSourceCounts) {
[_cache removeAllObjects];
return;
}
if ([context isKindOfClass:[CustomInvalidationContext class]]) {
if (context.targetFirstIndexPath) {
self.firstVisibleIndexPath = context.targetFirstIndexPath;
}
[_cachedAttributes removeAllObjects];
}
}
这样就完成了,运行起来就能正常下拉了。下拉多少firstVisibleIndexPath就向前移动多少,每次只核算有必要的部分。
当然,取决于具体状况和测验成果,也能够动态调整估计预核算的规模(即并不是只填满gap就算,而是找一个与屏幕巨细相关的高度),不过,单次核算的越多,越可能超过16ms导致掉帧,一共要显现的cell就那么多,核算总量是确定的,需求tradeoff的是从头布局的次数和单次的核算量。
上面讨论了下拉的状况,上拉流程根本类似,读者可自行补充。另外,lastVisibleIndexPath 也不是有必要的,实践中应该由firstVisibleIndexPath加上屏幕高度或collectionView高度得到一个适宜的lastVisibleIndexPath,在此不再细化。
最后,总结一下 CustomLayout delegate 的接口,还比较简洁,按需相关的只需求新增两个往前和往后遍历indexPath的办法即可:
@protocol CustomLayoutDelegate <NSObject>
- (CGSize)itemSizeAtIndexPath:(NSIndexPath *)indexPath;
- (NSIndexPath *)nextIndexPathForIndexPath:(NSIndexPath *)indexPath;
- (NSIndexPath *)previousIndexPathForIndexPath:(NSIndexPath *)indexPath;
@end