iOS UICollectionView 自定义布局(1)- 瀑布流

前言

UICollectionView是个强大的视图,除了提供默认的UICollectionViewFlowLayout布局外,还能让程序员自定义自己的布局,下文将记录笔者利用UICollectionView实现瀑布流列表时的一些经验与体会。


瀑布流

1、关于 UICollectionView

关于UICollectionView,可以推荐看下objccn.io上的这篇文章自定义 Collection View 布局,写的十分详细,在这篇文章的帮助下,我快速的理解了UICollectionView的运作方式,打破了以前只懂最基本用法的尴尬情况。
在这里我给自己总结了一下:

  1. 与UITableView类似,UICollectionView也由对应的DataSource、Delegate驱动,熟练使用UITableView后在用UICollectionView时也一定能驾轻就熟。

  2. 与UITableView不同的是,UICollectionView还需要管理一个UICollectionViewLayout对象,而正是这个对象,默默地管理着所有Cell的布局,为UICollectionView带来了布局上无限的可能。

  3. 系统自带的UICollectionViewFlowLayout已经可以满足很多日常需求,UICollectionViewFlowLayout为我们隐藏了大量的细节,让我们以十分简单的方式使用UICollectionView。另外我们可以继承UICollectionViewLayout,实现自己的需求。

2、自定义 CollectionView 布局

简单的来说就是当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:

  1. 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与布局对象

一旦UICollectionView需要刷新(放到屏幕上或需要reloadData)或者被标记为需要重新计算布局(调用了layout对象的invalidateLayout方法)时,UICollectionView就会向布局对象请求一系列的方法:

  1. 首先会调用prepareLayout方法,在此方法中尽可能将后续布局时需要用到的前置计算处理好,每次重新布局都是从此方法开始。
  2. 调用collectionViewContentSize方法,根据第一点中的计算来返回所有内容的滚动区域大小,。
  3. 调用layoutAttributesForElementsInRect:方法,计算rect内相应的布局,并返回一个装有UICollectionViewLayoutAttributes的数组,Attributes 跟所有Item一一对应,UICollectionView就是根据这个Attributes来对Item进行布局,并当新的Rect区域滚动进入屏幕时再次请求此方法。
  4. layoutAttributesForElementsInRect:方法中,可以单独访问-layoutAttributesForItemAtIndexPath:方法,来根据indexPath来请求layoutAttributes,虽然这一步不是必须的。

4、瀑布流布局

根据上面的流程我们就可以实现自己的瀑布流布局了,开始之前先整理一下瀑布流的特点

  1. 瀑布流的列数是固定的,不会动态改变。
  2. 每个Item的高都不是固定的,是由Item的内容决定的。
  3. 布局时Item总是加到高度比较小的那一列上。

所以结合前一节的流程,需要做的事情有下面几个:

  1. 在实现的自定义布局中,需要用属性保存瀑布流中有多少列。
  2. 由于Item高度由Item内容决定,需要提供代理方法,由外部提供Item的高度到自定义布局中。
  3. 布局时Item的排列不是按从左到右从上到下这种简单的规律,所以需要我们花点代码计算维护每列的高度,才能算出所有Item排列的结果,得到整个UICollectionView的ContentSize。
  4. 最后在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等功能,日后再更新。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342

推荐阅读更多精彩内容