文章目录UICollectionView 瀑布流布局整体思路声明属性核心方法prepareLayout为什么初始值是 sectionInset.topitem 宽度计算item 的 x 坐标计算item 的 y 坐标计算找最矮列findSmallestColumn返回内容大小collectionViewContentSize返回可见区域的布局属性layoutAttributesForElementsInRect:根据 indexPath 返回单个属性layoutAttributesForItemAtIndexPath:响应尺寸变化shouldInvalidateLayoutForBoundsChange:方法调用顺序总结完整代码如下:UICollectionView 瀑布流布局UICollectionViewLayout是 UICollectionView 的布局引擎通过继承它可以实现任意自定义布局。瀑布流Waterfall Layout就是其中最经典的一种——每列高度不同item 从高度最小的列开始填充像瀑布一样自然流下。整体思路瀑布流布局的核心逻辑只有一句话哪列最矮就往哪列放。实现步骤如下在prepareLayout中提前计算所有 item 的位置和大小缓存起来在layoutAttributesForElementsInRect:中返回当前可见区域内的 item 属性在collectionViewContentSize中告诉系统内容总共有多高声明属性// WaterfallLayout.hinterfaceWaterfallLayout:UICollectionViewLayoutproperty(nonatomic,assign)NSInteger columnCount;// 列数property(nonatomic,assign)CGFloat columnSpacing;// 列间距property(nonatomic,assign)CGFloat rowSpacing;// 行间距property(nonatomic,assign)UIEdgeInsets sectionInset;// 四周内边距end// WaterfallLayout.minterfaceWaterfallLayout()property(nonatomic,strong)NSMutableArrayUICollectionViewLayoutAttributes**attributesArray;// 缓存所有 item 的布局属性property(nonatomic,strong)NSMutableArrayNSNumber**columnHeights;// 记录每列当前的累计高度property(nonatomic,assign)CGFloat contentHeight;// 内容总高度endattributesArray用来缓存所有 item 的布局属性位置、大小避免每次都重新计算columnHeights记录每一列当前长到哪里了是找最矮列的依据contentHeight是整个内容区域的总高度决定 collectionView 能滚多远sectionInset是UIEdgeInsets结构体包含四个属性属性含义top上内边距left左内边距bottom下内边距right右内边距核心方法prepareLayoutprepareLayout是整个布局的总指挥在布局开始前被系统调用一次负责把所有 item 的位置和大小提前算好、存起来。-(void)prepareLayout{[superprepareLayout];UICollectionView*cvself.collectionView;// 初始化每列高度初始值为上内边距self.columnHeights[NSMutableArray array];for(NSInteger i0;iself.columnCount;i){[self.columnHeights addObject:(self.sectionInset.top)];}self.attributesArray[NSMutableArray array];// 计算 item 宽度CGFloat totalWidthCGRectGetWidth(cv.bounds);CGFloat availableWidthtotalWidth-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 i0;iitemCount;i){NSIndexPath*indexPath[NSIndexPath indexPathForItem:i inSection:0];// 找到最矮的列NSInteger targetColumn[selffindSmallestColumn];// 取出该列的当前高度CGFloat columnY[self.columnHeights[targetColumn]floatValue];// 计算 item 的 y 坐标非空列需要加行间距CGFloat ycolumnY;if(columnYself.sectionInset.top){yself.rowSpacing;}// 计算 item 的 x 坐标CGFloat xself.sectionInset.lefttargetColumn*(itemWidthself.columnSpacing);// 向数据源询问 item 高度需要自定义代理协议CGFloat itemHeight[self.delegate waterfallLayout:selfheightForItemAtIndexPath:indexPath itemWidth:itemWidth];// 创建布局属性设置 frameUICollectionViewLayoutAttributes*attr[UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];attr.frameCGRectMake(x,y,itemWidth,itemHeight);[self.attributesArray addObject:attr];// 更新该列的累计高度self.columnHeights[targetColumn](yitemHeight);}// 计算内容总高度 最高列的高度 底部内边距CGFloat maxColumnHeight[[self.columnHeights valueForKeyPath:max.floatValue]floatValue];self.contentHeightmaxColumnHeightself.sectionInset.bottom;}为什么初始值是 sectionInset.top每列从顶部开始放置第一个 item第一个 item 的 y 坐标就是sectionInset.top上内边距所以初始化时每列高度都设为sectionInset.top。item 宽度计算totalWidthcollectionView 的总宽度 availableWidthtotalWidth-左内边距-右内边距 itemWidth(availableWidth-列间距总和)/列数列间距总和 (columnCount - 1) * columnSpacing因为 3 列之间只有 2 个间隔。举例totalWidth 400左右内边距各 103 列列间距 10availableWidth 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 xself.sectionInset.lefttargetColumn*(itemWidthself.columnSpacing);第 0 列x 左内边距第 1 列x 左内边距 1 × (列宽 列间距)第 2 列x 左内边距 2 × (列宽 列间距)以此类推每列的 x 坐标都是固定的。item 的 y 坐标计算CGFloat columnY[self.columnHeights[targetColumn]floatValue];CGFloat ycolumnY;if(columnYself.sectionInset.top){yself.rowSpacing;}columnY就是该列当前的累计高度也就是这列最后一个 item 的底部在哪里如果columnY sectionInset.top说明该列已经有 item 了下一个 item 需要在此基础上加行间距如果columnY sectionInset.top说明该列还是空的直接从sectionInset.top开始放不需要加行间距找最矮列findSmallestColumn-(NSInteger)findSmallestColumn{NSInteger targetColumn0;CGFloat minHeight[self.columnHeights[0]floatValue];for(NSInteger i1;iself.columnCount;i){CGFloat height[self.columnHeights[i]floatValue];if(heightminHeight){minHeightheight;targetColumni;}}returntargetColumn;}遍历columnHeights找到高度最小的列的索引把下一个 item 放到那里这就是瀑布流哪里矮往哪里放的核心逻辑返回内容大小collectionViewContentSize-(CGSize)collectionViewContentSize{returnCGSizeMake(CGRectGetWidth(self.collectionView.bounds),self.contentHeight);}这个方法告诉 UICollectionView本质上是 UIScrollView“我的内容有多大”系统根据这个值来决定可以滚动多远。宽度 collectionView 自身的宽度 → 水平方向不可滚动高度 contentHeight在 prepareLayout 中计算好的→ 垂直方向按内容高度滚动为什么不是自动决定的使用系统自带的UICollectionViewFlowLayout时它内部已经帮你计算好了不需要手动写。但使用自定义 Layout 时系统不知道你的排列规则必须由你亲自告诉它内容有多大。如果不实现默认返回CGSizeZerocollectionView 无法滚动甚至不会显示任何 cell。返回可见区域的布局属性layoutAttributesForElementsInRect:-(NSArrayUICollectionViewLayoutAttributes**)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 oldBoundsself.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//#importUIKit/UIKit.hclassWaterfallLayout;protocolWaterfallLayoutDelegateNSObject-(CGFloat)waterfallLayout:(WaterfallLayout*)layout heightForItemAtIndexPath:(NSIndexPath*)indexPath itemWidth:(CGFloat)itemWidth;endinterfaceWaterfallLayout:UICollectionViewLayoutproperty(nonatomic,weak)idWaterfallLayoutDelegatedelegate;property(nonatomic,assign)NSInteger columnCount;property(nonatomic,assign)CGFloat columnSpacing;property(nonatomic,assign)CGFloat rowSpacing;property(nonatomic,assign)UIEdgeInsets sectionInset;end//// WaterfallLayout.m//#importWaterfallLayout.hinterfaceWaterfallLayout()property(nonatomic,strong)NSMutableArrayUICollectionViewLayoutAttributes**attributesArray;property(nonatomic,strong)NSMutableArrayNSNumber**columnHeights;property(nonatomic,assign)CGFloat contentHeight;endimplementationWaterfallLayout-(void)prepareLayout{[superprepareLayout];UICollectionView*cvself.collectionView;self.columnHeights[NSMutableArray array];for(NSInteger i0;iself.columnCount;i){[self.columnHeights addObject:(self.sectionInset.top)];}self.attributesArray[NSMutableArray array];CGFloat totalWidthCGRectGetWidth(cv.bounds);CGFloat availableWidthtotalWidth-self.sectionInset.left-self.sectionInset.right;CGFloat itemWidth(availableWidth-(self.columnCount-1)*self.columnSpacing)/self.columnCount;NSInteger itemCount[cv numberOfItemsInSection:0];for(NSInteger i0;iitemCount;i){NSIndexPath*indexPath[NSIndexPath indexPathForItem:i inSection:0];NSInteger targetColumn[selffindSmallestColumn];CGFloat columnY[self.columnHeights[targetColumn]floatValue];CGFloat ycolumnY;if(columnYself.sectionInset.top){yself.rowSpacing;}CGFloat xself.sectionInset.lefttargetColumn*(itemWidthself.columnSpacing);CGFloat itemHeight[self.delegate waterfallLayout:selfheightForItemAtIndexPath:indexPath itemWidth:itemWidth];UICollectionViewLayoutAttributes*attr[UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];attr.frameCGRectMake(x,y,itemWidth,itemHeight);[self.attributesArray addObject:attr];self.columnHeights[targetColumn](yitemHeight);}CGFloat maxColumnHeight[[self.columnHeights valueForKeyPath:max.floatValue]floatValue];self.contentHeightmaxColumnHeightself.sectionInset.bottom;}-(NSInteger)findSmallestColumn{NSInteger targetColumn0;CGFloat minHeight[self.columnHeights[0]floatValue];for(NSInteger i1;iself.columnCount;i){CGFloat height[self.columnHeights[i]floatValue];if(heightminHeight){minHeightheight;targetColumni;}}returntargetColumn;}-(CGSize)collectionViewContentSize{returnCGSizeMake(CGRectGetWidth(self.collectionView.bounds),self.contentHeight);}-(NSArrayUICollectionViewLayoutAttributes**)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 oldBoundsself.collectionView.bounds;if(CGSizeEqualToSize(oldBounds.size,newBounds.size)){returnNO;}returnYES;}end效果如下: