这次遇到一个 Tag 布局的需求, 寻找多方, 看到的很多方法都是自己硬头计算, 不太实用且成本太大. 我在之前的这篇文章讲到过如何在AutoLayout + UICollectionViewFlowLayout的情况下实现集合视图高度撑开, 这次再来看看在 AutoLayout + 自定义布局的情况下实现高度撑开, 并实现 Tag 布局.(想了解集合视图自定义布局的内容, 请看这篇文章)

基本环境

问题描述(需求): 在滚动视图(UIScrollView)中添加若干个集合视图, 且集合视图的高度就是自身内容高度(即单个集合视图不能滚动), 且这些集合视图使用的是自定义布局, 需要将集合视图高度撑开到自身内容的高度, 且布局需要实现 Tag 效果(即左对齐方式).

故上下文环境为:

  • 视图控制器根视图上有滚动视图

  • 滚动视图分为几个部分, 内部放置集合视图

  • 集合视图高度撑开自己所属的部分

  • 集合视图使用 UICollectionViewFlowLayout 的自定义子类对象进行布局

实现

针对上述问题, 网上的解决方式五花八门多种多样, 但总是没有一个统一的, 简便的且高效的解决方案, 无意中发现这个库, 并且顺藤摸瓜看到 StackOverflow 上的这篇问答, 才初见端倪.

总地来说, 能用 UICollectionViewFlowLayout 来做就尽量用它, 或去继承实现它, 不然会多很多工作量.

自定义 FlowLayout, 主要是重写计算 Attributes 的方法, 思路是: 针对每一个 Item, 修改它和上一个 Item 的相对位置, 即可实现自己需要的左对齐布局.

核心代码如下所示:

swift
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
    guard let collectionView = collectionView else { return nil }
    
    // super 中也应是通过上一个 Item 的 frame 作为基准计算当前 Item 的布局.
    let currentAttr = super.layoutAttributesForItem(at: indexPath)

    let sectionInset = getSectionInsetAtIndexPath(indexPath)

    let isFirstItemInsection = indexPath.item == 0
    guard !isFirstItemInsection else {
        currentAttr?.leftAlignFrameWithSectionInset(inset: sectionInset)
        return currentAttr
    }

    let previousIndexPath = IndexPath(item: indexPath.item - 1, section: indexPath.section)
    guard let previousFrame = layoutAttributesForItem(at: previousIndexPath)?.frame,
        var currentFrame = currentAttr?.frame
        else { return nil }

    let availableTotalWidth = collectionView.bounds.width - sectionInset.left - sectionInset.right
    let strecthedFrame = CGRect(x: sectionInset.left,
                                y: currentFrame.origin.y,
                                width: availableTotalWidth,
                                height: currentFrame.height)

    // 若无交集, 则证明是新一行的第一个 Item                           
    guard strecthedFrame.intersects(previousFrame) else {
        currentAttr?.leftAlignFrameWithSectionInset(inset: sectionInset)
        return currentAttr
    }

    let previousItemRightMostX = previousFrame.maxX
    currentFrame.origin.x =
        previousItemRightMostX + getMinimumInteritemSpacingForSectionAtIndex(indexPath.section)
    currentAttr?.frame = currentFrame
    return currentAttr
}

在上面代码中用到了 UICollectionViewLayoutAttributes 的扩展:

swift
extension UICollectionViewLayoutAttributes {
    func leftAlignFrameWithSectionInset(inset: UIEdgeInsets) {
        self.frame.origin.x = inset.left
    }
}

另外获取当前 Item 间隔设置和 Section 间隔设置的方法如下:

swift
private func getSectionInsetAtIndexPath(_ indexPath: IndexPath) -> UIEdgeInsets {
    guard let collectionView = collectionView,
        let flowLayoutDelegate = collectionView.delegate as? UICollectionViewDelegateFlowLayout,
        let inset = flowLayoutDelegate.collectionView?(
            collectionView,
            layout: self,
            insetForSectionAt: indexPath.section
        )
        else { return sectionInset }
    return inset
}

private func getMinimumInteritemSpacingForSectionAtIndex(_ sectionIndex: Int) -> CGFloat {
    guard let collectionView = collectionView,
        let flowLayoutDelegate = collectionView.delegate as? UICollectionViewDelegateFlowLayout,
        let spacing = flowLayoutDelegate.collectionView?(
            collectionView,
            layout: self,
            minimumInteritemSpacingForSectionAt: sectionIndex
        )
        else { return minimumInteritemSpacing }
    return spacing
}

上面重写了 layoutAttributesForItem 方法, 就需要对应修改 layoutAttributesForElements(in rect:):

swift
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
    guard var attributesArray = super.layoutAttributesForElements(in: rect) else { return nil }
    // 替换所有的 Item 布局对象: 调用重写后的 layoutAttributesForItem 方法生成.
    for (index, attr) in attributesArray.enumerated() where attr.representedElementKind == nil {
        if let attributes = self.layoutAttributesForItem(at: attr.indexPath) {
            attributesArray[index] = attributes
        }
    }
    return attributesArray
}

通过这个布局, 就可以实现所需效果, 简单实用:

tag布局

下面是整体演示(为便于演示整个撑开过程, 添加了动画效果):

自动撑开