概述
UICollectionView 是 iOS 开发中最强大的工具之一。你可以使用它创建复杂的 UI,支持滚动、响应点击事件、更新布局,并且有卓越的性能。事实上,即使应用程序中的几乎每个页面都可以/应该支持 UICollectionView 也一点都不奇怪。
不像 UITableView,UICollectionView 给你足够的自由去自定义你自己的布局。没错,你可以把东西放在任何你想放的地方。要利用这个特性,你将需要创建自己的自定义布局(custom layout)。
在 Apple 或者其他许多文章中都有关于自定义布局的优秀文档,但是很容易让我们迷失在细节中。在这篇文章中,我们将会提供一个简单的模板用来实现 custom layout。在这个过程中,我们将会讨论 layout 是什么,你应该实现哪些方法,以及你应该在这些方法中做什么。
**注意:**在开始这个探索之前,值得看一看苹果的默认布局(UICollectionViewFlowLayout)是否足以满足你的需求。这个类具有很大程度的可定制性,并且可能适用于大多数情况。
你会说什么...你在这里做什么?
layout 到底能做什么,以及它是如何和 UICollectionView 一起工作的?你可以认为 UICollectionView 作为一个渲染引擎。它负责创建视图,将它们显示在屏幕上,以及处理事件。然而,为了最大化其可重用性,UICollectionView 使用了大量的委托来实现自定义功能。例如,它将创建单元格的责任委托给它的数据源。类似地,它将决定这些单元格的位置等信息的责任委托给了它的 layout。
最终,布局负责返回两条信息:
- 内容区域(contentSize)有多大?(例如,可滚动区域的大小是多少?)
- 单元格以及补充/装饰视图在哪里和它们的大小是多少?
第一条信息是通过只读属性 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)
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() 方法应该:
- 使用 self.collectionView 来确定需要布局的内容。
- 确定好所有元素都放在哪个位置,以及需要多大的空间。
- 实例化适当的 UICollectionViewLayoutAttributes 对象。
- 存储布局属性,并且通过其他方法去计算 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]
}
}
现在只需实例化您的自定义布局,并将其设置为您的集合视图的布局属性,以观察其运行结果。