前言
本文会介绍 UICollectionView 的一些比较新的特性,包括:
- iOS 11 推出的拖拽 drag & drop
- iOS 13 推出的组合布局 CompositionalLayout
- iOS 13 推出的 DiffableDataSource
主要参考的是 WWDC 历年关于 UICollectionView 的一些 sessions,结尾会有链接。
Drag & Drop
UIView 的 drag & drop
ios 11 之后,UICollectionView
内部支持拖拽操作,只需要遵循代理并且将 collectionView.dragInteractionEnabled
设置为 true
即可,但是Drag & Drop 并不是 CollectionView 或者 TableView 的专利,一个普通的 View 也可以遵循拖拽协议从而拥有苹果为你封装好的这一套拖拽的功能。
所以,这里的 drag & drop 可以理解为一个抽象的概念,它是一个拖拽的功能,就像点击功能,只要你遵循对应的协议,并且将允许拖拽的属性设置为 true,你就可以获得这一个功能,它又分为两个部分,一个是 drag、你开始拖动时的操作,一个是 drop,你要放下时的操作,这两部分可以互不干扰,你可以只实现其中的一个部分而获得那个部分的功能。
以为一个 View 获得拖拽功能为例:
1.为 view 添加 drag & drop 代理(不需要设置 .dragInteractionEnabled
)
// Add drag interaction
view.addInteraction(UIDragInteraction(delegate: self))
// Add drop interaction
view.addInteraction(UIDropInteraction(delegate: self))
复制代码
2.请求拖拽元素
// UIDragInteractionDelegate
func dragInteraction(_ interaction: UIDragInteraction, itemsForBeginning session:UIDragSession) -> [UIDragItem] {
// object 需要遵循 NSItemProviderWriting 协议
// 关于 NSItemProvider,可以看一下 WWDC17 Data Delivery with Drag and Drop 这个 session)
let itemProvider = NSItemProvider(object: "Hello World" as NSString)
let dragItem = UIDragItem(itemProvider: itemProvider)
return [dragItem]
}
复制代码
3.你可以自定义你的拖拽元素样式,也可以使用默认方案
// UIDragInteractionDelegate
func dragInteraction(_ interaction: UIDragInteraction, previewForLifting item:UIDragItem, session: UIDragSession) -> UITargetedDragPreview? {
let index = item.localObject as! Int
return UITargetedDragPreview(view: views[index])
}
复制代码
4.当你拖拽的元素在移动时,你可以设置一些效果,如回流方式,是 move 或者 copy 或者其他操作
// UIDropInteractionDelegate
// 会去请求 sessionAllowsMoveOperation,如果返回 false,move操作将不被允许
func dropInteraction(_ interaction: UIDropInteraction, sessionDidUpdate session:UIDropSession) -> UIDropProposal {
// cancel forbidden copy move
return UIDropProposal(operation: UIDropOperation)
}
复制代码
5.拖拽结束,处理你松手后的操作
// UIDropInteractionDelegate
func dropInteraction(_ interaction: UIDropInteraction, performDrop session:UIDropSession) {
// func 1
session.loadObjects(ofClass: UIImage.self) { objects in
for image in objects as! [UIImage] {
self.imageView.image = image
}
}
// or func 2
for item in session.items {
item.itemProvider.loadObject(ofClass: UIImage.self) { (object, error) in
if object != nil {
DispatchQueue.main.async {
self.imageView?.image = (object as! UIImage)
}
}
else {
// Handle th Error
}
}
}
}
复制代码
这样你就可以为一个 View 添加拖拽功能了,定义元素和动画效果的代理不是必须实现的,你只需要实现两个协议即可:
// UIDragInteractionDelegate
// 提供要拖拽的元素
func dragInteraction(_ interaction: UIDragInteraction, itemsForBeginning session:UIDragSession) -> [UIDragItem] {
return []
}
// UIDropInteractionDelegate
// 处理松手后的操作
func dropInteraction(_ interaction: UIDropInteraction, performDrop session:UIDropSession) {
}
复制代码
参考下面两张图来更详细的了解 drag & drop 的 deadline:
大致流程为:用户选中 -> 请求开发者返回拖拽的元素 -> lift 动画 -> 用户移动 -> 开始拖拽 > 允许自定义预览 & 动画 -> 拖动结束 -> 回调 Perform -> 执行 Drop 的动画(可自定义)-> 传输数据(异步)
Collection View DragDelegate and DropDelegate 介绍
CollectionView 需要实现的 delegate 略有不同,但是原理是一样的。在没有这套协议之前,我们需要自己实现拖拽,代码看起来长下面这样:
@objc func longPressAction(_ longPress: UILongPressGestureRecognizer) {
let point = longPress.location(in: collectionView)
let indexPath = collectionView.indexPathForItem(at: point)
switch longPress.state {
case .began:
if indexPath == nil { break }
// begin move
collectionView.beginInteractiveMovementForItem(at: indexPath!)
case .changed:
collectionView.updateInteractiveMovementTargetPosition(point)
case .ended:
collectionView.endInteractiveMovement()
default: // cancel remove
collectionView.cancelInteractiveMovement()
}
}
复制代码
beginInteractiveMovementForItem(at:)
系列方法是 ios9.0 之后提供的,如果更前面,那需要自己实现的东西就更多了。
iOS 11 之后,只需要设置 dragDelegate
和 dropDelegate
并实现,然后将 dragInteractionEnabled
设置为 true 即可。
collectionView.dragDelegate = self
collectionView.dropDelegate = self
// ipad默认为true,iphone ios15 之前默认为false,ios15之后默认为 true
collectionView.dragInteractionEnabled = true
复制代码
分别看一下两个代理的一些方法:
// UICollectionViewDragDelegate
// @required
// 开始拖拽
// 返回空数组的话不响应拖动事件
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session:UIDragSession, at indexPath: IndexPath) -> [UIDragItem]) {
}
// @optional
// 添加 item 到正在拖拽的 session
func collectionView(_ collectionView: UICollectionView,
itemsForAddingTo session: UIDragSession,
at indexPath: IndexPath,
point: CGPoint) -> [UIDragItem]) {
}
// UICollectionViewDropDelegate
// @required
// 松开手指时的处理,不实现的话会采用默认方案
func collectionView(_ collectionView: UICollectionView, performDropWith coordinator:UICollectionViewDropCoordinator) {
}
复制代码
这样你就可以实现拖拽了。当然,还有一些额外的代码方法来帮助你获取拖拽时的一些状态和自定义视图或者动画:
// UICollectionViewDragDelegate
// 自定义 drag 的预览,如果没有实现或者返回 nil,那么将使用整个 cell 作为预览
func collectionView(_ collectionView: UICollectionView, dragPreviewParametersForItemAtindexPath: IndexPath) -> UIDragPreviewParameters? {}
// 选中一个 item 的动画完成之后,开始拖拽之前会调用该方法
func collectionView(_ collectionView: UICollectionView, dragSessionWillBegin session:UIDragSession) {}
// 拖拽结束会调用该方法
func collectionView(_ collectionView: UICollectionView, dragSessionDidEnd session:UIDragSession) {}
// 控制拖动会话是否允许移动操作,默认为 true,如果为false,那么 .move 操作将不被允许
// UIDropOperation的四种操作:cancel | forbidden | copy | move
func collectionView(_ collectionView: UICollectionView, dragSessionAllowsMoveOperationsession: UIDragSession) -> Bool {}
// 能不能被拖动到另外的app,默认为 false
func collectionView(_ collectionView: UICollectionView,dragSessionIsRestrictedToDraggingApplication session: UIDragSession) -> Bool {}
复制代码
// UICollectionViewDropDelegate
// 返回false则不响应 drop 事件
- (BOOL)collectionView:(UICollectionView *)collectionView canHandleDropSession:(id<UIDropSession>)session;
// drop session 进入到 collectionView 的坐标区域内就会调用
// 调用顺序
// dragSessionWillBegin
// dropSessionDidEnter
// dragSessionDidEnd
// dropSessionDidEnd
- (void)collectionView:(UICollectionView *)collectionView dropSessionDidEnter:(id<UIDropSession>)session;
// 如果你开始拖拽并且拖拽的 session 在 collectionView 区域内,这个方法会不停的调用
// 它是跟踪 drop 的行为的,由于调用十分频繁,所以你要尽可能减少在这个方法内的工作量
// 可以让你自定义一些 drop 时的方案,但是可能不被系统允许,此时系统会执行自己的方案
- (UICollectionViewDropProposal *)collectionView:(UICollectionView *)collectionViewdropSessionDidUpdate:(id<UIDropSession>)session withDestinationIndexPath:(nullableNSIndexPath *)destinationIndexPath;
// 这是当你拖拽的 session 被移动到 collectionView 的区域之外时会调用
- (void)collectionView:(UICollectionView *)collectionView dropSessionDidExit:(id<UIDropSession>)session;
// drop 结束后调用,适合做一些清理的操作
- (void)collectionView:(UICollectionView *)collectionView dropSessionDidEnd:(id<UIDropSession>)session;
// 自定义 drop 的预览图,drop 结束之后会先放一张这个预览图,随后 collectionView 的数据刷新后会替换掉预览图
- (nullable UIDragPreviewParameters *)collectionView:(UICollectionView *)collectionViewdropPreviewParametersForItemAtIndexPath:(NSIndexPath *)indexPath;
复制代码
回流方式:
UICollectionViewDropProposal.Indent
,在 dropSessionDidUpdate
方法中设置:
// UICollectionViewDropProposal.Indent 这个属性可以控制 drop 的缺口打开效果
// unspecified: 不会打开缺口,目标索引也不会高亮
// insertAtDestinationIndexPath: 打开一个缺口,模拟最终释放时的效果
// insertIntoDestinationIndexPath: 不会打开缺口,但是目标索引会高亮
复制代码
默认效果:
具体的用法可以参考 Demo,包括你可以从 collectionView 拖到 tableView,从这个 app 拖到另外一个 app。
CompositionalLayout
这一部分可以直接看 Compositional Layout 詳解 讓你簡單操作 CollectionView! 这篇文章,写的很全面,这部分我基本算是阅读这篇文章的一个笔记。
Compositional Layout 想要解决的问题,是让 layout 可以像堆积木一样,拿现成的东西组一组,就能组出各种不一样的变化,而不用每次都针对一个新的 UI 设置去写一份新的 layout 算法。换句话说,Compostional Layout 和原本的 UICollectionViewLayout
最大的不同,就是它是用许许多多的组件,来描述一个 layout 的样子。
在 UICollectionViewLayout
中,我们是从零开始,把所有点的 item
的大小位置都算好告诉系统;而相对的,Compostional Layout 就像是,我们手边有几个代表 layout 方式的组件,比如一个 section
里有三个 item
,这个 item
的大小要跟容器一样,然后我们告诉系统,系统自己组合这些组件来设定 layout,细节的计算就不是我们需要关心的。
来看一下 Composition Layout 的架构:
在设定 Compositional Layout 的时候,我们的流程会像这样:先创造出属于 item
的 layout
组件,并且设定好大小、边界距离等等,每一种长相不一样的 item
,就会有一个专属的物件来描述它。设定好 item
之后,再创造一个 group layou
组件,把刚刚产生的 item
物件都丢到这个 group
里,并且设定好 group
本身的大小、边界距离等等,再丢到属于 section
的 layout
组件里,以此类推,到最后,一个 Collection View 的长相,就会由一个最大的 layout
物件来描述,它里面包含了不同的 section
,section
里又包含了 group
,而 group
里包含了各种 item
。所以上面的这张图,同时也是这些组件的阶层图,你可以把这些方块,都当成是程序里一个一个的组件。
各种基本组件
来实现一个横向滚动的 Collection View:
代码:
func createLayout() -> UICollectionViewLayout {
// 1
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.2),heightDimension: .fractionalHeight(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
// 2
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),heightDimension: .absolute(120))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems:[item])
group.interItemSpacing = .fixed(8)
// 3
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuous
section.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 5,trailing: 5)
// 4
let layout = UICollectionViewCompositionalLayout(section: section)
return layout
}
func configureHieratchy() {
self.collectionView = UICollectionView(frame: view.bounds, collectionViewLayout:createLayout())
}
复制代码
从 //1
开始,它是设定 item 的代码:
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.2),heightDimension: .fractionalHeight(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
复制代码
NSCollectionLayoutSize
这个类非常重要,它是描述所有 group
和 item
的组件,上面这段代码的意思是:设定宽度和是 group
的 0.2,高度和 group
一样(1x)。widthDismension
和 heightDismension
是 NSCollectionLayoutDimension
的两个参数。NSCollectionLayoutDimension
就是用来描述宽度和高度的组件:
open class NSCollectionLayoutDimension : NSObject, NSCopying {
// 宽度是容器的某个比例,比方1(一样)或0.5(一半)
open class func fractionalWidth(_ fractionalWidth: CGFloat) -> Self
// 高度是容器的某个比例,比方1(一样)或0.5(一半)
open class func fractionalHeight(_ fractionalHeight: CGFloat) -> Self
// 设定一个绝对值
open class func absolute(_ absoluteDimension: CGFloat) -> Self
// 这是一个预估值,实际布局完成之后,会使用实际的内容大小来展示
open class func estimated(_ estimatedDimension: CGFloat) -> Self
}
复制代码
所以这两句的代码的意思是:产生一个描述 item
的组件,指定它的宽度为 group
宽度的0.2,高度与 group
一样。
继续,看 //2
:
// 同 itemSize,它的意思是宽度与section保持一致,高度为绝对值120
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),heightDimension: .absolute(120))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
// group 内部的 Item 的间距
group.interItemSpacing = .fixed(8)
复制代码
NSCollectionLayoutGroup.horizontal(layoutSize: subitems:)
:产生一个描述 group
的组件,指定它的大小为 groupSize
,并且指定这个 group
里面有哪些种类的 item
,这里的 subitems
放入描述 item
的组件。
//3
:
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuous
section.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 5,trailing: 5)
复制代码
主要看 section.orthogonalScrollingBehavior = .continuous
,设定完这个参数,Collection View 的 section
会变成一个横向 Scroll View,我们可以不管这个多出来 Scroll View,还是像之前那样去使用 Collection View 即可,非常方便。它实际代表的意思是:我要让这个 section
可以与 Collection View 滚动的 90 度方向滚动。一般如果 Collection View 是垂直滚动的话,这个参数的意义就是 section
可以横向移动。如果 Collection View 是横向移动,这个参数的意义就变成让 section
可以垂直滚动。它有不同的值可以设定,可以稍后我们再讲。
//4
:
let layout = UICollectionViewCompositionalLayout(section: section)
return layout
复制代码
layout 通过 section 初始化,返回给 Collection View 使用。
但是上面的代码我们得到的效果是这样的:
会发现有个缺口,其实它是长这样的:
虽然 item
的宽度设定为容器的 0.2,但是因为 interItemSpacing
的关系,group
里面放不下 5 个 item
,所以最右边会有一个多出来的空间。所以你大概也能看出 group
的作用了,它的作用就是将一个或多个 item
圈在一起,利用这个特性,就可以做出许许多多的变化。比如在一个横向滚动的 section
中加上垂直排列的三个 cell
,就只需要设定一个 vertical group
,然后里面去包含三个 item
就可以了。
回到需求,我们希望有一个能够横向滚动的 section
,并且 item
跟 item
之间都是等间距的,该怎么做呢?
func createLayout() -> UICollectionViewLayout {
// 1
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),heightDimension: .fractionalHeight(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
// 2
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),heightDimension: .absolute(120))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems:[item])
group.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: nil, top: nil, trailing:.fixed(8), bottom: nil)
// 3
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuous
section.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 5,trailing: 5)
// 4
let layout = UICollectionViewCompositionalLayout(section: section)
return layout
}
复制代码
//1
,把 item
设定跟 group
一样宽度,//2
,让 group
的宽度等于容器的 0.2 倍。也就是说,现在一个 group
里面置灰放一个 item
,并且这个 item
跟 group
是一样大小的。不过还是要设定一下元素之间的间距,现在改成要设定 group
和 group
之间的间距了,所以 //3
,通过 group.edgeSpacing
来设定 group
之间的间距。
// NSCollectionLayoutEdgeSpacing
class NSCollectionLayoutEdgeSpacing {
public convenience init(leading: NSCollectionLayoutSpacing?, top:NSCollectionLayoutSpacing?, trailing: NSCollectionLayoutSpacing?, bottom:NSCollectionLayoutSpacing?)
}
class NSCollectionLayoutSpacing {
// 指定一个可伸长的最小距离
open class func flexible(_ flexibleSpacing: CGFloat) -> Self // i.e. >=
// 指定固定的距离
open class func fixed(_ fixedSpacing: CGFloat) -> Self // i.e. ==
}
复制代码
这样,我们就能得到一个正确的横向移动的 Collection View 了:
布局 Demos
item 垂直相叠的横向滚动
代码:
// 提供三种不同形狀的 item
let layoutSize = NSCollectionLayoutSize(widthDimension: .absolute(110),heightDimension: .absolute(45))
let item = NSCollectionLayoutItem(layoutSize: layoutSize)
let layoutSize2 = NSCollectionLayoutSize(widthDimension: .absolute(110),heightDimension: .absolute(65))
let item2 = NSCollectionLayoutItem(layoutSize: layoutSize2)
let layoutSize3 = NSCollectionLayoutSize(widthDimension: .absolute(110),heightDimension: .absolute(85))
let item3 = NSCollectionLayoutItem(layoutSize: layoutSize3)
// 給刚好大小的 group
let groupLayoutSize = NSCollectionLayoutSize(widthDimension: .absolute(110),heightDimension: .absolute(205))
// 用 .vertical 指明我们的 group 是垂直排列的
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupLayoutSize, subitems:[item, item2, item3])
// 這裡指的是垂直的间距了
group.interItemSpacing = .fixed(5)
group.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: nil, top: nil, trailing:.fixed(10), bottom: nil)
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
let layout = UICollectionViewCompositionalLayout(section: section)
return layout
复制代码
为什么 item
的间距可以只用一个 interItemSpacing
就能代表,但是 group
之间的间距却需要用 NSCollectionLayoutEdgeSpacing
来指定四个方向的参数才可以呢?
可以看到group
里面的排列,只会有三种情况:
.horizontal
: 水平排列.vertical
: 垂直排列.custom
: 自定义
前两种情况下,interItemSpacing
指的不是水平间距就是垂直间距,所以不需要指定方向系统也能知道这个 spacing
代表的意义。而 .custom
这个有点特别,这个其实就是我们熟悉的,计算一个一个 cell
的大小跟位置的方法,后面我们会提到。
Nested group
代码:
let layoutSize = NSCollectionLayoutSize(widthDimension: .absolute(65), heightDimension:.absolute(120))
let item = NSCollectionLayoutItem(layoutSize: layoutSize)
let layoutSize2 = NSCollectionLayoutSize(widthDimension: .absolute(65),heightDimension: .absolute(45))
let item2 = NSCollectionLayoutItem(layoutSize: layoutSize2)
let layoutSize3 = NSCollectionLayoutSize(widthDimension: .absolute(65),heightDimension: .absolute(65))
let item3 = NSCollectionLayoutItem(layoutSize: layoutSize3)
// 右边的子 group
let subGroupSize = NSCollectionLayoutSize(widthDimension: .absolute(65),heightDimension: .absolute(120))
let subGroup = NSCollectionLayoutGroup.vertical(layoutSize: subGroupSize, subitems:[item2, item3])
subGroup.interItemSpacing = .fixed(10)
// 包含一個左边的 item 跟右边的子 group 的大 group
let groupLayoutSize = NSCollectionLayoutSize(widthDimension: .absolute(135),heightDimension: .absolute(120))
// 同时在 group 里面放 group 跟 item 两种 layout 组件
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupLayoutSize, subitems:[item, subGroup])
group.interItemSpacing = .fixed(5)
group.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: nil, top: nil, trailing:.fixed(10), bottom: nil)
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
let layout = UICollectionViewCompositionalLayout(section: section)
return layout
复制代码
NSCollectionLayoutGroup
的定义:
open class NSCollectionLayoutGroup : NSCollectionLayoutItem, NSCopying {
...
}
复制代码
group
其实是 item
的子类,也就是说,任何放 NSCollectionLayoutItem
的地方,都可以放 NSCollectionLayoutGroup
,这个特性让许多复杂的 layout
,都能够转化成 group
跟 item
的组合。
只要在 group
中放入一个 subGroup
,subGroup
中再放两个 item
,就能实现 Nested Group
这种布局了。
UICollectionLayoutSectionOrthogonalScrollingBehavior 滚动方式
section
可以设置 orthogonalScrollingBehavior
,前面提到过,比如 Collection View 是垂直滚动的,那你设置了 section
的 orthogonalScrollingBehavior
之后,这个 section
就可以进行横向滚动,这个参数可以设定不同的值:
.none
默认值,不会滚动.continuous
连续滚动.continuousGroupLeadingBoundary
连续滚动,但最后会停在gruop
的前沿(gruop` 的起始位置偏移一定值).paging
每次滚动跟 Collection View 一样宽(或高)的距离.groupPaging
每次会滚动一个group
.groupPagingCentered
每次滚动一个group
,并且停在group
的中点
分别看一下效果:
Header - Boundary Supplementary Item
帮 section
加上 header。
在 UICollectionView
里面,header
和 footer
都是一种 supplementary view
,附加在 section
旁边。Compostional Layout 自然也有办法设定这些 supplementary
的 layout
,并且逻辑上跟设定其他的 layout
是一样的:
// ...
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
// ...
// 設定 header 的大小
let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),heightDimension: .absolute(40))
// 負責描述 supplementary item 的物件
let headerItem = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize,elementKind: UICollectionView.elementKindSectionHeader, alignment: .top, absoluteOffset:CGPoint(x: 0, y: -5))
let section = NSCollectionLayoutSection(group: group)
// 指定 supplementary item 給 section
section.boundarySupplementaryItems = [headerItem]
let layout = UICollectionViewCompositionalLayout(section: section)
return layout
复制代码
上述代码的意思可以通过下图说明:
如果你想要实现 footer
,将 alignment
改成 .bottom
即可。如果是横向移动的 section
,header
的 alignment
可能就需要是 .leading
。
当然,要记住在 UICollectionViewDataSource
里面实现下面这个方法,根据接收到的 viewForSupplementaryElementOfKind
来提供对应的 header view
:
@objc open func collectionView(_ collectionView: UICollectionView,viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) ->UICollectionReusableView
复制代码
记得还需要注册这个 supplementary view
:
CollectionView.register(HeaderView.self, forSupplementaryViewOfKind:UICollectionView.elementKindSectionHeader, withReuseIdentifier: "HeaderView")
复制代码
小红点 badge - Supplementary Item
NSCollectionLayoutBoundarySupplementaryItem
的定义:
open class NSCollectionLayoutBoundarySupplementaryItem :NSCollectionLayoutSupplementaryItem, NSCopying {
// ...
}
复制代码
它是基于 NSCollectionLayoutSupplementaryItem
提供一些方便的 method
,并且预设在 section
之外的 supplementary item
。那这个 NSCollectionLayoutSupplementaryItem
有什么不一样呢?Boundary supplementary item 基本上就是用来描述一个预设在 section
外面的 view
,而 supplementary item 就是更广泛的用来描述所有在 section
之上的东西,比如我们现在将要介绍的,在 section
左上角的小红点。
来看一下实现:
// ...
let badgeSize = NSCollectionLayoutSize(widthDimension: .absolute(20), heightDimension:.absolute(20))
let badgeContainerAnchor = NSCollectionLayoutAnchor(edges: [.top, .leading],absoluteOffset: CGPoint(x: 10, y: 10))
let badgeItemAnchor = NSCollectionLayoutAnchor(edges: [.bottom, .trailing],absoluteOffset: CGPoint(x: 0, y: 0))
let badgeItem = NSCollectionLayoutSupplementaryItem(layoutSize: badgeSize, elementKind:BadgeView.supplementaryKind, containerAnchor: badgeContainerAnchor, itemAnchor:badgeItemAnchor)
let item = NSCollectionLayoutItem(layoutSize: layoutSize, supplementaryItems:[badgeItem])
// ...
复制代码
在这里,多了一个新同学:NSCollectionLayoutAnchor
,它是用来指定锚点的(anchor)的。对于一个 NSCollectionLayoutSupplementaryItem
来说,你除了要指定它的大小、element kind 之外,你也要指定它的两个锚点,containerAnchor
跟 itemAnchor
,用图看就可以了解 containerAnchor
跟 itemAnchor
分别代表什么:
从上图可以看出,itemAnchor
代表的是 item
用来对齐的锚点,而 containerAnchor
代表的是包含这个 item
的 section
或 group
用来对齐的锚点。NSCollectionLayoutSupplementaryItem
会在 layout
的时候,将 item
跟 container
的锚点对齐,所以通过这两个参数,就可以将 supplementary view 摆在任何我们想摆的地方。不过 itemAnchor
这个参数一般常用,如果我们不在初始化 NSCollectionLayoutSupplementaryItem
时设定,那预设的 itemAnchor
就会在 item
的左上角 (0, 0)
的位置,只利用 containerAnchor
就足以设定出小红点的位置了,也就是上面的例子我们可以简化成:
let badgeContainerAnchor = NSCollectionLayoutAnchor(edges: [.top, .leading],absoluteOffset: CGPoint(x: -10, y: -10))
let badgeItem = NSCollectionLayoutSupplementaryItem(layoutSize: badgeSize, elementKind:BadgeView.supplementaryKind, containerAnchor: badgeContainerAnchor)
复制代码
结果在 UI 上是一模一样的,当然如果你想要把 containerAnchor
设定在光年之外,再用 itemAnchor
拉回来也没有人会阻止你。
当然,如果不是用 Diffable DataSource 的话,记得实现 UICollectionViewDataSource
里面的:
optional func CollectionView(_ CollectionView: UICollectionView,
viewForSupplementaryElementOfKind kind: String,
at indexPath: IndexPath) -> UICollectionReusableView
复制代码
并且处理好 BadgeView.supplementaryKind
这个 element kind 的 supplementary view。
提供 section 背景的 decoration item
看到上面这个 layout
,有做过的人就知道这是所有 UI 工程师的痛。这是一个有 5 个 item
的 section
,并且它有一个圆角的灰色背景。以往这样的 layout
你就一定要提供一个 decoration view,并且实现 UICollectionViewLayout
计算好它的大小,其他的方法都只会比计算数学更复杂,还有看过像汉堡一样做出上、中、下三种样式的 item
,再把他们叠起来的。
不过现在,compostional layout 也有办法控制 decoratino view 的 layout 了,首先跟 supplementary 一样,我们需要有一个 decoration view:
class SectionBackgroundDecorationView: UICollectionReusableView { ... }
复制代码
设定 layout
时的代码长这样:
// 设定 decoration view
let sectionBackgroundDecoration = NSCollectionLayoutDecorationItem.background(
elementKind: SectionBackgroundDecorationView.elementKind)
sectionBackgroundDecoration.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 5,bottom: 5, trailing: 5)
section.decorationItems = [sectionBackgroundDecoration]
// 注册 decoration view
layout.register(SectionBackgroundDecorationView.self, forDecorationViewOfKind:SectionBackgroundDecorationView.elementKind)
return layout
复制代码
你会看到跟 supplementary layout 一样的逻辑:我们创造了一个 NSCollectionLayoutDecorationitem.background
,并且设定好它对应的 elementKind
跟 contentInsets
,在 layout
的时候,刚刚新增的 SectionBackgroundDecorationView
就会被拿来当做背景贴在 section
的后面,附加设定好的边界空间。跟 supplementary view 不一样的地方是,要记得最后在回传 layout
时,注册这个 decoration view,而不是在 Collection View 中注册。
完全客制化的 Custom Layout
利用 NSCollectionLayoutGroup
的 .custom
这个 build method:
let height: CGFloat = 120.0
let groupLayoutSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),heightDimension: .absolute(height))
let group = NSCollectionLayoutGroup.custom(layoutSize: groupLayoutSize) { (env) ->[NSCollectionLayoutGroupCustomItem] in
let size = env.container.contentSize
let spacing: CGFloat = 8.0
let itemWidth = (size.width-spacing*4)/3.0
return [
NSCollectionLayoutGroupCustomItem(frame: CGRect(x: 0, y: 0, width: itemWidth,height: height/3.0)),
NSCollectionLayoutGroupCustomItem(frame: CGRect(x: (itemWidth+spacing), y:height/3.0, width: itemWidth, height: height/3.0)),
NSCollectionLayoutGroupCustomItem(frame: CGRect(x: ((itemWidth+spacing)*2), y:height*2/3.0, width: itemWidth, height: height/3.0))
]
}
复制代码
先从 .custom
的定义开始看起:
open class func custom(layoutSize: NSCollectionLayoutSize, itemProvider: @escapingNSCollectionLayoutGroupCustomItemProvider) -> Self
复制代码
这里的 .custom
是指明我们这个 group
里面的 layout
要我们自己来决定,第一个参数 layoutSize
想必大家很熟悉了,就是指定 group
的大小。itemProvider
是 (NSCollectionLayoutEnvironment) -> [NSCollectionLayouGroupCustomItem]
的 closure
,在系统准备呈现 item
之前,会调用这个 closure
,让 closure
提供每一个 item
的 frame
。这个 closure
会传入一个代表容器的 NSCollectionLayoutEnvironment
进来,然后我们就可以利用这个 environment
提供的资讯,来计算 item
的大小跟位置,并且在 closure
把算好的 item
大小跟位置,通过 NSCollectionLayoutGroupCustomItem
这个组件传出来,回传值是一个数组,意思是你要指明这个 group
总共会有几个 item
,所以你会看到上面我们通过 env.container.contentSize
拿到容器的大小。我们可以通过这个计算出 group
里面的三个 item
的绝对位置,然后通过 NSCollectionLayoutGroupCustomItem
打包再回传回去。拿到这些内容的 group
,就可以知道如何在内部呈现这些 item
了。
小结
除了上面这些基本套路之外,还有非常多种可能的组合方式,不过你会发现,只要了解了基本的这些 group
、item
、spacing
、dimention
等等的原理,任何变化都会变得十分简单,就像是找到正确的积木就能堆出城堡一样。
另外一个很推荐的资源是 Apple 官方的资料:Implementing Modern Collection Views,这是一个范例程序,里面涵盖了几乎所有常见的 Compostional Layout 的套路,而且每一种套路都被切分成一个个 view controller,你只要执行范例 app,对照 UI 上标识的 view controller 的名称,就可以找到对应功能的范例程序。
Compostional Layout 跟 data source 是相当独立的两个部分,你可以使用 compositional layout + 原本的 data source,也可以使用新的 diffable data source,在替换的时候完全不需要改变彼此的代码。
目前 Compositional Layout 只能用在 iOS 13 以上的机器,不过已经神人 Kishikawa Katsumi 把这一套 compositional layout 实现出来,让你可以在 ios12 及以下的 SDK 中使用:IBPCollectionViewCompositionalLayout。
Diffable Data Source
ios 13
在推出 Diffable Data Source 之前,开发者需要使用 numberOfItemsInSection
和 cellForItemAt
方法来构建 data source,如果要更新 data source,就需要使用 performBatchUpdates
和 reloadData()
方法。
而且,之前一般是 controller 维护一份 data source,UI 也维护一份 data source,需要开发者去控制它们两者的数据保持一致;reloadData
虽然可以建立和刷新 data source,但是并不支持动画;而 performBatchUpdates
的使用,很容易导致一些常见的问题,比如 NSInternalInconsistencyException
,因为数据源不是集中管理的,很容易出现数据源不一致的情况,从而导致崩溃。
有了新的 Diffable data source,就可以通过 Snapshot 来统一提供数据。
Snapshot 代表一种 data source 的状态,它不是依赖于 index path 来更新 Item
,而是依靠唯一标识符(identifter)来识别唯一的 Section
和 Item
。
而且,如果你使用 apply
方法来更新数据源,它是允许你使用动画的,只要你将 apply
方法中的 animatingDifferences
参数设置为 true
。更牛逼的是,apply
方法可以在异步队列执行,减少了要在主线程中做的事情。
可以通过读取 dataSource.snapshot()
来读取 UI 的当前状态,并相应的添加或删除 item
。
构建原始数据
先来看一下基本的使用,构建原始数据时:
dataSource = UICollectionViewDiffableDataSource<Int, UUID>(collectionView:collectionView) {
(collectionView: UICollectionView, indexPath: IndexPath, itemIdentifier: UUID) ->UICollectionViewCell? in
// configure and return cell
}
// Create a snapshot
// 2
var snapshot = NSDiffableDataSourceSnapshot<Int, UUID>()
// Populate the snapshot,添加一组 section,为section添加一组items,传nil的话会取最后一次添加的section
// 3
snapshot.appendSections([0])
snapshot.appendItems([UUID(), UUID(), UUID()], toSection: nil)
// Apply the snapshot.
// 4
dataSource.apply(snapshot, animatingDifferences: true)
复制代码
先来看看 //1
。
dataSource = UICollectionViewDiffableDataSource<Int, UUID>(collectionView:collectionView) {
(collectionView: UICollectionView, indexPath: IndexPath, itemIdentifier: UUID) ->UICollectionViewCell? in
// configure and return cell
}
// UICollectionViewDiffableDataSource
open class UICollectionViewDiffableDataSource<SectionIdentifierType,ItemIdentifierType> : NSObject, UICollectionViewDataSource where SectionIdentifierType: Hashable, ItemIdentifierType : Hashable {
// 构建方法
public init(collectionView: UICollectionView, cellProvider: @escapingUICollectionViewDiffableDataSource<SectionIdentifierType,ItemIdentifierType>.CellProvider)
// Cell Provider
public typealias CellProvider = (_ collectionView: UICollectionView, _ indexPath:IndexPath, _ itemIdentifier: ItemIdentifierType) -> UICollectionViewCell?
}
复制代码
首先,dataSource
其实是 UICollectionViewDiffableDataSource
,其中的 SectionIdentifierType
和 ItemIdentifierType
分别是 Collection View 的 section
的唯一标识符的类型和 item
的唯一标识符的类型。它的初始化方法,需要传入一个 Collection View,然后在尾随闭包中,需要实现 CellProvider
去返回一个 UICollectionViewCell
,这个 CellProvider
其实与之前的 cellForItemAt:
方法是相对应的。
再来看 //2
:
var snapshot = NSDiffableDataSourceSnapshot<Int, UUID>()
复制代码
构建一个 snapshot
,同样,Int
和 UUID
分别是 section
的 identifier
的类型和 item
的 identifier
的类型,与 //1
中的类型是对应的。
//3
:
snapshot.appendSections([0])
snapshot.appendItems([UUID(), UUID(), UUID()], toSection: nil)
复制代码
添加 sections
和 items
,传入的数据类型必须是可哈希的,遵循 Hashable
。appItems
中的 toSection
非必填, 不填或者填 nil
,会添加到最后一次赋值的那个 section
上去。
//4
:
dataSource.apply(snapshot, animatingDifferences: true)
// apply 方法定义
nonisolated open func apply(_ snapshot:NSDiffableDataSourceSnapshot<SectionIdentifierType, ItemIdentifierType>,animatingDifferences: Bool = true, completion: (() -> Void)? = nil)
复制代码
这一步就是调用 apply
了,animatingDifferences
是控制刷新时是否需要动画的,一般初始化时设置为 false
,再次刷新时设置为 true
。并且,这个方法提供一个 completion
回调。
刷新数据
当需要刷新数据的时候,只需要新建一个 snapshot
,然后调用 apply()
即可。
var snapshot = NSDiffableDataSourceSnapshot<Int, UUID>()
snapshot.appendSections([0])
snapshot.appendItems([UUID(), UUID()])
dataSource.apply(snapshot, animatingDifferences: true)
复制代码
其余的事情就不需要管了,Collection View 内部会比对差异,并将更新更改到 UI 之上,你还可以拥有它的动画。不再需要使用之前的 performBatchUpdates:
去对某一个 indexPath
进行刷新,这些都被封装在了 apply
内部。
iOS 14
ios 14 之后引入了 SectionSnapshots,可以方便的建立展开式的集合视图,并且引入了新的 Recordering API,可以帮助我们快读的将 Recording API 插入到 diffable data source 之中。
并且加入了 UICollectionViewListCell
,一种具有更多功能的 cell,有兴趣的话可以看看 iOS 14 的 Diffable Data Source 讓你輕鬆建立和更新大量資料
总结
iOS 自 iOS 11 之后,Collection View 发生的一系列变化,包括支持拖拽、新的布局方式,新的数据源,新的 Cell 注册方式,以及更加现代的 Collection Cell,随着不断的优化,相信 Collection View 会越来越强大,我们也可以更加集中注意我们的 app 的创意而不是浪费太多的时间在功能的实现上。
参考资料
WWDC16 What's New in UICollectionView in iOS 10
WWDC17 Introducing Drag and Drop
WWDC17 Drag and Drop with Collection and TableView
WWDC18 A Tour of UICollectionView
WWDC19 Advances in UI Data Sources
WWDC19 Advances in Collection View Layout
WWDC20 Advances in diffable data sources
WWDC20 Modern cell configuration
WWDC20 Lists in UICollectionView
拖拽部分的 sessions:
WWDC17 Data Delivery with Drag and Drop
WWDC17 Mastering Drag and Drop
WWDC17 File Provider Enhancements
文档参考:
Compositional Layout 詳解 讓你簡單操作 CollectionView!
iOS 14 的 Diffable Data Source 讓你輕鬆建立和更新大量資料
可以下载官方提供的 Demo 下来作为使用参考。其中拖拽的 Demo 只允许 iPad 使用,所以放在了另外一个 地址 。
转载请注明出处:iOS 11 之后的 Collection View