自定义 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 

- (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 *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 *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 *)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吧。

你可能感兴趣的:(自定义 UICollectionViewLayout系列——了解 UICollectionViewLayout)