UI学习:UICollectionView瀑布流

文章目录

UICollectionView 瀑布流布局

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

整体思路

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

实现步骤如下:

  1. prepareLayout 中提前计算所有 item 的位置和大小,缓存起来
  2. layoutAttributesForElementsInRect: 中返回当前可见区域内的 item 属性
  3. collectionViewContentSize 中告诉系统内容总共有多高

声明属性

objc 复制代码
// WaterfallLayout.h

@interface WaterfallLayout : UICollectionViewLayout

@property (nonatomic, assign) NSInteger columnCount;    // 列数
@property (nonatomic, assign) CGFloat columnSpacing;    // 列间距
@property (nonatomic, assign) CGFloat rowSpacing;       // 行间距
@property (nonatomic, assign) UIEdgeInsets sectionInset; // 四周内边距

@end
objc 复制代码
// WaterfallLayout.m

@interface WaterfallLayout ()

@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 的位置和大小提前算好、存起来。

objc 复制代码
- (void)prepareLayout {
    [super prepareLayout];

    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 的 frame
    NSInteger itemCount = [cv numberOfItemsInSection:0];
    for (NSInteger i = 0; i < itemCount; i++) {
        NSIndexPath *indexPath = [NSIndexPath indexPathForItem:i inSection:0];

        // 找到最矮的列
        NSInteger targetColumn = [self findSmallestColumn];

        // 取出该列的当前高度
        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:self heightForItemAtIndexPath:indexPath itemWidth:itemWidth];

        // 创建布局属性,设置 frame
        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;
}

为什么初始值是 sectionInset.top

每列从顶部开始放置第一个 item,第一个 item 的 y 坐标就是 sectionInset.top(上内边距),所以初始化时每列高度都设为 sectionInset.top

item 宽度计算

objc 复制代码
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 坐标计算

objc 复制代码
CGFloat x = self.sectionInset.left + targetColumn * (itemWidth + self.columnSpacing);

第 0 列:x = 左内边距

第 1 列:x = 左内边距 + 1 × (列宽 + 列间距)

第 2 列:x = 左内边距 + 2 × (列宽 + 列间距)

以此类推,每列的 x 坐标都是固定的。

item 的 y 坐标计算

objc 复制代码
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

objc 复制代码
- (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;
        }
    }
    return targetColumn;
}

遍历 columnHeights,找到高度最小的列的索引,把下一个 item 放到那里,这就是瀑布流"哪里矮往哪里放"的核心逻辑


返回内容大小:collectionViewContentSize

objc 复制代码
- (CGSize)collectionViewContentSize {
    return CGSizeMake(CGRectGetWidth(self.collectionView.bounds), self.contentHeight);
}

这个方法告诉 UICollectionView(本质上是 UIScrollView)"我的内容有多大",系统根据这个值来决定可以滚动多远。

  • 宽度 = collectionView 自身的宽度 → 水平方向不可滚动
  • 高度 = contentHeight(在 prepareLayout 中计算好的)→ 垂直方向按内容高度滚动

为什么不是自动决定的?

使用系统自带的 UICollectionViewFlowLayout 时,它内部已经帮你计算好了,不需要手动写。但使用自定义 Layout 时,系统不知道你的排列规则,必须由你亲自告诉它内容有多大。如果不实现,默认返回 CGSizeZero,collectionView 无法滚动,甚至不会显示任何 cell。


返回可见区域的布局属性:layoutAttributesForElementsInRect:

objc 复制代码
- (NSArray<UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect {
    NSMutableArray *result = [NSMutableArray array];
    for (UICollectionViewLayoutAttributes *attr in self.attributesArray) {
        if (CGRectIntersectsRect(rect, attr.frame)) {
            [result addObject:attr];
        }
    }
    return result;
}

系统在滚动时会不断调用这个方法,传入当前可见区域的 rect,问你"这个范围内有哪些 item 需要显示?"

CGRectIntersectsRect(rect, attr.frame) 判断某个 item 的 frame 是否与可见区域有交集,有交集才加入结果数组返回给系统。

这样做的好处是:不管有多少个 item,每次只返回屏幕上看得见的那十几个,系统只渲染这些,滚动流畅不卡顿。

如果不判断直接返回所有 attributes,功能上也能显示,但性能极差------每次滚动系统都要处理全部 item,数量一多就会明显掉帧。


根据 indexPath 返回单个属性:layoutAttributesForItemAtIndexPath:

objc 复制代码
- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath {
    for (UICollectionViewLayoutAttributes *attr in self.attributesArray) {
        if ([attr.indexPath isEqual:indexPath]) {
            return attr;
        }
    }
    return nil;
}

系统在某些情况下(比如插入、删除 item 时)会直接通过 indexPath 查询某个 item 的属性,这个方法负责从缓存数组中找到对应的结果返回。

objc 复制代码
// 在 prepareLayout 中
self.attributesDict[indexPath] = attr;

// 查找时直接返回
return self.attributesDict[indexPath];

响应尺寸变化:shouldInvalidateLayoutForBoundsChange:

objc 复制代码
- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds {
    CGRect oldBounds = self.collectionView.bounds;
    if (CGSizeEqualToSize(oldBounds.size, newBounds.size)) {
        return NO;
    }
    return YES;
}

这是系统提供的可重写方法,用来控制当 collectionView 的 bounds 变化时,是否需要重新计算布局。

CGSizeEqualToSize 会比较两个 CGSizewidthheight 是否都相等。

  • 滚动时 :bounds 的 origin 变化,但 size 不变 → 返回 NO,不重新计算,滚动流畅
  • 旋转屏幕时 :宽度发生变化,size 不同 → 返回 YES,重新计算所有 item 的位置和宽度

系统默认实现返回 NO,即 bounds 改变不会自动触发重新布局。瀑布流中 item 宽度依赖 collectionView 宽度,所以旋转屏幕后必须重算,需要重写这个方法。


方法调用顺序总结

复制代码
prepareLayout                           // 提前算好所有 item 的位置
        ↓
collectionViewContentSize               // 告诉系统内容总高度
        ↓
layoutAttributesForElementsInRect:      // 滚动时返回当前可见区域的 item 属性
        ↓
layoutAttributesForItemAtIndexPath:     // 按需查询某个 item 的属性

完整代码如下:

objc 复制代码
//
//  WaterfallLayout.h
//

#import <UIKit/UIKit.h>

@class WaterfallLayout;

@protocol WaterfallLayoutDelegate <NSObject>
- (CGFloat)waterfallLayout:(WaterfallLayout *)layout
    heightForItemAtIndexPath:(NSIndexPath *)indexPath
                  itemWidth:(CGFloat)itemWidth;
@end

@interface WaterfallLayout : 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
objc 复制代码
//
//  WaterfallLayout.m
//

#import "WaterfallLayout.h"

@interface WaterfallLayout ()
@property (nonatomic, strong) NSMutableArray<UICollectionViewLayoutAttributes *> *attributesArray;
@property (nonatomic, strong) NSMutableArray<NSNumber *> *columnHeights;
@property (nonatomic, assign) CGFloat contentHeight;
@end

@implementation WaterfallLayout

- (void)prepareLayout {
    [super prepareLayout];

    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 = [self findSmallestColumn];
        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:self heightForItemAtIndexPath: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;
        }
    }
    return targetColumn;
}

- (CGSize)collectionViewContentSize {
    return CGSizeMake(CGRectGetWidth(self.collectionView.bounds), self.contentHeight);
}

- (NSArray<UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect {
    NSMutableArray *result = [NSMutableArray array];
    for (UICollectionViewLayoutAttributes *attr in self.attributesArray) {
        if (CGRectIntersectsRect(rect, attr.frame)) {
            [result addObject:attr];
        }
    }
    return result;
}

- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath {
    for (UICollectionViewLayoutAttributes *attr in self.attributesArray) {
        if ([attr.indexPath isEqual:indexPath]) {
            return attr;
        }
    }
    return nil;
}

- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds {
    CGRect oldBounds = self.collectionView.bounds;
    if (CGSizeEqualToSize(oldBounds.size, newBounds.size)) {
        return NO;
    }
    return YES;
}

@end

效果如下:

相关推荐
AOwhisky2 小时前
MySQL 学习笔记(第六期):MySQL 备份与恢复
运维·数据库·笔记·学习·mysql·云计算
_李小白2 小时前
【android opencv学习笔记】Day 32:直线检测之霍夫变换
android·opencv·学习
提子拌饭1333 小时前
Column 嵌套布局:多级 Column 实现复杂纵向结构——鸿蒙 HarmonyOS ArkTS 原生学习应用
学习·华为·harmonyos·鸿蒙·鸿蒙系统
xqqxqxxq4 小时前
树结构技术学习笔记
数据结构·笔记·学习
xiaobai1784 小时前
pytest+playwright实现UI自动化(4)-上夹具fixture
ui·自动化·pytest·playwright
十月的皮皮5 小时前
C语言学习笔记202606008- 三角形判断(3种方法)
c语言·笔记·学习
XGeFei5 小时前
【Fastapi学习笔记(6)】—— Fastapi文件上传、请求头自动转换
笔记·学习·fastapi
大熊猫侯佩5 小时前
WWDC26 全网首发:SwiftUI 8 “可重排序“操作符深度解析
ios·swiftui·swift
一口吃俩胖子5 小时前
【脉宽调制DCDC功率变换学习笔记024】频域性能
笔记·学习