前言
UICollectionView是个强大的视图,除了提供默认的UICollectionViewFlowLayout布局外,还能让程序员自定义自己的布局,下文将记录笔者利用UICollectionView实现瀑布流列表时的一些经验与体会。
1、关于 UICollectionView
关于UICollectionView,可以推荐看下objccn.io上的这篇文章自定义 Collection View 布局,写的十分详细,在这篇文章的帮助下,我快速的理解了UICollectionView的运作方式,打破了以前只懂最基本用法的尴尬情况。
在这里我给自己总结了一下:
与UITableView类似,UICollectionView也由对应的DataSource、Delegate驱动,熟练使用UITableView后在用UICollectionView时也一定能驾轻就熟。
与UITableView不同的是,UICollectionView还需要管理一个UICollectionViewLayout对象,而正是这个对象,默默地管理着所有Cell的布局,为UICollectionView带来了布局上无限的可能。
系统自带的UICollectionViewFlowLayout已经可以满足很多日常需求,UICollectionViewFlowLayout为我们隐藏了大量的细节,让我们以十分简单的方式使用UICollectionView。另外我们可以继承UICollectionViewLayout,实现自己的需求。
2、自定义 CollectionView 布局
- 何时应该自定义CollectionView布局?可以参考苹果文档:
Knowing When to Subclass the Flow Layout - 如何自定义CollectionView布局?还是苹果文档:
Creating Custom Layouts(强烈推荐)
简单的来说就是当UICollectionViewFlowLayout
所提供的常规布局不能满足你的需求时,即可继承UICollectionViewFlowLayout
进行功能扩展,或者继承UICollectionViewLayout
自定义自己的布局。例如:UICollectionView中的元素不像一个网格那样子整齐的排列,也不是按照线性排列(排满一行,然后再排到下一行)。
3、UICollectionViewLayout 都做了些什么
一直以来我都觉得,与其把代码通通贴上来完事,尝试去用自己的语言把那些干涩的代码阐述成更容易理解的内容(只要理解了其中的原理,就只剩敲代码的工作了),是一种更有效的学习方法。所以在继续下一步前,我想试试简单地描述一下UICollectionView中的布局对象都做了些什么。
还记得我们在使用UITableView的时候,通过设置Delegate,让其负责处理一些交互相关的事件,管理Cell的高度、Header Footer等外观,然后设置DataSource,让其负责管理数据的来源。我们使用同样的设计方式来配置UICollectionView,除了这点:
UICollectionViewDelegate
中并没有像UITableViewDelegate
中的- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath;
一类的方法。没错,这些原本放在Delegate当中用于返回Cell、Header等信息的方法都被苹果设计师抽离出去,放到了UICollectionViewLayout中去管理了。
参考苹果文档中的相关描述
During the layout process, the collection view calls specific methods of your layout object. These methods are your chance to calculate the position of items and to provide the collection view with the primary information it needs. Other methods may be called, too, but these methods are always called during the layout process in the following order:
- Use the prepareLayout method to perform the up-front calculations needed to provide layout information.
- Use the collectionViewContentSize method to return the overall size of the entire content area based on your initial calculations.
- Use the layoutAttributesForElementsInRect:
一旦
UICollectionView
需要刷新(放到屏幕上或需要reloadData
)或者被标记为需要重新计算布局(调用了layout对象的invalidateLayout
方法)时,UICollectionView
就会向布局对象请求一系列的方法:
- 首先会调用
prepareLayout
方法,在此方法中尽可能将后续布局时需要用到的前置计算处理好,每次重新布局都是从此方法开始。 - 调用
collectionViewContentSize
方法,根据第一点中的计算来返回所有内容的滚动区域大小,。 - 调用
layoutAttributesForElementsInRect:
方法,计算rect内相应的布局,并返回一个装有UICollectionViewLayoutAttributes
的数组,Attributes 跟所有Item一一对应,UICollectionView
就是根据这个Attributes来对Item进行布局,并当新的Rect区域滚动进入屏幕时再次请求此方法。 - 在
layoutAttributesForElementsInRect:
方法中,可以单独访问-layoutAttributesForItemAtIndexPath:
方法,来根据indexPath来请求layoutAttributes,虽然这一步不是必须的。
4、瀑布流布局
根据上面的流程我们就可以实现自己的瀑布流布局了,开始之前先整理一下瀑布流的特点:
- 瀑布流的列数是固定的,不会动态改变。
- 每个Item的高都不是固定的,是由Item的内容决定的。
- 布局时Item总是加到高度比较小的那一列上。
所以结合前一节的流程,需要做的事情有下面几个:
- 在实现的自定义布局中,需要用属性保存瀑布流中有多少列。
- 由于Item高度由Item内容决定,需要提供代理方法,由外部提供Item的高度到自定义布局中。
- 布局时Item的排列不是按从左到右从上到下这种简单的规律,所以需要我们花点代码计算维护每列的高度,才能算出所有Item排列的结果,得到整个UICollectionView的ContentSize。
- 最后在
layoutAttributesForElementsInRect:
方法中返回我们的计算后的结果。
实现:
第一第二点,用属性保存列数,提供代理方法让外部提供Item高度,所以在自定义布局中定义一个protocol,再声明一个实现了此协议的属性:
CollectionWaterfallLayout的头文件,继承UICollectionViewLayout
:
@protocol CollectionWaterfallLayoutProtocol <NSObject>
- (CGFloat)collectionViewLayout:(CollectionWaterfallLayout *)layout heightForRowAtIndexPath:(NSIndexPath *)indexPath;
@end
@interface CollectionWaterfallLayout : UICollectionViewLayout
@property (nonatomic, weak) id<CollectionWaterfallLayoutProtocol> delegate;
@property (nonatomic, assign) NSUInteger columns;
@end
剩下要做的就是自定义布局的核心所在,计算每个Item的布局。参照前面苹果文档的指引:
1.在prepareLayout
方法中处理布局的计算。
- (void)prepareLayout
{
[super prepareLayout];
//初始化数组
self.columnHeights = [NSMutableArray array];
for(NSInteger column=0; column<_columns; column++){
self.columnHeights[column] = @(0);
}
self.attributesArray = [NSMutableArray array];
NSInteger numSections = [self.collectionView numberOfSections];
for(NSInteger section=0; section<numSections; section++){
NSInteger numItems = [self.collectionView numberOfItemsInSection:0];
for(NSInteger item=0; item<numItems; item++){
//遍历每一项
NSIndexPath *indexPath = [NSIndexPath indexPathForItem:item inSection:section];
//计算LayoutAttributes
UICollectionViewLayoutAttributes *attributes = [self layoutAttributesForItemAtIndexPath:indexPath];
[self.attributesArray addObject:attributes];
}
}
}
2.在collectionViewContentSize
方法中返回ContentSize。
- (CGSize)collectionViewContentSize
{
NSInteger mostColumn = [self columnOfMostHeight];
//所有列当中最大的高度
CGFloat mostHeight = [self.columnHeights[mostColumn] floatValue];
return CGSizeMake(self.collectionView.bounds.size.width, mostHeight+_insets.top+_insets.bottom);
}
3.在layoutAttributesForElementsInRect:
方法中放回UICollectionViewLayoutAttributes
数组。
- (NSArray<UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect
{
return self.attributesArray;
}
4.在layoutAttributesForItemAtIndexPath:
中计算单个Item的layoutAttributes。
- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath
{
UICollectionViewLayoutAttributes *attributes = [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];
//外部返回Item高度
CGFloat itemHeight = [self.delegate collectionViewLayout:self heightForRowAtIndexPath:indexPath];
//找出所有列中高度最小的
NSInteger columnIndex = [self columnOfLessHeight];
CGFloat lessHeight = [self.columnHeights[columnIndex] floatValue];
//计算LayoutAttributes
CGFloat width = (self.collectionView.width-(_insets.left+_insets.right)-_columnSpacing*(_columns-1)) / _columns;
CGFloat height = itemHeight;
CGFloat x = _insets.left+(width+_columnSpacing)*columnIndex;
CGFloat y = lessHeight==0 ? _insets.top+lessHeight : lessHeight+_itemSpacing;
attributes.frame = CGRectMake(x, y, width, height);
//更新列高度
self.columnHeights[columnIndex] = @(y+height);
return attributes;
}
说明:
上面代码都有注释,应该比较好理解,主要就是计算Item在UICollectionView
中的frame。
需要注意的是,前面苹果文档中提到,可以在layoutAttributesForElementsInRect:
方法中调用layoutAttributesForItemAtIndexPath:
计算每个Item的Attributes,但我的做法是在prepareLayout
中就调用了,而layoutAttributesForElementsInRect:
只用来返回数组。这么做的原因是,在瀑布流中无法在不计算全部Item布局的情况下,得到CollectionView的ContentSize。
The attributes objects that your layout is responsible for are instances of the UICollectionViewLayoutAttributes class. These instances can be created in a variety of different methods in your app. When your app is not dealing with thousands of items, it makes sense to create these instances while preparing the layout, because the layout information can be cached and referenced rather than computed on the fly. If the costs of computing all the attributes up front outweighs the benefits of caching in your app, it is just as easy to create attributes in the moment when they are requested.
从苹果的文档这段话中也可以看出,具体在哪里计算所有的attributes取决于我们需要展现的数据量的大小。苹果的本意是让我们自己根据rect参数(contentOffset)只计算指定区域内,完全出现和部分出现在屏幕中的Item的Attributes,当数据量很大时(上千条)这么做可以减少每次请求布局时的计算量,有更高的性能。
但是在瀑布流中,Item是随机排列的(根据列的高度动态变化),所以我们没法只根据一个Rect参数就推算出当前Rect中完全出现和部分出现的Item有哪些(除非专门为每一列弄个数组维护列中的Item排列情况,这种优化其实就是用空间换时间,但在这个场景下以平时的需求,数据量还没有大到需要做这种优化的程度),故干脆直接在prepareLayout
中就把所有Item的Attributes都先算好了,算好的Attributes直接缓存在布局中,这样子在滚动CollectionView的时候也不会再进行计算了。
完整的Demo:
https://github.com/Tidusww/WWCollectionWaterfallLayout
commit:7d4f12a462ec7f27ea8e4b8dcd0a8bb281c32723
关于动画、插入删除、Header等功能,日后再更新。