【译】自定义 Collection View Layout -- 一个简单的模板

2,153 阅读6分钟

原文地址:engineering.shopspring.com/custom-coll…

概述

UICollectionView 是 iOS 开发中最强大的工具之一。你可以使用它创建复杂的 UI,支持滚动、响应点击事件、更新布局,并且有卓越的性能。事实上,即使应用程序中的几乎每个页面都可以/应该支持 UICollectionView 也一点都不奇怪。

不像 UITableView,UICollectionView 给你足够的自由去自定义你自己的布局。没错,你可以把东西放在任何你想放的地方。要利用这个特性,你将需要创建自己的自定义布局(custom layout)。

在 Apple 或者其他许多文章中都有关于自定义布局的优秀文档,但是很容易让我们迷失在细节中。在这篇文章中,我们将会提供一个简单的模板用来实现 custom layout。在这个过程中,我们将会讨论 layout 是什么,你应该实现哪些方法,以及你应该在这些方法中做什么。

**注意:**在开始这个探索之前,值得看一看苹果的默认布局(UICollectionViewFlowLayout)是否足以满足你的需求。这个类具有很大程度的可定制性,并且可能适用于大多数情况。

你会说什么...你在这里做什么?

layout 到底能做什么,以及它是如何和 UICollectionView 一起工作的?你可以认为 UICollectionView 作为一个渲染引擎。它负责创建视图,将它们显示在屏幕上,以及处理事件。然而,为了最大化其可重用性,UICollectionView 使用了大量的委托来实现自定义功能。例如,它将创建单元格的责任委托给它的数据源。类似地,它将决定这些单元格的位置等信息的责任委托给了它的 layout。

最终,布局负责返回两条信息:

  1. 内容区域(contentSize)有多大?(例如,可滚动区域的大小是多少?)
  2. 单元格以及补充/装饰视图在哪里和它们的大小是多少?

第一条信息是通过只读属性 collectionView ContentSize 返回的(稍后详细介绍)。第二个是通过UICollectionViewLayoutAttributes 对象返回的。UICollectionViewLayoutAttributes 根据其类型(单元格、补充视图或装饰视图)和 indexPath 进行区分。布局属性封装了关于相应单元格和视图的布局信息(如 frame)并且告诉 collection view 将这些可见的元素放到哪里。你的自定义 layout 对象通过以下描述的几个方法返回这些属性,然后你的 collection view 会使用这些属性把可见的元素放到指定的位置。

创建一个 Custom Layout

为了创建一个 custom layout,你必须创建一个 UICollectionViewLayout 的子类。你的子类至少应该重写(override)以下几个方法:

核心

  • prepare —— 不是必须的,但是强烈推荐实现此方法。
  • collectionViewContentSize
  • layoutAttributesForElements(in:)

属性需求

  • layoutAttributesForItem(at:)
  • layoutAttributesForSupplementaryView(ofKind:at:)(如果你的布局支持额外补充的元素)
  • layoutAttributesForDecorationView(ofKind:at:)(如果你的布局支持装饰的视图)

实现这些方法有很多种方式。不过最终来看,你只需要返回合适的 content size 和布局属性。下面,我们将提供一种实现方式 —— 一个简单的模板,可以通过只修改 prepare() 方法来进行定制。

重写核心的布局方法

当对你的 collection view 进行布局时,苹果将会按照以下顺序依次调用下面的方法:

  • prepare()
  • collectionViewContentSize
  • layoutAttributesForElements(in:)

这构成了核心布局过程。(Core Layout Process)

Core Layout Process

prepare()

这个方法在核心布局过程中会第一个被调用,这是一个进行预处理的机会。

在我们的实现中,这是最重要的方法,因为这是魔术(aka 数学)发生的地方。在这个方法里我们将会:

  • 决定每一个cell、supplementart view 和 decoration view 的 size 和 position。
  • 计算 collection view 中可滑动区域的大小(content size)。
  • 实例化这些布局属性并存储它们。

基本上,这个方法是我们决定这个 layout 是什么的一个地方。

你的子类可以访问一个名为 collectionView 的属性,该属性指向此布局所属的 collection view。使用它可以拿到关于 sections,cells,以及视图的一些信息。并将该信息与其他模型数据结合起来,以决定将哪些数据放到哪里(其他数据可以直接设置为布局,或者通过委托提供)。

请记住,你不能使用单元格或视图的 frame 属性。因为它还没有被计算好,这就是我们现在要做的。

完成计算之后,存储结果以供其他方法访问。我建议:

  • 对于 content size,定义一个私有的 CGSize 属性,并将其设置为计算得到的大小。
  • 对于 layout attributes,定义一到三个私有的字典来存储计算的结果 —— 一个用于 cells,一个用于 supplementary view(如果你需要),另一个用于 decoration views(如果你也需要)。为你的每个单元格/视图创建一个 UICollectionViewLayoutAttributes 对象,根据你的数学设置它的框架,并将它以索引路径作为键添加到字典中。
