自定义 UICollectionViewLayout系列——了解 UICollectionViewLayout

尽管 UICollectionView 是iOS 日常开发遇到的高频控件之一,但很多时候我们对其使用仅仅是为了满足一些横向滚动的场景。再复杂的场景我们可能继承UICollectionViewFlowLayout然后稍作调整。重头开始写一个UICollectionViewLayout? Oh, no. 这真的太复杂了。但是自定义 UICollectionViewLayout可以帮助我们深度定制 UI 和行为以及针对性的优化滚动性能。那么就让我带大家来重新认识一下UICollectionViewLayout

这个系列计划分为两篇,分别是:

  • 了解UICollectionViewLayout

    在这篇我们会先了解UICollectionViewLayout的设计思想、排版规则以及方法时序

  • UICollectionViewLayout 性能优化

    在初步了解UICollectionViewLayout的工作原理后,我会以视频号的瀑布流界面为例思考如何优化UICollectionViewLayout的性能, 以及如何实现Header悬停等效果

布局核心数据结构

在 xcode 中打开UICollectionViewLayout.h我们会看到几个和UICollectionViewLayout相关的核心类。他们是:

  • UICollectionViewLayoutAttributes

    UICollectionViewLayoutAttributes是非常重要的一个数据模型,它负责记录 cell 的布局信息,如 frameboundstransform3DzIndex等。通过对其属性的设置,我们可以很方便的控制 cell 的 UI 形态。

    UICollectionViewLayoutAttributes有三个初始化构造方法,分别用于 cell、supplementaryView、decorationView 的创建,注意不要使用其init方法。

    从类定义我们可以看到UICollectionViewLayoutAttributes实现NSCopying协议,这意味着我们可以很方便的实现深拷贝,这对于我们在布局时记录数据有很大的作用。

  • UICollectionViewLayoutInvalidationContext

    UICollectionViewLayoutInvalidationContext用于标记无效的信息,以便于我们部分更新布局数据,而不是全量更新布局数据。本文不会对其做过多介绍,我们会在下一篇性能优化再做详细讲解。

  • UICollectionViewLayout

    UICollectionViewLayout作为我们自定义布局要继承的父类,UICollectionViewLayout自然是非常重要。如果单单看类定义似乎非常简单,但其实我们需要实现的核心方法都定义在UICollectionViewLayout (UISubclassingHooks),如prepareLayoutlayoutAttributesForElementsInRect:layoutAttributesForItemAtIndexPath等。

布局核心过程

让我们想一下 collectionview 的布局过程,其实就是 collectionview 和 layout的沟通过程。当 collectionview 需要布局信息的时候,他会通过特定的方法来向 layout 获取。

collectionviewlayout1-1

你的自定义 layout 必须实现一下方法:

  • collectionViewContentSize

    这个方法返回 collection view 内容的尺寸(contentSize)。注意这个方法需要的是全部内容的宽高,而不是可视内容的宽高。

  • prepare

    任何时候当一个新的布局过程发生时,UIKit 都会先调用这个方法。你可以在这个时候准备一些布局需要的数据。

    什么是布局需要的数据呢?举个例子,如果我们这个布局是两列并排的数据流,并且每个 cell 各占 collectionview 的一半,那么我们可以在这个方法通过 collectionview 的宽度来计算出 cell 的宽度,而不需要依赖调用方来提供宽度。一般来说不建议在这个方法里面计算出所有视图的布局信息,在数据量大且 cell 布局复杂的时候,这可能导致严重的卡顿。一些有上下依赖的情况,如非等高的cell,在数据量不大的时候则可以提前在这个方法里面计算好布局。

  • layoutAttributesForElements(in:):

    在这个方法你需要返回在 rect 范围内的所有可视item。无论是cell、supplementaryView还是decorationView 的布局信息都是放在一个 array 里面返回。

  • layoutAttributesForItem(at:):

    这个方法提供最终的布局信息给 collectionView。你需要提供indexpath 对应的 cell 的布局信息(UICollectionViewLayoutAttributes)。

计算布局属性

上面我们知道了我们需要实现什么方法,但是我们应该怎样计算布局属性呢?为了方便接下来的讲述,我会使用最常见的瀑布流 StreamLayout 来做例子。

首先对于一个瀑布流来说,你需要动态的计算每一个 item 的高度,也就是需要声明一个 protocol 来获取信息。

那么回到代码,在实现我们的 StreamLayout 之前,我们需要声明 protocol

@protocol StreamLayoutDelegate <UICollectionViewDelegate>

- (CGFloat)collectionView:(UICollectionView *)collectionView
                   layout:(WCFinderStreamLayout2 *)collectionViewLayout
    cellHeightAtIndexPath:(NSIndexPath *)indexPath
                withWidth:(CGFloat)width;

@end

实现这个 protocol 的实例就需要实现这个方法来提供每个 cell 的高度。在我们开始写布局代码之前,我们需要在 StreamLayout 中声明一些属性来帮助布局。

@interface StreamLayout : UICollectionViewLayout

@property (nonatomic, assign) NSUInteger columnCount;
@property (nonatomic, assign) CGSize cellSpace;
@property (nonatomic, assign) CGFloat cellHeight;
@property (nonatomic, assign) CGSize contentSize;
@property (nonatomic, strong) NSMutableDictionary<NSNumber *, UICollectionViewLayoutAttributes *> *cellsAttr;

@end

从属性的命名上,我们可以很容易的理解其作用。其中 cellsAttr 是所有 cell 的布局信息缓存,这样可以避免大部分的重复计算。

现在,你有了计算布局属性的所有信息,可以得到所有cell 的位置,为了让大家更容易理解计算的过程,看下图:

collectionviewlayout1-2

计算布局的过程其实就是计算每个 cell 的 frame 的过程,在这个过程中,你需要积累计算每个 cell 的 xOffset、yOffset。在我们这个例子中,假设我们的数据量级不大,那么可以在 prepareLayout中就计算出所有的cell 的布局信息。

代码如下:

- (void)prepareLayout {
    [super prepareLayout];
        //1.
    if (self.cellsAttr) {
        return;
    }
    self.cellsAttr = [NSMutableDictionary dictionary];

    NSUInteger cellCount = [self.collectionView.dataSource collectionView:self.collectionView numberOfItemsInSection:section];
    if (cellCount == 0) {
        return;
    }
    NSUInteger columnCount = self.columnCount;
    CGSize cellSpace = self.cellSpace;
    CGFloat rowSpace = MAX(0.0, cellSpace.width);
    CGFloat columnSpace = MAX(0.0, cellSpace.height);
    CGFloat currentMaxY = edgeInsets.top;
    //2.
    NSMutableArray<NSNumber *> *columnHeights = [NSMutableArray array];
    for (int i = 0; i < columnCount; i++) {
        [columnHeights addObject:@(0)];
    }

    CGFloat maxHeight = 0;
    for (int i = 0; i < cellCount; i++) {
        //3.
        UICollectionViewLayoutAttributes *attrs =
        [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:[NSIndexPath indexPathForItem:i inSection:section]];
        if (!attrs) {
            continue;
        }
        CGFloat width = (self.collectionView.width - (columnCount - 1) * rowSpace) / columnCount;
        CGFloat cellHeight = self.cellHeight;
        if ([self.delegate respondsToSelector:@selector(collectionView:layout:cellHeightAtIndexPath:withWidth:)]) {
            cellHeight = [self.delegate collectionView:self.collectionView
                                                layout:self
                                 cellHeightAtIndexPath:[NSIndexPath indexPathForItem:i inSection:section]
                                             withWidth:width];
        }
        cellHeight = MAX(0.0, cellHeight);
                //4.
        __block CGFloat minHeight = CGFLOAT_MAX;
        __block NSUInteger minIndex = 0;
        [columnHeights enumerateObjectsUsingBlock:^(NSNumber *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
            if (obj.floatValue < minHeight && obj.floatValue >= 0) {
                minHeight = obj.floatValue;
                minIndex = idx;
            }
        }];
        CGFloat offsetY = currentMaxY + minHeight;
        CGFloat newColumnHeight = minHeight + cellHeight + columnSpace;
        attrs.frame = CGRectMake((width + rowSpace) * minIndex, offsetY, width, cellHeight);
        columnHeights[minIndex] = @(newColumnHeight);
        maxHeight = MAX(maxHeight, newColumnHeight - columnSpace);
        self.cellsAttr safeSetObject:attrs forKey:@(i)];
    }
}
  1. 仅当缓存数据不存在的时候才计算
  2. 新建数组用来搜集每一列的最新高度
  3. 生成UICollectionViewLayoutAttributes
  4. 循环计算每一个 cell 的布局,每一个 cell 会被安排到最小高度的列。

因为 prepareLayout 在每次布局过程中都会被调用,而有很多情况很导致重新布局,比如 collectionview 的 size 发生变化,所以在特定时刻如invalidationContextForBoundsChange 需要清除缓存。本篇暂时不考虑这种情况。

得到了所有 cell 的布局信息,我们就需要把数据传递给 collectionview。

- (NSArray<UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect {
    if (self.cellsAttr == nil) {
        return nil;
    }
    NSMutableArray *attrs = [NSMutableArray array];
    [[self.cellsAttr allValues]
    enumerateObjectsUsingBlock:^(UICollectionViewLayoutAttributes *_Nonnull cell, NSUInteger idx, BOOL *_Nonnull stop) {
        if (CGRectIntersectsRect(cell.frame, rect)) {
            [attrs safeAddObject:cell];
        }
    }];
    return attrs;
}

- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath {
    return self.cellsAttr[@(indexPath.row)];
}

在第一个方法中,我们找出了所有在 rect 范围的 cell,而在第二个方法中,我们返回了 indexpath 下对应的布局信息

注意:尽管两个方法都返回了UICollectionViewLayoutAttributes,但实际布局只会采用layoutAttributesForItemAtIndexPath返回的布局属性。

总结

在这篇简单的文章中,我们写了一个简单的瀑布流布局 StreamLayout,简单的了解了 UICollectionViewLayout 的核心内容。在下一篇性能优化,我们再来看看如何写出高性能的自定义UICollectionViewLayout吧。

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

推荐阅读更多精彩内容