自打 Apple 在 iOS6 中引入 UICollectionView 这个控件之后,越来越多的 iOS 开发者选择将它作为构建 UI 的首选,如此吸引人的原因在于它的可定制化程度很高,非常的灵活,这取决于它有一个单独的对象来管理布局,布局决定了视图的位置和属性。

说到布局 layout,大家在开发过程中与 UICollectionView 搭配使用最多的 应该就是 UICollectionViewFlowLayout 了,这是 UIKit 提供给开发者最基础的的网格布局,如果我们稍微要求高一点的定制化布局需求,它就没法满足实际的要求了,我们能否实现自定义的布局方案呢!答案当然是可以的。

在今天的这篇文章中,我将演示如何实现一个自定义的瀑布流布局方案,类似下图:

UICollectionView 自定义布局实现瀑布流视图_ide

大家在这个过程中会学习到以下几个知识点:

  1. 关于自定义布局
  2. 动态尺寸 Cell 的处理
  3. 计算和缓存布局属性

好了,废话不多说,咱就开始吧!

自定义布局

日常开发中,我们使用 UICollectionView 控件都会搭配一个默认的,提供一些基础的布局 UICollectionViewFlowLayout 来使用,但是当我们需要实现定制化程度比较高的界面时,就得自己实现一个自定义布局了。

那么,我们该如何来实现一个自定义布局呢!

查阅苹果的文档可以得知,UICollectionView 的布局是抽象类 UICollectionViewLayout 的子类,它定义了 UICollectionView 中每个 Item 的布局属性叫做:UICollectionViewLayoutAttributes,所以我们可以通过继承 UICollectionViewLayout,然后对每个 item 的 UICollectionViewLayoutAttributes 做调整,例如它的尺寸,旋转角度,缩放等等。

既然 Apple 的开发文档已经说得很明白了,那么我们就可以先完成这些基础的工作:

  1. 创建一个继承自 UICollectionViewFlowLayout 的类 WaterFallFlowLayout
  2. 声明一个变量表示布局中列的数量:cols
  3. 声明一个数组变量用于缓存计算好的布局属性:[UICollectionViewLayoutAttributes]
  4. 声明一个数组变量用于存放每列的高度:[CGFloat]

动态尺寸

有的人会问,瀑布流视图的惊艳之处就在于它的每个 Cell 的尺寸都是不一致的,那如何生成动态高度的 Cell 呢!

这里我用了 Swift 生成随机数的方式,在给每个 item 设置 frame 的时候,随机生成一个高度,这也是我们创建动态化界面的常用方式,这个代码逻辑就比较简单了,一行代码即可搞定:

CGFloat(arc4random_uniform(150) + 50)

计算和缓存布局属性

在实现该功能之前,我们先了解一下 UICollectionView 的布局过程,它与布局对象之间的关系是一种协作的关系,当 UICollectionView 需要一些布局信息的时候,它会去调用布局对象的一些函数,这些函数的执行是有一定的次序的,如图所示:

UICollectionView 自定义布局实现瀑布流视图_ico_02

所以我们继承自 UICollectionViewLayout 的子类必须要实现以下方法:

1. override var collectionViewContentSize: CGSize {...}

This method returns the width and height of the collection view’s contents. You must implement it to return the height and width of the entire collection view’s content, not just the visible content. The collection view uses this information internally to configure its scroll view’s content size.

2. override func prepare()

Whenever a layout operation is about to take place, UIKit calls this method. It’s your opportunity to prepare and perform any calculations required to determine the collection view’s size and the positions of the items.

3. override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {...}

In this method, you return the layout attributes for all items inside the given rectangle. You return the attributes to the collection view as an array of UICollectionViewLayoutAttributes.

4. override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {...}

This method provides on demand layout information to the collection view. You need to override it and return the layout attributes for the item at the requested indexPath.

了解完需要实现的函数后,接下来就开始计算瀑布流视图的布局属性了,在这里我先讲一下我实现的大概思路吧!

Cell 高度动态化

