iOS UICollectionView记录

UICollectionView 在项目中是出现很高频的一个空间,它能灵活的展现各种布局。平时,我们常用的水平、垂直及网格的效果基本上都可以使用系统提供的给我们的 Layout 进行完成,最近刚好做了一个自定义布局的需求,这里将过程稍作记录,后面也提及了一些 DragAndDrop 的简单使用。

  • 准备知识
  • Basic Layout
  • Custom Layout
  • Drag And Drop

准备知识

UICollectionView 的核心概念有三点:

  • Layout(布局)
  • Data Source(数据源)
  • Delegate(代理)

UICollectionViewLayout

一个抽象基类,用于生成 UICollectionView 的布局信息,每一个 cell 的布局信息由 UICollectionViewLayoutAttributes 进行管理。

系统为我们提供了一个流式布局的类——UICollectionViewFlowLayout,我们可以利用这个定义我们常用的布局。它可以定义布局方向、cell大小、间距等信息。

UICollectionViewDataSource

数据源协议,遵守协议的 delegate 为 UICollectionView 提供数据的各种信息:分组情况、Cell 的数量、每个 Cell 的内容等。

常用代理方法:

1
2
3
4
5
6
7
8
9
10
11
// 分组信息
optional func numberOfSections(in collectionView: UICollectionView) -> Int

// 每个分组中,cell 的数量
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int

// 返回需要显示的 cell
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell

// 返回 UICollectionView 的 Header 或 Footer
optional func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView

UICollectionViewDelegate

UICollectionViewDelegate 为我们提供了 cell 点击的事件以及一些视图的显示事件。

常用代理方法:

1
2
3
4
5
// cell 点击事件
optional func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)

// cell 视图即将显示
optional func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath)

Basic Layout

  • 准备工作,后面的几个示例中,数据源的代理方法也是如下实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
extension LayoutViewController: UICollectionViewDelegate, UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 14
}

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ImageCell", for: indexPath) as! ImageCell

let random = dataSource[indexPath.row]

cell.showImage.image = UIImage(named: "\(random)")

return cell
}
}
  • 设置 Basic Layout 的布局
1
2
3
4
5
6
let layout = UICollectionViewFlowLayout()
// 垂直滚动
layout.scrollDirection = .vertical
// cell 大小
layout.itemSize = CGSize(width: UIScreen.main.bounds.size.width, height: UIScreen.main.bounds.size.width)
self.collectionView.collectionViewLayout = layout

这是最基本的使用,就不多赘述了,基本的使用如果不了解可以去看看官方文档。

Custom Layout

系统提供给我们的布局只是简单的流式布局,当我们需要一些特殊的布局的时候,只能自己继承自 UICollectionViewLayout 来自定义 Layout。

自定义 Layout

1
2
3
4
5
6
7
8
9
10
class CustomCollectionViewLayout: UICollectionViewLayout {
private var itemWidth: CGFloat = 0 // cell 宽度
private var itemHeight: CGFloat = 0 // cell 高度

private var currentX: CGFloat = 0 // 当前 x 坐标
private var currentY: CGFloat = 0 // 当前 y 坐标

// 存储每个 cell 的布局信息
private var attrubutesArray = [UICollectionViewLayoutAttributes]()
}

布局相关准备工作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 布局相关准备工作
// 为每个 invalidateLayout 调用
// 缓存 UICollectionViewLayoutAttributes
// 计算 collectionViewContentSize
override func prepare() {
super.prepare()

guard let count = self.collectionView?.numberOfItems(inSection: 0) else { return }
// 得到每个 item 的属性并存储
for i in 0..<count {
let indexPath = IndexPath(row: i, section: 0)

guard let attributes = self.layoutAttributesForItem(at: indexPath) else { break }
attrubutesArray.append(attributes)
}
}

提供布局属性对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
// 提供布局对象
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
return attrubutesArray
}

override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
// 获取宽度
let contentWidth = self.collectionView!.frame.size.width

// 通过 indexpath 创建一个 item 属性
let temp = UICollectionViewLayoutAttributes(forCellWith: indexPath)

// 计算 item 的宽高
let typeOne: (Int) -> () = { index in
self.itemWidth = contentWidth / 2
self.itemHeight = self.itemWidth

temp.frame = CGRect(x: self.currentX, y: self.currentY, width: self.itemWidth, height: self.itemHeight)

if index == 0 {
self.currentX += self.itemWidth
} else {
self.currentX += self.itemWidth
self.currentY += self.itemHeight
}
}

let typeTwo: (Int) -> () = { index in
if index == 2 {
self.itemWidth = (contentWidth * 2) / 3.0
self.itemHeight = (self.itemWidth * 2) / 3.0

temp.frame = CGRect(x: self.currentX, y: self.currentY, width: self.itemWidth, height: self.itemHeight)

self.currentX += self.itemWidth
} else {
self.itemWidth = contentWidth / 3.0
self.itemHeight = (self.itemWidth * 2) / 3.0

temp.frame = CGRect(x: self.currentX, y: self.currentY, width: self.itemWidth, height: self.itemHeight)

self.currentY += self.itemHeight
if index == 4 {
self.currentX = 0
}
}
}

