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 } }
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 只有一个必须实现的方法。
创建一个或多个 NSItemProvider ,使用 NSItemProvider 传递集合视图item内容。
将每个 NSItemProvider 封装在对应 UIDragItem 对象中。
返回 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