由于我们瀑布流视图的每个 Cell 的高度是动态的,为了实现这个需求,我们可以声明一个 protocol 并提供一个返回动态高度的方法,来为每个 Cell 提供动态的高度,代码如下:

protocol WaterFallLayoutDelegate: NSObjectProtocol {
func waterFlowLayout(_ waterFlowLayout: WaterFallFlowLayout, itemHeight indexPath: IndexPath) -> CGFloat
属性计算

Cell 高度动态化已经解决,那如何能让每个 Cell 都能紧密的挨在一起呢!这里我的策略就是通过追踪计算每一列的高度值来得出最小高度的那一列,由于已知当前有最小高度的那一列的高度值以及索引值,那我们就可以为一个 Cell 计算得出它新的 X 坐标 和 Y 坐标,然后重新对该 Cell 的位置信息赋值,最后再更新一下每列的高度,直到为每一个 Cell 都重新计算了一遍它的位置。

我们可以在 prepare() 函数中,添加这些逻辑,代码如下:

override func prepare() {
super.prepare()
// 计算每个 Cell 的宽度
let itemWidth = (collectionView!.bounds.width - sectionInset.left - sectionInset.right - minimumInteritemSpacing * CGFloat(cols - 1)) / CGFloat(cols)
// Cell 数量
let itemCount = collectionView!.numberOfItems(inSection: 0)
// 最小高度索引
var minHeightIndex = 0
// 遍历 item 计算并缓存属性
for i in layoutAttributeArray.count ..< itemCount {
let indexPath = IndexPath(item: i, section: 0)
let attr = UICollectionViewLayoutAttributes(forCellWith: indexPath)
// 获取动态高度
let itemHeight = delegate?.waterFlowLayout(self, itemHeight: indexPath)

// 找到高度最短的那一列
let value = yArray.min()
// 获取数组索引
minHeightIndex = yArray.firstIndex(of: value!)!
// 获取该列的 Y 坐标
var itemY = yArray[minHeightIndex]
// 判断是否是第一行,如果换行需要加上行间距
if i >= cols {
itemY += minimumInteritemSpacing
}

// 计算该索引的 X 坐标
let itemX = sectionInset.left + (itemWidth + minimumInteritemSpacing) * CGFloat(minHeightIndex)
// 赋值新的位置信息
attr.frame = CGRect(x: itemX, y: itemY, width: itemWidth, height: CGFloat(itemHeight!))
// 缓存布局属性
layoutAttributeArray.append(attr)
// 更新最短高度列的数据
yArray[minHeightIndex] = attr.frame.maxY
}
maxHeight = yArray.max()! + sectionInset.bottom

接下来,在 layoutAttributesForElements(in rect: CGRect) 方法中添加如下逻辑:

这个方法决定了哪些 item 在给定的区域内是可见的,我们可以通过数组提供的过滤的方法 filter ,检查之前计算的布局属性是否与该可见区域相交,然后并把相交的属性返回,代码如下:

override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
return layoutAttributeArray.filter {
$0.frame.intersects(rect)
}
}

好了,到这里关于瀑布流视图的布局就讲完了,附上 WaterFallFlowLayout 的全部代码,供大家参考:

import UIKit

protocol WaterFallLayoutDelegate: NSObjectProtocol {
func waterFlowLayout(_ waterFlowLayout: WaterFallFlowLayout, itemHeight indexPath: IndexPath) -> CGFloat
}

class WaterFallFlowLayout: UICollectionViewFlowLayout {

weak var delegate: WaterFallLayoutDelegate?
// 列数
var cols = 4
// 布局数组
fileprivate lazy var layoutAttributeArray: [UICollectionViewLayoutAttributes] = []
// 高度数组
fileprivate lazy var yArray: [CGFloat] = Array(repeating: self.sectionInset.top, count: cols)

fileprivate var maxHeight: CGFloat = 0

override func prepare() {
super.prepare()
// 计算每个 Cell 的宽度
let itemWidth = (collectionView!.bounds.width - sectionInset.left - sectionInset.right - minimumInteritemSpacing * CGFloat(cols - 1)) / CGFloat(cols)
// Cell 数量
let itemCount = collectionView!.numberOfItems(inSection: 0)
// 最小高度索引
var minHeightIndex = 0
// 遍历 item 计算并缓存属性
for i in layoutAttributeArray.count ..< itemCount {
let indexPath = IndexPath(item: i, section: 0)
let attr = UICollectionViewLayoutAttributes(forCellWith: indexPath)
// 获取动态高度
let itemHeight = delegate?.waterFlowLayout(self, itemHeight: indexPath)

// 找到高度最短的那一列
let value = yArray.min()
// 获取数组索引
minHeightIndex = yArray.firstIndex(of: value!)!
// 获取该列的 Y 坐标
var itemY = yArray[minHeightIndex]
// 判断是否是第一行,如果换行需要加上行间距
if i >= cols {
itemY += minimumInteritemSpacing
}

// 计算该索引的 X 坐标
let itemX = sectionInset.left + (itemWidth + minimumInteritemSpacing) * CGFloat(minHeightIndex)
// 赋值新的位置信息
attr.frame = CGRect(x: itemX, y: itemY, width: itemWidth, height: CGFloat(itemHeight!))
// 缓存布局属性
layoutAttributeArray.append(attr)
// 更新最短高度列的数据
yArray[minHeightIndex] = attr.frame.maxY
}
maxHeight = yArray.max()! + sectionInset.bottom

}
}

extension WaterFallFlowLayout {

override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
return layoutAttributeArray.filter {
$0.frame.intersects(rect)
}
}

override var collectionViewContentSize: CGSize {
return CGSize(width: collectionView!.bounds.width, height: maxHeight)
}
}

