UI学习:UICollectionView瀑布流
文章目录
- UICollectionView 瀑布流布局
- 整体思路
- 声明属性
- 核心方法:prepareLayout
- 为什么初始值是 sectionInset.top
- item 宽度计算
- item 的 x 坐标计算
- item 的 y 坐标计算
- 找最矮列:findSmallestColumn
- 返回内容大小:collectionViewContentSize
- 返回可见区域的布局属性:layoutAttributesForElementsInRect:
- 根据 indexPath 返回单个属性:layoutAttributesForItemAtIndexPath:
- 响应尺寸变化:shouldInvalidateLayoutForBoundsChange:
- 方法调用顺序总结
- 完整代码如下:
UICollectionView 瀑布流布局
UICollectionViewLayout是 UICollectionView 的布局引擎,通过继承它可以实现任意自定义布局。瀑布流(Waterfall Layout)就是其中最经典的一种——每列高度不同,item 从高度最小的列开始填充,像瀑布一样自然流下。
整体思路
瀑布流布局的核心逻辑只有一句话:哪列最矮,就往哪列放。
实现步骤如下:
- 在
prepareLayout中提前计算所有 item 的位置和大小,缓存起来 - 在
layoutAttributesForElementsInRect:中返回当前可见区域内的 item 属性 - 在
collectionViewContentSize中告诉系统内容总共有多高
声明属性
// WaterfallLayout.h@interfaceWaterfallLayout:UICollectionViewLayout@property(nonatomic,assign)NSInteger columnCount;// 列数@property(nonatomic,assign)CGFloat columnSpacing;// 列间距@property(nonatomic,assign)CGFloat rowSpacing;// 行间距@property(nonatomic,assign)UIEdgeInsets sectionInset;// 四周内边距@end// WaterfallLayout.m@interfaceWaterfallLayout()@property(nonatomic,strong)NSMutableArray<UICollectionViewLayoutAttributes*>*attributesArray;// 缓存所有 item 的布局属性@property(nonatomic,strong)NSMutableArray<NSNumber*>*columnHeights;// 记录每列当前的累计高度@property(nonatomic,assign)CGFloat contentHeight;// 内容总高度@end
attributesArray用来缓存所有 item 的布局属性(位置、大小),避免每次都重新计算columnHeights记录每一列当前"长"到哪里了,是找最矮列的依据contentHeight是整个内容区域的总高度,决定 collectionView 能滚多远
sectionInset是UIEdgeInsets结构体,包含四个属性:
| 属性 | 含义 |
|---|---|
top | 上内边距 |
left | 左内边距 |
bottom | 下内边距 |
right | 右内边距 |
核心方法:prepareLayout
prepareLayout是整个布局的"总指挥",在布局开始前被系统调用一次,负责把所有 item 的位置和大小提前算好、存起来。
-(void)prepareLayout{[superprepareLayout];UICollectionView*cv=self.collectionView;// 初始化每列高度,初始值为上内边距self.columnHeights=[NSMutableArray array];for(NSInteger i=0;i<self.columnCount;i++){[self.columnHeights addObject:@(self.sectionInset.top)];}self.attributesArray=[NSMutableArray array];// 计算 item 宽度CGFloat totalWidth=CGRectGetWidth(cv.bounds);CGFloat availableWidth=totalWidth-self.sectionInset.left-self.sectionInset.right;CGFloat itemWidth=(availableWidth-(self.columnCount-1)*self.columnSpacing)/self.columnCount;// 遍历所有 item,计算每个 item 的 frameNSInteger itemCount=[cv numberOfItemsInSection:0];for(NSInteger i=0;i<itemCount;i++){NSIndexPath*indexPath=[NSIndexPath indexPathForItem:i inSection:0];// 找到最矮的列NSInteger targetColumn=[selffindSmallestColumn];// 取出该列的当前高度CGFloat columnY=[self.columnHeights[targetColumn]floatValue];// 计算 item 的 y 坐标(非空列需要加行间距)CGFloat y=columnY;if(columnY>self.sectionInset.top){y+=self.rowSpacing;}// 计算 item 的 x 坐标CGFloat x=self.sectionInset.left+targetColumn*(itemWidth+self.columnSpacing);// 向数据源询问 item 高度(需要自定义代理协议)CGFloat itemHeight=[self.delegate waterfallLayout:selfheightForItemAtIndexPath:indexPath itemWidth:itemWidth];// 创建布局属性,设置 frameUICollectionViewLayoutAttributes*attr=[UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];attr.frame=CGRectMake(x,y,itemWidth,itemHeight);[self.attributesArray addObject:attr];// 更新该列的累计高度self.columnHeights[targetColumn]=@(y+itemHeight);}// 计算内容总高度 = 最高列的高度 + 底部内边距CGFloat maxColumnHeight=[[self.columnHeights valueForKeyPath:@"@max.floatValue"]floatValue];self.contentHeight=maxColumnHeight+self.sectionInset.bottom;}为什么初始值是 sectionInset.top
每列从顶部开始放置第一个 item,第一个 item 的 y 坐标就是sectionInset.top(上内边距),所以初始化时每列高度都设为sectionInset.top。
item 宽度计算
totalWidth=collectionView 的总宽度 availableWidth=totalWidth-左内边距-右内边距 itemWidth=(availableWidth-列间距总和)/列数列间距总和 =(columnCount - 1) * columnSpacing,因为 3 列之间只有 2 个间隔。
举例:totalWidth = 400,左右内边距各 10,3 列,列间距 10
availableWidth = 400 - 10 - 10 = 380 itemWidth = (380 - 2 × 10) / 3 = 360 / 3 = 120验证:3 × 120 + 2 × 10 = 380
注意:左右内边距 ≠ 列间距。
sectionInset.left/right是内容区域与 collectionView边界之间的空白columnSpacing是列与列之间的空白
item 的 x 坐标计算
CGFloat x=self.sectionInset.left+targetColumn*(itemWidth+self.columnSpacing);第 0 列:x = 左内边距
第 1 列:x = 左内边距 + 1 × (列宽 + 列间距)
第 2 列:x = 左内边距 + 2 × (列宽 + 列间距)
以此类推,每列的 x 坐标都是固定的。
item 的 y 坐标计算
CGFloat columnY=[self.columnHeights[targetColumn]floatValue];CGFloat y=columnY;if(columnY>self.sectionInset.top){y+=self.rowSpacing;}columnY就是该列当前的累计高度,也就是这列最后一个 item 的底部在哪里
如果columnY > sectionInset.top,说明该列已经有 item 了,下一个 item 需要在此基础上加行间距
如果columnY == sectionInset.top,说明该列还是空的,直接从sectionInset.top开始放,不需要加行间距
找最矮列:findSmallestColumn
-(NSInteger)findSmallestColumn{NSInteger targetColumn=0;CGFloat minHeight=[self.columnHeights[0]floatValue];for(NSInteger i=1;i<self.columnCount;i++){CGFloat height=[self.columnHeights[i]floatValue];if(height<minHeight){minHeight=height;targetColumn=i;}}returntargetColumn;}遍历columnHeights,找到高度最小的列的索引,把下一个 item 放到那里,这就是瀑布流"哪里矮往哪里放"的核心逻辑
返回内容大小:collectionViewContentSize
-(CGSize)collectionViewContentSize{returnCGSizeMake(CGRectGetWidth(self.collectionView.bounds),self.contentHeight);}这个方法告诉 UICollectionView(本质上是 UIScrollView)“我的内容有多大”,系统根据这个值来决定可以滚动多远。
- 宽度 = collectionView 自身的宽度 → 水平方向不可滚动
- 高度 =
contentHeight(在 prepareLayout 中计算好的)→ 垂直方向按内容高度滚动
为什么不是自动决定的?
使用系统自带的
UICollectionViewFlowLayout时,它内部已经帮你计算好了,不需要手动写。但使用自定义 Layout 时,系统不知道你的排列规则,必须由你亲自告诉它内容有多大。如果不实现,默认返回CGSizeZero,collectionView 无法滚动,甚至不会显示任何 cell。
返回可见区域的布局属性:layoutAttributesForElementsInRect:
-(NSArray<UICollectionViewLayoutAttributes*>*)layoutAttributesForElementsInRect:(CGRect)rect{NSMutableArray*result=[NSMutableArray array];for(UICollectionViewLayoutAttributes*attrinself.attributesArray){if(CGRectIntersectsRect(rect,attr.frame)){[result addObject:attr];}}returnresult;}系统在滚动时会不断调用这个方法,传入当前可见区域的rect,问你"这个范围内有哪些 item 需要显示?"
CGRectIntersectsRect(rect, attr.frame)判断某个 item 的 frame 是否与可见区域有交集,有交集才加入结果数组返回给系统。
这样做的好处是:不管有多少个 item,每次只返回屏幕上看得见的那十几个,系统只渲染这些,滚动流畅不卡顿。
如果不判断直接返回所有 attributes,功能上也能显示,但性能极差——每次滚动系统都要处理全部 item,数量一多就会明显掉帧。
根据 indexPath 返回单个属性:layoutAttributesForItemAtIndexPath:
-(UICollectionViewLayoutAttributes*)layoutAttributesForItemAtIndexPath:(NSIndexPath*)indexPath{for(UICollectionViewLayoutAttributes*attrinself.attributesArray){if([attr.indexPath isEqual:indexPath]){returnattr;}}returnnil;}系统在某些情况下(比如插入、删除 item 时)会直接通过 indexPath 查询某个 item 的属性,这个方法负责从缓存数组中找到对应的结果返回。
// 在 prepareLayout 中self.attributesDict[indexPath]=attr;// 查找时直接返回returnself.attributesDict[indexPath];
响应尺寸变化:shouldInvalidateLayoutForBoundsChange:
-(BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds{CGRect oldBounds=self.collectionView.bounds;if(CGSizeEqualToSize(oldBounds.size,newBounds.size)){returnNO;}returnYES;}这是系统提供的可重写方法,用来控制当 collectionView 的 bounds 变化时,是否需要重新计算布局。
CGSizeEqualToSize会比较两个CGSize的width和height是否都相等。
- 滚动时:bounds 的 origin 变化,但 size 不变 → 返回
NO,不重新计算,滚动流畅 - 旋转屏幕时:宽度发生变化,size 不同 → 返回
YES,重新计算所有 item 的位置和宽度
系统默认实现返回
NO,即 bounds 改变不会自动触发重新布局。瀑布流中 item 宽度依赖 collectionView 宽度,所以旋转屏幕后必须重算,需要重写这个方法。
方法调用顺序总结
prepareLayout // 提前算好所有 item 的位置 ↓ collectionViewContentSize // 告诉系统内容总高度 ↓ layoutAttributesForElementsInRect: // 滚动时返回当前可见区域的 item 属性 ↓ layoutAttributesForItemAtIndexPath: // 按需查询某个 item 的属性完整代码如下:
//// WaterfallLayout.h//#import<UIKit/UIKit.h>@classWaterfallLayout;@protocolWaterfallLayoutDelegate<NSObject>-(CGFloat)waterfallLayout:(WaterfallLayout*)layout heightForItemAtIndexPath:(NSIndexPath*)indexPath itemWidth:(CGFloat)itemWidth;@end@interfaceWaterfallLayout:UICollectionViewLayout@property(nonatomic,weak)id<WaterfallLayoutDelegate>delegate;@property(nonatomic,assign)NSInteger columnCount;@property(nonatomic,assign)CGFloat columnSpacing;@property(nonatomic,assign)CGFloat rowSpacing;@property(nonatomic,assign)UIEdgeInsets sectionInset;@end//// WaterfallLayout.m//#import"WaterfallLayout.h"@interfaceWaterfallLayout()@property(nonatomic,strong)NSMutableArray<UICollectionViewLayoutAttributes*>*attributesArray;@property(nonatomic,strong)NSMutableArray<NSNumber*>*columnHeights;@property(nonatomic,assign)CGFloat contentHeight;@end@implementationWaterfallLayout-(void)prepareLayout{[superprepareLayout];UICollectionView*cv=self.collectionView;self.columnHeights=[NSMutableArray array];for(NSInteger i=0;i<self.columnCount;i++){[self.columnHeights addObject:@(self.sectionInset.top)];}self.attributesArray=[NSMutableArray array];CGFloat totalWidth=CGRectGetWidth(cv.bounds);CGFloat availableWidth=totalWidth-self.sectionInset.left-self.sectionInset.right;CGFloat itemWidth=(availableWidth-(self.columnCount-1)*self.columnSpacing)/self.columnCount;NSInteger itemCount=[cv numberOfItemsInSection:0];for(NSInteger i=0;i<itemCount;i++){NSIndexPath*indexPath=[NSIndexPath indexPathForItem:i inSection:0];NSInteger targetColumn=[selffindSmallestColumn];CGFloat columnY=[self.columnHeights[targetColumn]floatValue];CGFloat y=columnY;if(columnY>self.sectionInset.top){y+=self.rowSpacing;}CGFloat x=self.sectionInset.left+targetColumn*(itemWidth+self.columnSpacing);CGFloat itemHeight=[self.delegate waterfallLayout:selfheightForItemAtIndexPath:indexPath itemWidth:itemWidth];UICollectionViewLayoutAttributes*attr=[UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];attr.frame=CGRectMake(x,y,itemWidth,itemHeight);[self.attributesArray addObject:attr];self.columnHeights[targetColumn]=@(y+itemHeight);}CGFloat maxColumnHeight=[[self.columnHeights valueForKeyPath:@"@max.floatValue"]floatValue];self.contentHeight=maxColumnHeight+self.sectionInset.bottom;}-(NSInteger)findSmallestColumn{NSInteger targetColumn=0;CGFloat minHeight=[self.columnHeights[0]floatValue];for(NSInteger i=1;i<self.columnCount;i++){CGFloat height=[self.columnHeights[i]floatValue];if(height<minHeight){minHeight=height;targetColumn=i;}}returntargetColumn;}-(CGSize)collectionViewContentSize{returnCGSizeMake(CGRectGetWidth(self.collectionView.bounds),self.contentHeight);}-(NSArray<UICollectionViewLayoutAttributes*>*)layoutAttributesForElementsInRect:(CGRect)rect{NSMutableArray*result=[NSMutableArray array];for(UICollectionViewLayoutAttributes*attrinself.attributesArray){if(CGRectIntersectsRect(rect,attr.frame)){[result addObject:attr];}}returnresult;}-(UICollectionViewLayoutAttributes*)layoutAttributesForItemAtIndexPath:(NSIndexPath*)indexPath{for(UICollectionViewLayoutAttributes*attrinself.attributesArray){if([attr.indexPath isEqual:indexPath]){returnattr;}}returnnil;}-(BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds{CGRect oldBounds=self.collectionView.bounds;if(CGSizeEqualToSize(oldBounds.size,newBounds.size)){returnNO;}returnYES;}@end效果如下:
