尽管 UICollectionView
是iOS 日常开发遇到的高频控件之一,但很多时候我们对其使用仅仅是为了满足一些横向滚动的场景。再复杂的场景我们可能继承UICollectionViewFlowLayout
然后稍作调整。重头开始写一个UICollectionViewLayout
? Oh, no. 这真的太复杂了。但是自定义 UICollectionViewLayout
可以帮助我们深度定制 UI 和行为以及针对性的优化滚动性能。那么就让我带大家来重新认识一下UICollectionViewLayout
。
这个系列计划分为两篇,分别是:
-
了解
UICollectionViewLayout
在这篇我们会先了解
UICollectionViewLayout
的设计思想、排版规则以及方法时序 -
在初步了解
UICollectionViewLayout
的工作原理后,我会以视频号的瀑布流界面为例思考如何优化UICollectionViewLayout
的性能, 以及如何实现Header悬停等效果
布局核心数据结构
在 xcode 中打开UICollectionViewLayout.h
我们会看到几个和UICollectionViewLayout
相关的核心类。他们是:
-
UICollectionViewLayoutAttributes
UICollectionViewLayoutAttributes
是非常重要的一个数据模型,它负责记录 cell 的布局信息,如frame
、bounds
、transform3D
、zIndex
等。通过对其属性的设置,我们可以很方便的控制 cell 的 UI 形态。UICollectionViewLayoutAttributes
有三个初始化构造方法,分别用于 cell、supplementaryView、decorationView 的创建,注意不要使用其init
方法。从类定义我们可以看到
UICollectionViewLayoutAttributes
实现NSCopying
协议,这意味着我们可以很方便的实现深拷贝,这对于我们在布局时记录数据有很大的作用。 -
UICollectionViewLayoutInvalidationContext
UICollectionViewLayoutInvalidationContext
用于标记无效的信息,以便于我们部分更新布局数据,而不是全量更新布局数据。本文不会对其做过多介绍,我们会在下一篇性能优化再做详细讲解。 -
UICollectionViewLayout
UICollectionViewLayout
作为我们自定义布局要继承的父类,UICollectionViewLayout
自然是非常重要。如果单单看类定义似乎非常简单,但其实我们需要实现的核心方法都定义在UICollectionViewLayout (UISubclassingHooks)
,如prepareLayout
、layoutAttributesForElementsInRect:
、layoutAttributesForItemAtIndexPath
等。
布局核心过程
让我们想一下 collectionview 的布局过程,其实就是 collectionview 和 layout的沟通过程。当 collectionview 需要布局信息的时候,他会通过特定的方法来向 layout 获取。
你的自定义 layout 必须实现一下方法:
-
collectionViewContentSize
这个方法返回 collection view 内容的尺寸(contentSize)。注意这个方法需要的是全部内容的宽高,而不是可视内容的宽高。
-
prepare
任何时候当一个新的布局过程发生时,UIKit 都会先调用这个方法。你可以在这个时候准备一些布局需要的数据。
什么是布局需要的数据呢?举个例子,如果我们这个布局是两列并排的数据流,并且每个 cell 各占 collectionview 的一半,那么我们可以在这个方法通过 collectionview 的宽度来计算出 cell 的宽度,而不需要依赖调用方来提供宽度。一般来说不建议在这个方法里面计算出所有视图的布局信息,在数据量大且 cell 布局复杂的时候,这可能导致严重的卡顿。一些有上下依赖的情况,如非等高的cell,在数据量不大的时候则可以提前在这个方法里面计算好布局。
-
layoutAttributesForElements(in:):
在这个方法你需要返回在 rect 范围内的所有可视item。无论是cell、supplementaryView还是decorationView 的布局信息都是放在一个 array 里面返回。
-
layoutAttributesForItem(at:):
这个方法提供最终的布局信息给 collectionView。你需要提供indexpath 对应的 cell 的布局信息(UICollectionViewLayoutAttributes)。
计算布局属性
上面我们知道了我们需要实现什么方法,但是我们应该怎样计算布局属性呢?为了方便接下来的讲述,我会使用最常见的瀑布流 StreamLayout 来做例子。
首先对于一个瀑布流来说,你需要动态的计算每一个 item 的高度,也就是需要声明一个 protocol 来获取信息。
那么回到代码,在实现我们的 StreamLayout 之前,我们需要声明 protocol
@protocol StreamLayoutDelegate <UICollectionViewDelegate>
- (CGFloat)collectionView:(UICollectionView *)collectionView
layout:(WCFinderStreamLayout2 *)collectionViewLayout
cellHeightAtIndexPath:(NSIndexPath *)indexPath
withWidth:(CGFloat)width;
@end
实现这个 protocol 的实例就需要实现这个方法来提供每个 cell 的高度。在我们开始写布局代码之前,我们需要在 StreamLayout 中声明一些属性来帮助布局。
@interface StreamLayout : UICollectionViewLayout
@property (nonatomic, assign) NSUInteger columnCount;
@property (nonatomic, assign) CGSize cellSpace;
@property (nonatomic, assign) CGFloat cellHeight;
@property (nonatomic, assign) CGSize contentSize;
@property (nonatomic, strong) NSMutableDictionary<NSNumber *, UICollectionViewLayoutAttributes *> *cellsAttr;
@end
从属性的命名上,我们可以很容易的理解其作用。其中 cellsAttr 是所有 cell 的布局信息缓存,这样可以避免大部分的重复计算。
现在,你有了计算布局属性的所有信息,可以得到所有cell 的位置,为了让大家更容易理解计算的过程,看下图:
计算布局的过程其实就是计算每个 cell 的 frame 的过程,在这个过程中,你需要积累计算每个 cell 的 xOffset、yOffset。在我们这个例子中,假设我们的数据量级不大,那么可以在 prepareLayout
中就计算出所有的cell 的布局信息。
代码如下:
- (void)prepareLayout {
[super prepareLayout];
//1.
if (self.cellsAttr) {
return;
}
self.cellsAttr = [NSMutableDictionary dictionary];
NSUInteger cellCount = [self.collectionView.dataSource collectionView:self.collectionView numberOfItemsInSection:section];
if (cellCount == 0) {
return;
}
NSUInteger columnCount = self.columnCount;
CGSize cellSpace = self.cellSpace;
CGFloat rowSpace = MAX(0.0, cellSpace.width);
CGFloat columnSpace = MAX(0.0, cellSpace.height);
CGFloat currentMaxY = edgeInsets.top;
//2.
NSMutableArray<NSNumber *> *columnHeights = [NSMutableArray array];
for (int i = 0; i < columnCount; i++) {
[columnHeights addObject:@(0)];
}
CGFloat maxHeight = 0;
for (int i = 0; i < cellCount; i++) {
//3.
UICollectionViewLayoutAttributes *attrs =
[UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:[NSIndexPath indexPathForItem:i inSection:section]];
if (!attrs) {
continue;
}
CGFloat width = (self.collectionView.width - (columnCount - 1) * rowSpace) / columnCount;
CGFloat cellHeight = self.cellHeight;
if ([self.delegate respondsToSelector:@selector(collectionView:layout:cellHeightAtIndexPath:withWidth:)]) {
cellHeight = [self.delegate collectionView:self.collectionView
layout:self
cellHeightAtIndexPath:[NSIndexPath indexPathForItem:i inSection:section]
withWidth:width];
}
cellHeight = MAX(0.0, cellHeight);
//4.
__block CGFloat minHeight = CGFLOAT_MAX;
__block NSUInteger minIndex = 0;
[columnHeights enumerateObjectsUsingBlock:^(NSNumber *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
if (obj.floatValue < minHeight && obj.floatValue >= 0) {
minHeight = obj.floatValue;
minIndex = idx;
}
}];
CGFloat offsetY = currentMaxY + minHeight;
CGFloat newColumnHeight = minHeight + cellHeight + columnSpace;
attrs.frame = CGRectMake((width + rowSpace) * minIndex, offsetY, width, cellHeight);
columnHeights[minIndex] = @(newColumnHeight);
maxHeight = MAX(maxHeight, newColumnHeight - columnSpace);
self.cellsAttr safeSetObject:attrs forKey:@(i)];
}
}
- 仅当缓存数据不存在的时候才计算
- 新建数组用来搜集每一列的最新高度
- 生成UICollectionViewLayoutAttributes
- 循环计算每一个 cell 的布局,每一个 cell 会被安排到最小高度的列。
因为 prepareLayout 在每次布局过程中都会被调用,而有很多情况很导致重新布局,比如 collectionview 的 size 发生变化,所以在特定时刻如
invalidationContextForBoundsChange
需要清除缓存。本篇暂时不考虑这种情况。
得到了所有 cell 的布局信息,我们就需要把数据传递给 collectionview。
- (NSArray<UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect {
if (self.cellsAttr == nil) {
return nil;
}
NSMutableArray *attrs = [NSMutableArray array];
[[self.cellsAttr allValues]
enumerateObjectsUsingBlock:^(UICollectionViewLayoutAttributes *_Nonnull cell, NSUInteger idx, BOOL *_Nonnull stop) {
if (CGRectIntersectsRect(cell.frame, rect)) {
[attrs safeAddObject:cell];
}
}];
return attrs;
}
- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath {
return self.cellsAttr[@(indexPath.row)];
}
在第一个方法中,我们找出了所有在 rect 范围的 cell,而在第二个方法中,我们返回了 indexpath 下对应的布局信息
注意:尽管两个方法都返回了UICollectionViewLayoutAttributes,但实际布局只会采用
layoutAttributesForItemAtIndexPath
返回的布局属性。
总结
在这篇简单的文章中,我们写了一个简单的瀑布流布局 StreamLayout,简单的了解了 UICollectionViewLayout 的核心内容。在下一篇性能优化,我们再来看看如何写出高性能的自定义UICollectionViewLayout吧。