在 UIViewController 中呈现

完成上述的瀑布流布局后,那是时候应该在 UIViewController 中将它呈现出来了,接下来的步骤就比较简单了,相信大家都能够独自完成,我就不做详细的解释了,附上代码:

import UIKit

class WaterFallViewController: UIViewController {

private let cellID = "baseCellID"

var itemCount: Int = 30
var collectionView: UICollectionView!

override func viewDidLoad() {
super.viewDidLoad()

// Do any additional setup after loading the view.
setUpView()
}

func setUpView() {
// 设置 flowlayout
let layout = WaterFallFlowLayout()
layout.delegate = self

// 设置 collectionview
let margin: CGFloat = 8
layout.minimumLineSpacing = margin
layout.minimumInteritemSpacing = margin
layout.sectionInset = UIEdgeInsets(top: 0, left: margin, bottom: 0, right: margin)
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
collectionView.backgroundColor = .white
collectionView.dataSource = self

// 注册 Cell
collectionView.register(BaseCollectionViewCell.self, forCellWithReuseIdentifier: cellID)
view.addSubview(collectionView)
}
}

extension WaterFallViewController: UICollectionViewDelegate{

}

extension WaterFallViewController: UICollectionViewDataSource{

func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return itemCount
}

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellID, for: indexPath) as! BaseCollectionViewCell
cell.cellIndex = indexPath.item
cell.backgroundColor = indexPath.item % 2 == 0 ? .systemBlue : .purple
if itemCount - 1 == indexPath.item {
itemCount += 20
collectionView.reloadData()
}
return cell
}
}

extension WaterFallViewController: WaterFallLayoutDelegate{
func waterFlowLayout(_ waterFlowLayout: WaterFallFlowLayout, itemHeight indexPath: IndexPath) -> CGFloat {
return CGFloat(arc4random_uniform(150) + 50)
}
}

将上述代码添加到 Xcode 工程中编译并运行,你就会看到 Cell 根据照片的高度正确放置并设置了大小:

UICollectionView 自定义布局实现瀑布流视图_布局属性_03

好了, 利用 UICollectionView 控件与自定义布局实现瀑布流的内容到此就结束了,最后附上项目的源码地址:

​github.com/ShenJieSuzh…​


关注我的技术公众号"HelloWorld杰少",获取更多优质技术文章。