美文网首页
自定义 UICollectionViewLayout系列——了解

自定义 UICollectionViewLayout系列——了解

作者: felix9 | 来源:发表于2022-10-20 11:13 被阅读0次

    尽管 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吧。

    相关文章

      网友评论

          本文标题:自定义 UICollectionViewLayout系列——了解

          本文链接:https://www.haomeiwen.com/subject/wdhuzrtx.html