let typeThree: (Int) -> () = { index in
if index == 7 {
self.itemWidth = (contentWidth * 2) / 3.0
self.itemHeight = (self.itemWidth * 2) / 3.0

temp.frame = CGRect(x: self.currentX, y: self.currentY, width: self.itemWidth, height: self.itemHeight)

self.currentX += self.itemWidth
self.currentY += self.itemHeight
} else {
self.itemWidth = contentWidth / 3.0
self.itemHeight = (self.itemWidth * 2) / 3.0

temp.frame = CGRect(x: self.currentX, y: self.currentY, width: self.itemWidth, height: self.itemHeight)

if index == 5 {
self.currentY += self.itemHeight
} else {
self.currentX += self.itemWidth
self.currentY -= self.itemHeight
}
}
}

// 这下面是我模拟的根据不同的 indexPath 的信息来提供不同的 cell 的显示类型。
// 实际项目中,一把根据利用 Block 或者 Delegate,在 controller 中根据 indexPath 的值进行计算
// 并根据计算结果确定其具体的显示类型。
// Custom Layout 再根据显示类型进行计算显示位置。

let judgeNum = indexPath.row % 8

switch judgeNum {
case 0, 1:
typeOne(judgeNum)
case 2, 3, 4:
typeTwo(judgeNum)
case 5, 6, 7:
typeThree(judgeNum)
default:
break
}

// 当 currentX 到屏幕最右边时,换行到下一行显示。
if currentX >= contentWidth {
currentX = 0
}

return temp
}

滚动范围

1
2
3
4
// 提供滚动范围
override var collectionViewContentSize: CGSize {
return CGSize(width: UIScreen.main.bounds.size.width, height: currentY + 20)
}

边界更改

1
2
3
4
5
// 处理自定义布局中的边界修改
// 返回 true 使集合视图重新查询布局
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}

使用

1
2
let layout = CustomCollectionViewLayout()
self.collectionView.collectionViewLayout = layout

Drag And Drop

之前为了在 UICollectionView 中拖动 cell 需要自己定义手势处理拖动,在 iOS11 增加了 UICollectionViewDragDelegate 及 UICollectionViewDropDelegate 两个协议方便我们进行这个操作。

开启拖放手势,设置代理

1
2
3
4
// 开启拖放手势,设置代理。
self.collectionView.dragInteractionEnabled = true
self.collectionView.dragDelegate = self
self.collectionView.dropDelegate = self

实现代理

UICollectionViewDragDelegate

UICollectionViewDragDelegate 只有一个必须实现的方法。

  1. 创建一个或多个 NSItemProvider ,使用 NSItemProvider 传递集合视图item内容。
  2. 将每个 NSItemProvider 封装在对应 UIDragItem 对象中。
  3. 返回 dragItem。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
extension LayoutViewController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {

let imageName = self.dataSource[indexPath.row]

let image = UIImage(named: imageName)!

let provider = NSItemProvider(object: image)

let dragItem = UIDragItem(itemProvider: provider)

return [dragItem]
}
}

如果需要支持一次拖动多个,还需要实现下面这个方法,其实现步骤与上方大致相同。

1
func collectionView(_ collectionView: UICollectionView, itemsForAddingTo session: UIDragSession, at indexPath: IndexPath, point: CGPoint) -> [UIDragItem]

如果拖动之后,想自定义拖动视图的样式,可以实现:

1
2
3
4
5
6
/*
自定义拖动过程中 cell 外观。返回 nil 则以 cell 原样式呈现。
*/
func collectionView(_ collectionView: UICollectionView, dragPreviewParametersForItemAt indexPath: IndexPath) -> UIDragPreviewParameters? {
return nil
}

UICollectionViewDropDelegate

实现上方的协议之后,咱们在程序中已经可以实现拖动了,但是现在当放开时,cell 并不会按照我们预想的到达对应的位置,此时,需要实现 UICollectionViewDropDelegate 协议,来处理拖动内容的接收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
extension LayoutViewController: UICollectionViewDropDelegate {
// 返回一个 UICollectionViewDropProposal 对象,告知 cell 该怎么送入新的位置。
func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal {
if session.localDragSession != nil {
// 拖动手势源自同一app。
return UICollectionViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
} else {
// 拖动手势源自其它app。
return UICollectionViewDropProposal(operation: .copy, intent: .insertAtDestinationIndexPath)
}
}

/*
当手指离开屏幕时,UICollectionView 会调用。必须实现该方法以接收拖动的数据。
*/
func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {
let destinationIndexPath = coordinator.destinationIndexPath ?? IndexPath(item: 0, section: 0)

switch coordinator.proposal.operation {
case .move:
let items = coordinator.items

if items.contains(where: { $0.sourceIndexPath != nil }) {
if items.count == 1, let item = items.first {
// 找到操作的数据
let temp = dataSource[item.sourceIndexPath!.row]

// 数据源将操作的数据在原位置删除,以及插入到新的位置。
dataSource.remove(at: item.sourceIndexPath!.row)
dataSource.insert(temp, at: destinationIndexPath.row)

// 将 collectionView 的多个操作合并为一个动画。
collectionView.performBatchUpdates({
collectionView.deleteItems(at: [item.sourceIndexPath!])
collectionView.insertItems(at: [destinationIndexPath])
})

coordinator.drop(item.dragItem, toItemAt: destinationIndexPath)
}
}
default:
return
}
}
}

关于 Drag 和 Drap 还可以很多使用的协议我们没有使用,这里只是实现了一个基本的效果,更多的实现方式还是需要多研读研读官方的文档。

Demo在此

参考链接:Drag and Drop with Collection and Table View

参考链接:A Tour Of UICollectionView