当前位置: 首页 > news >正文

UI学习:UICollectionView瀑布流

文章目录

  • UICollectionView 瀑布流布局
    • 整体思路
    • 声明属性
    • 核心方法:prepareLayout
      • 为什么初始值是 sectionInset.top
      • item 宽度计算
      • item 的 x 坐标计算
      • item 的 y 坐标计算
    • 找最矮列:findSmallestColumn
    • 返回内容大小:collectionViewContentSize
    • 返回可见区域的布局属性:layoutAttributesForElementsInRect:
    • 根据 indexPath 返回单个属性:layoutAttributesForItemAtIndexPath:
    • 响应尺寸变化:shouldInvalidateLayoutForBoundsChange:
    • 方法调用顺序总结
    • 完整代码如下:

UICollectionView 瀑布流布局

UICollectionViewLayout是 UICollectionView 的布局引擎,通过继承它可以实现任意自定义布局。瀑布流(Waterfall Layout)就是其中最经典的一种——每列高度不同,item 从高度最小的列开始填充,像瀑布一样自然流下。

整体思路

瀑布流布局的核心逻辑只有一句话:哪列最矮,就往哪列放

实现步骤如下:

  1. prepareLayout中提前计算所有 item 的位置和大小,缓存起来
  2. layoutAttributesForElementsInRect:中返回当前可见区域内的 item 属性
  3. 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 能滚多远

sectionInsetUIEdgeInsets结构体,包含四个属性:

属性含义
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会比较两个CGSizewidthheight是否都相等。

  • 滚动时: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

效果如下:

http://www.cnnetsun.cn/news/2828609.html

相关文章:

  • Protege新手避坑:用Cellfie插件从Excel导入OWL数据时,这4个报错你肯定遇到过
  • DSP与PC高效数据交换:基于PCI总线主控与Scatter-Gather机制实战解析
  • 3分钟学会AI音频分离:Ultimate Vocal Remover GUI免费提取人声与伴奏完整指南
  • 5分钟掌握AI动作迁移:让任何视频人物学会专业舞蹈动作
  • 如何用DiffSinger实现高质量AI歌唱:从零开始的完整指南
  • M68HC05片上电压调节器软硬件协同设计与低功耗实战
  • google adwords怎么找关键词|新手必看,2个免费工具搞定词包
  • TikTok跨境电商浏览器怎么使用:多账号防关联,IP独立隔离
  • 深度实战指南:Vocal-Separate音频分离工具的完整应用方案
  • 057、BaseTrainer初始化源码精读:模型、数据、优化器、调度器的初始化全流程
  • 业务提效300%!实测实在Agent低代码调用Python:2026年企业级AI助理避坑指南
  • 高效安卓日历组件NCalendar:打造专业级时间管理解决方案
  • 期末论文不用熬大夜?paperxie 课程论文 AI 写作,帮你高效搞定学术任务
  • 像素化文本恢复终极指南:5分钟掌握Unredacter安全检测技术
  • 鸣潮自动化革命:如何用图像识别技术解放你的游戏时间
  • 从ColdFire MCF5307到MCF5407:嵌入式系统硬件升级与软件移植全攻略
  • AI知识库投喂:从“喂饱”到“喂好”的进化指南
  • GEO内容工程:面向AI模型的信息组织方法论
  • 96GB显存运行230B大模型!七彩虹灵创K16笔记本评测:160W性能释放 AMD锐龙AI Max+ 395加持全能移动AI工作站
  • 磁力链接转种子文件终极指南:Magnet2Torrent深度解析与技术实现
  • 如何解决Minecraft卡顿问题:PCL2启动器内存优化终极指南
  • Windows系统优化实战:WinUtil深度配置方案与性能调优技巧
  • 告别定位漂移!5款手机GNSS数据采集App实测对比(附避坑指南)
  • MC68HC908AS60 FLASH编程实战:从电荷泵原理到智能算法避坑
  • Windows微信朋友圈自动点赞评论工具(Python开发,带图形配置界面和多分辨率适配)
  • 基于加速度传感器与MCU的棒球测速系统:原理、设计与实现
  • LPC55S6x单SDMMC控制器驱动双SD卡:SDK补丁与串行访问实践
  • 第17篇:元数据与 SEO 基础
  • Obsidian个性化定制:CSS片段与主题生态深度解析
  • LPC55S3x/LPC553x MCU低功耗实战:从电源域到Power API的深度优化指南