private var computedContentSize: CGSize = .zero
private var cellAttributes = [IndexPath: UICollectionViewLayoutAttributes]()

override func prepare() {
  // Clear out previous results
  computedContentSize = .zero
  cellAttributes = [IndexPath: UICollectionViewLayoutAttributes]()
  
  for section in 0 ..< collectionView.numberOfSections {
    for item in 0 ..< collectionView.numberOfItems(inSection: section) {
      let itemFrame = // ...Determine the frame of your cell...

      // Create the layout attributes and set the frame
      let indexPath = IndexPath(item: item, section: section)
      let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
      attributes.frame = itemFrame

      // Store the results
      cellAttributes[indexPath] = attributes
    }
  }
  
  computedContentSize = // Store computed content size
}

简而言之,对于我们的实现,你的 prepare() 方法应该:

  1. 使用 self.collectionView 来确定需要布局的内容。
  2. 确定好所有元素都放在哪个位置,以及需要多大的空间。
  3. 实例化适当的 UICollectionViewLayoutAttributes 对象。
  4. 存储布局属性,并且通过其他方法去计算 conetnt size。

collectionContentSize

这个值决定了 collection view 中可滚动区域的大小。它实际上是 collection view layout 的只读属性。重写它的 getter 方法返回所需的大小。

对于我们的实现,返回在 prepare() 中计算和存储的 content size。

private var computedContentSize: CGSize = .zero

override var collectionViewContentSize: CGSize {
  return computedContentSize
}

layoutAttributesForElements(in:)

这个方法会给你传入一个 CGRect,并希望你返回一个 UICollectionViewLayoutAttributes 对象的数组,这些对象对应于(部分和全部)位于该矩形中的单元格/视图。(即和这个矩形相交的所有单元格/视图)

在我们的实现中,迭代在 prepare() 中创建的布局属性,检查它们的 frame,看看它们是否与所提供的矩形相交。使用 CGRect 的 intersects(_:) 方法可以轻松确定。

对于框架与给定矩形相交的任何布局属性,将其放入数组中,然后返回这个数组。

private var cellAttributes = [IndexPath: UICollectionViewLayoutAttributes]()

override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
  var attributeList = [UICollectionViewLayoutAttributes]()

  for (_, attributes) in cellAttributes {
    if attributes.frame.intersects(rect) {
      attributeList.append(attributes)
    }
  }

  return attributeList
}

布局属性

似乎这还不够,在核心布局过程之外,你的集合视图还可能要求特定索引路径上的布局属性。以下方法可以满足这个需求:

  • layoutAttributesForItem(at:)

  • layoutAttributesForSupplementaryView(ofKind:at:)(如果你的布局支持 supplementary views)

  • layoutAttributesForDecorationView(ofKind:at:)(如果你的布局支持 decoration views)

这些方法中的每一个都接受一个索引路径并返回该路径上单元格/视图的布局属性。

对于我们的实现,只需使用提供的索引路径来访问适当的字典,并返回结果就可以。

private var cellAttributes = [IndexPath: UICollectionViewLayoutAttributes]()

override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
  return cellAttributes[indexPath]
}

如果你的集合视图不使用补充/装饰视图,那么这两个方法返回 nil 就好了。

总结

总之,创建一个 custom layout,所有你要做的就是:

  • 在 collectionViewContentSize 中返回可滚动区域的大小
  • 在 layoutAttributesForElements(in:),layoutAttributesForItem(at:),layoutAttributesForSupplementaryView(ofKind:at:) 和 layoutAttributesForDecorationView(ofKind:at:) 中返回对应 frame 中的所有 UICollectionViewLayoutAttributes 对象。

下面是我们的实现模板。要定制它,您所要做的就是更改 prepare() 来计算您的布局所需的大小和位置。

@objc class CustomCollectionViewLayout: UICollectionViewLayout {
  private var computedContentSize: CGSize = .zero
  private var cellAttributes = [IndexPath: UICollectionViewLayoutAttributes]()
  
  override func prepare() {
    // Clear out previous results
    computedContentSize = .zero
    cellAttributes = [IndexPath: UICollectionViewLayoutAttributes]()
    
    for section in 0 ..< collectionView.numberOfSections {
      for item in 0 ..< collectionView.numberOfItems(inSection: section) {
        let itemFrame = // ...Determine the frame of your cell...
        // Create the layout attributes and set the frame
        let indexPath = IndexPath(item: item, section: section)
        let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
        attributes.frame = itemFrame

        // Store the results
        cellAttributes[indexPath] = attributes
      }
    }

    computedContentSize = // Store computed content size
  }
  
  override var collectionViewContentSize: CGSize {
    return computedContentSize
  }
  
  override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
    var attributeList = [UICollectionViewLayoutAttributes]()
    
    for (_, attributes) in cellAttributes {
      if attributes.frame.intersects(rect) {
        attributeList.append(attributes)
      }
    }
    
    return attributeList
  }
  
  override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
    return cellAttributes[indexPath]
  }
  
}

现在只需实例化您的自定义布局,并将其设置为您的集合视图的布局属性,以观察其运行结果。