iOS 11 之后的 Collection View

前言

本文会介绍 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(_ interactionUIDragInteractionitemsForBeginning 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(_ interactionUIDragInteractionpreviewForLifting item:UIDragItemsessionUIDragSession) -> UITargetedDragPreview? {
   let index = item.localObject as! Int
   return UITargetedDragPreview(view: views[index])
}
复制代码

4.当你拖拽的元素在移动时,你可以设置一些效果,如回流方式,是 move 或者 copy 或者其他操作

// UIDropInteractionDelegate// 会去请求 sessionAllowsMoveOperation,如果返回 false,move操作将不被允许
func dropInteraction(_ interactionUIDropInteractionsessionDidUpdate session:UIDropSession) -> UIDropProposal {
// cancel forbidden copy move
   return UIDropProposal(operation: UIDropOperation)
}
复制代码

image.png

5.拖拽结束,处理你松手后的操作

扫描二维码关注公众号,回复: 13557027 查看本文章
// UIDropInteractionDelegatefunc dropInteraction(_ interactionUIDropInteractionperformDrop 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(_ interactionUIDragInteractionitemsForBeginning session:UIDragSession) -> [UIDragItem] {
    return []
}
​
// UIDropInteractionDelegate// 处理松手后的操作
func dropInteraction(_ interactionUIDropInteractionperformDrop session:UIDropSession) {
}
复制代码

参考下面两张图来更详细的了解 drag & drop 的 deadline:

image.png

大致流程为:用户选中 -> 请求开发者返回拖拽的元素 -> lift 动画 -> 用户移动 -> 开始拖拽 > 允许自定义预览 & 动画 -> 拖动结束 -> 回调 Perform -> 执行 Drop 的动画(可自定义)-> 传输数据(异步)

image.png

Collection View DragDelegate and DropDelegate 介绍

CollectionView 需要实现的 delegate 略有不同,但是原理是一样的。在没有这套协议之前,我们需要自己实现拖拽,代码看起来长下面这样:

@objc func longPressAction(_ longPressUILongPressGestureRecognizer) {
   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 之后,只需要设置 dragDelegatedropDelegate 并实现,然后将 dragInteractionEnabled 设置为 true 即可。

collectionView.dragDelegate = self
collectionView.dropDelegate = self
// ipad默认为true,iphone ios15 之前默认为false,ios15之后默认为 true
collectionView.dragInteractionEnabled = true
复制代码

分别看一下两个代理的一些方法:

// UICollectionViewDragDelegate// @required
// 开始拖拽
// 返回空数组的话不响应拖动事件
func collectionView(_ collectionViewUICollectionViewitemsForBeginning session:UIDragSessionat indexPathIndexPath) -> [UIDragItem]) {
 
}
​
// @optional 
// 添加 item 到正在拖拽的 session
func collectionView(_ collectionViewUICollectionView,
                   itemsForAddingTo sessionUIDragSession,
                   at indexPathIndexPath,
                   pointCGPoint) -> [UIDragItem]) {
 
}
​
// UICollectionViewDropDelegate// @required
// 松开手指时的处理,不实现的话会采用默认方案
func collectionView(_ collectionViewUICollectionViewperformDropWith coordinator:UICollectionViewDropCoordinator) {
 
}
复制代码

这样你就可以实现拖拽了。当然,还有一些额外的代码方法来帮助你获取拖拽时的一些状态和自定义视图或者动画:

// UICollectionViewDragDelegate// 自定义 drag 的预览,如果没有实现或者返回 nil,那么将使用整个 cell 作为预览
func collectionView(_ collectionViewUICollectionViewdragPreviewParametersForItemAtindexPathIndexPath) -> UIDragPreviewParameters? {}
​
// 选中一个 item 的动画完成之后,开始拖拽之前会调用该方法
func collectionView(_ collectionViewUICollectionViewdragSessionWillBegin session:UIDragSession) {}
​
// 拖拽结束会调用该方法
func collectionView(_ collectionViewUICollectionViewdragSessionDidEnd session:UIDragSession) {}
​
// 控制拖动会话是否允许移动操作,默认为 true,如果为false,那么 .move 操作将不被允许
// UIDropOperation的四种操作:cancel | forbidden | copy | move
func collectionView(_ collectionViewUICollectionViewdragSessionAllowsMoveOperationsessionUIDragSession) -> Bool {}
​
// 能不能被拖动到另外的app,默认为 false
func collectionView(_ collectionViewUICollectionView,dragSessionIsRestrictedToDraggingApplication sessionUIDragSession) -> 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;
复制代码

image.png

回流方式:

UICollectionViewDropProposal.Indent,在 dropSessionDidUpdate 方法中设置:

// UICollectionViewDropProposal.Indent 这个属性可以控制 drop 的缺口打开效果
// unspecified: 不会打开缺口,目标索引也不会高亮
// insertAtDestinationIndexPath: 打开一个缺口,模拟最终释放时的效果
// insertIntoDestinationIndexPath: 不会打开缺口,但是目标索引会高亮
复制代码

默认效果:

缺口打开效果.gif

具体的用法可以参考 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 的架构:

image.png

在设定 Compositional Layout 的时候,我们的流程会像这样:先创造出属于 itemlayout 组件,并且设定好大小、边界距离等等,每一种长相不一样的 item,就会有一个专属的物件来描述它。设定好 item 之后,再创造一个 group layou 组件,把刚刚产生的 item 物件都丢到这个 group 里,并且设定好 group 本身的大小、边界距离等等,再丢到属于 sectionlayout 组件里,以此类推,到最后,一个 Collection View 的长相,就会由一个最大的 layout 物件来描述,它里面包含了不同的 sectionsection 里又包含了 group,而 group 里包含了各种 item。所以上面的这张图,同时也是这些组件的阶层图,你可以把这些方块,都当成是程序里一个一个的组件。

各种基本组件

来实现一个横向滚动的 Collection View:

image.png

代码:

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 这个类非常重要,它是描述所有 groupitem 的组件,上面这段代码的意思是:设定宽度和是 group 的 0.2,高度和 group 一样(1x)。widthDismensionheightDismensionNSCollectionLayoutDimension 的两个参数。NSCollectionLayoutDimension 就是用来描述宽度和高度的组件:

open class NSCollectionLayoutDimension : NSObjectNSCopying {
// 宽度是容器的某个比例,比方1(一样)或0.5(一半)
   open class func fractionalWidth(_ fractionalWidthCGFloat) -> Self
// 高度是容器的某个比例,比方1(一样)或0.5(一半)
   open class func fractionalHeight(_ fractionalHeightCGFloat) -> Self
// 设定一个绝对值
   open class func absolute(_ absoluteDimensionCGFloat) -> Self
// 这是一个预估值,实际布局完成之后,会使用实际的内容大小来展示
   open class func estimated(_ estimatedDimensionCGFloat) -> 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 使用。

但是上面的代码我们得到的效果是这样的:

横向移动2.gif

会发现有个缺口,其实它是长这样的:

image.png

虽然 item 的宽度设定为容器的 0.2,但是因为 interItemSpacing 的关系,group 里面放不下 5 个 item,所以最右边会有一个多出来的空间。所以你大概也能看出 group 的作用了,它的作用就是将一个或多个 item 圈在一起,利用这个特性,就可以做出许许多多的变化。比如在一个横向滚动的 section 中加上垂直排列的三个 cell,就只需要设定一个 vertical group,然后里面去包含三个 item 就可以了。

回到需求,我们希望有一个能够横向滚动的 section,并且 itemitem 之间都是等间距的,该怎么做呢?

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,并且这个 itemgroup 是一样大小的。不过还是要设定一下元素之间的间距,现在改成要设定 groupgroup 之间的间距了,所以 //3,通过 group.edgeSpacing 来设定 group 之间的间距。

// NSCollectionLayoutEdgeSpacingclass NSCollectionLayoutEdgeSpacing {
public convenience init(leadingNSCollectionLayoutSpacing?, top:NSCollectionLayoutSpacing?, trailingNSCollectionLayoutSpacing?, bottom:NSCollectionLayoutSpacing?) 
}
​
class NSCollectionLayoutSpacing {
// 指定一个可伸长的最小距离
open class func flexible(_ flexibleSpacingCGFloat) -> Self // i.e. >=
   // 指定固定的距离
   open class func fixed(_ fixedSpacingCGFloat) -> Self // i.e. ==
}
复制代码

这样,我们就能得到一个正确的横向移动的 Collection View 了:

横向移动4.gif

布局 Demos

item 垂直相叠的横向滚动

横向移动_demo2.gif 代码:

// 提供三种不同形狀的 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

横向移动_demo3.gif

代码:

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 : NSCollectionLayoutItemNSCopying {
...
}
复制代码

group 其实是 item 的子类,也就是说,任何放 NSCollectionLayoutItem 的地方,都可以放 NSCollectionLayoutGroup,这个特性让许多复杂的 layout,都能够转化成 groupitem 的组合。

image.png

只要在 group 中放入一个 subGroupsubGroup 中再放两个 item,就能实现 Nested Group 这种布局了。

UICollectionLayoutSectionOrthogonalScrollingBehavior 滚动方式

section 可以设置 orthogonalScrollingBehavior,前面提到过,比如 Collection View 是垂直滚动的,那你设置了 sectionorthogonalScrollingBehavior 之后,这个 section 就可以进行横向滚动,这个参数可以设定不同的值:

  • .none 默认值,不会滚动
  • .continuous 连续滚动
  • .continuousGroupLeadingBoundary 连续滚动,但最后会停在 gruop 的前沿(gruop` 的起始位置偏移一定值)
  • .paging 每次滚动跟 Collection View 一样宽(或高)的距离
  • .groupPaging 每次会滚动一个 group
  • .groupPagingCentered 每次滚动一个 group,并且停在 group 的中点

分别看一下效果:

横向移动_demo4.gif

横向移动_demo5.gif

横向移动_demo6.gif

横向移动_demo7.gif

横向移动_demo8.gif

Header - Boundary Supplementary Item

section 加上 header。

UICollectionView 里面,headerfooter 都是一种 supplementary view ,附加在 section 旁边。Compostional Layout 自然也有办法设定这些 supplementarylayout,并且逻辑上跟设定其他的 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
复制代码

上述代码的意思可以通过下图说明:

image.png

如果你想要实现 footer,将 alignment 改成 .bottom 即可。如果是横向移动的 sectionheaderalignment 可能就需要是 .leading

当然,要记住在 UICollectionViewDataSource 里面实现下面这个方法,根据接收到的 viewForSupplementaryElementOfKind 来提供对应的 header view

@objc open func collectionView(_ collectionViewUICollectionView,viewForSupplementaryElementOfKind kindStringat indexPathIndexPath) ->UICollectionReusableView
复制代码

记得还需要注册这个 supplementary view

CollectionView.register(HeaderView.self, forSupplementaryViewOfKind:UICollectionView.elementKindSectionHeader, withReuseIdentifier: "HeaderView")
复制代码

小红点 badge - Supplementary Item

image.png

NSCollectionLayoutBoundarySupplementaryItem 的定义:

open class NSCollectionLayoutBoundarySupplementaryItem :NSCollectionLayoutSupplementaryItemNSCopying {
// ...
}
复制代码

它是基于 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 之外,你也要指定它的两个锚点,containerAnchoritemAnchor,用图看就可以了解 containerAnchoritemAnchor 分别代表什么:

image.png

从上图可以看出,itemAnchor 代表的是 item 用来对齐的锚点,而 containerAnchor 代表的是包含这个 itemsectiongroup 用来对齐的锚点。NSCollectionLayoutSupplementaryItem 会在 layout 的时候,将 itemcontainer 的锚点对齐,所以通过这两个参数,就可以将 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(_ CollectionViewUICollectionViewviewForSupplementaryElementOfKind kindString, 
                         at indexPathIndexPath) -> UICollectionReusableView
复制代码

并且处理好 BadgeView.supplementaryKind 这个 element kind 的 supplementary view。

提供 section 背景的 decoration item

横向移动9.png

看到上面这个 layout,有做过的人就知道这是所有 UI 工程师的痛。这是一个有 5 个 itemsection,并且它有一个圆角的灰色背景。以往这样的 layout 你就一定要提供一个 decoration view,并且实现 UICollectionViewLayout 计算好它的大小,其他的方法都只会比计算数学更复杂,还有看过像汉堡一样做出上、中、下三种样式的 item,再把他们叠起来的。

不过现在,compostional layout 也有办法控制 decoratino view 的 layout 了,首先跟 supplementary 一样,我们需要有一个 decoration view:

class SectionBackgroundDecorationViewUICollectionReusableView ... }
复制代码

设定 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,并且设定好它对应的 elementKindcontentInsets,在 layout 的时候,刚刚新增的 SectionBackgroundDecorationView 就会被拿来当做背景贴在 section 的后面,附加设定好的边界空间。跟 supplementary view 不一样的地方是,要记得最后在回传 layout 时,注册这个 decoration view,而不是在 Collection View 中注册。

完全客制化的 Custom Layout

横向移动10.png

利用 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) ->[NSCollectionLayoutGroupCustomItemin
   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(layoutSizeNSCollectionLayoutSizeitemProvider: @escapingNSCollectionLayoutGroupCustomItemProvider) -> Self
复制代码

这里的 .custom 是指明我们这个 group 里面的 layout 要我们自己来决定,第一个参数 layoutSize 想必大家很熟悉了,就是指定 group 的大小。itemProvider(NSCollectionLayoutEnvironment) -> [NSCollectionLayouGroupCustomItem]closure,在系统准备呈现 item 之前,会调用这个 closure,让 closure 提供每一个 itemframe。这个 closure 会传入一个代表容器的 NSCollectionLayoutEnvironment 进来,然后我们就可以利用这个 environment 提供的资讯,来计算 item 的大小跟位置,并且在 closure 把算好的 item 大小跟位置,通过 NSCollectionLayoutGroupCustomItem 这个组件传出来,回传值是一个数组,意思是你要指明这个 group 总共会有几个 item,所以你会看到上面我们通过 env.container.contentSize 拿到容器的大小。我们可以通过这个计算出 group 里面的三个 item 的绝对位置,然后通过 NSCollectionLayoutGroupCustomItem 打包再回传回去。拿到这些内容的 group,就可以知道如何在内部呈现这些 item 了。

小结

除了上面这些基本套路之外,还有非常多种可能的组合方式,不过你会发现,只要了解了基本的这些 groupitemspacingdimention 等等的原理,任何变化都会变得十分简单,就像是找到正确的积木就能堆出城堡一样。

另外一个很推荐的资源是 Apple 官方的资料:Implementing Modern Collection Views,这是一个范例程序,里面涵盖了几乎所有常见的 Compostional Layout 的套路,而且每一种套路都被切分成一个个 view controller,你只要执行范例 app,对照 UI 上标识的 view controller 的名称,就可以找到对应功能的范例程序。

横向移动_demo10.gif

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 之前,开发者需要使用 numberOfItemsInSectioncellForItemAt 方法来构建 data source,如果要更新 data source,就需要使用 performBatchUpdatesreloadData() 方法。

而且,之前一般是 controller 维护一份 data source,UI 也维护一份 data source,需要开发者去控制它们两者的数据保持一致;reloadData 虽然可以建立和刷新 data source,但是并不支持动画;而 performBatchUpdates 的使用,很容易导致一些常见的问题,比如 NSInternalInconsistencyException,因为数据源不是集中管理的,很容易出现数据源不一致的情况,从而导致崩溃。

有了新的 Diffable data source,就可以通过 Snapshot 来统一提供数据。

Snapshot 代表一种 data source 的状态,它不是依赖于 index path 来更新 Item,而是依靠唯一标识符(identifter)来识别唯一的 SectionItem

而且,如果你使用 apply 方法来更新数据源,它是允许你使用动画的,只要你将 apply 方法中的 animatingDifferences 参数设置为 true。更牛逼的是,apply 方法可以在异步队列执行,减少了要在主线程中做的事情。

可以通过读取 dataSource.snapshot() 来读取 UI 的当前状态,并相应的添加或删除 item

构建原始数据

先来看一下基本的使用,构建原始数据时:

dataSource = UICollectionViewDiffableDataSource<IntUUID>(collectionView:collectionView) {
   (collectionView: UICollectionView, indexPath: IndexPath, itemIdentifier: UUID) ->UICollectionViewCellin
   // configure and return cell
}
​
// Create a snapshot
// 2
var snapshot = NSDiffableDataSourceSnapshot<IntUUID>()        
​
// 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<IntUUID>(collectionView:collectionView) {
   (collectionView: UICollectionView, indexPath: IndexPath, itemIdentifier: UUID) ->UICollectionViewCellin
   // configure and return cell
}
​
// UICollectionViewDiffableDataSource
open class UICollectionViewDiffableDataSource<SectionIdentifierType,ItemIdentifierType> : NSObjectUICollectionViewDataSource where SectionIdentifierTypeHashableItemIdentifierType : Hashable {
// 构建方法
public init(collectionViewUICollectionViewcellProvider@escapingUICollectionViewDiffableDataSource<SectionIdentifierType,ItemIdentifierType>.CellProvider) 
 
// Cell Provider
public typealias CellProvider = (_ collectionView: UICollectionView_ indexPath:IndexPath_ itemIdentifier: ItemIdentifierType) -> UICollectionViewCell?
}
复制代码

首先,dataSource 其实是 UICollectionViewDiffableDataSource,其中的 SectionIdentifierTypeItemIdentifierType 分别是 Collection View 的 section 的唯一标识符的类型和 item 的唯一标识符的类型。它的初始化方法,需要传入一个 Collection View,然后在尾随闭包中,需要实现 CellProvider 去返回一个 UICollectionViewCell,这个 CellProvider 其实与之前的 cellForItemAt: 方法是相对应的。

再来看 //2

var snapshot = NSDiffableDataSourceSnapshot<IntUUID>()    
复制代码

构建一个 snapshot,同样,IntUUID 分别是 sectionidentifier 的类型和 itemidentifier 的类型,与 //1 中的类型是对应的。

//3

snapshot.appendSections([0])
snapshot.appendItems([UUID(), UUID(), UUID()], toSection: nil)
复制代码

添加 sectionsitems,传入的数据类型必须是可哈希的,遵循 HashableappItems 中的 toSection 非必填, 不填或者填 nil,会添加到最后一次赋值的那个 section 上去。

//4

dataSource.apply(snapshot, animatingDifferences: true)
​
// apply 方法定义
nonisolated open func apply(_ snapshot:NSDiffableDataSourceSnapshot<SectionIdentifierTypeItemIdentifierType>,animatingDifferencesBool = truecompletion: (() -> Void)? = nil)
复制代码

这一步就是调用 apply 了,animatingDifferences 是控制刷新时是否需要动画的,一般初始化时设置为 false,再次刷新时设置为 true。并且,这个方法提供一个 completion 回调。

刷新数据

当需要刷新数据的时候,只需要新建一个 snapshot,然后调用 apply() 即可。

var snapshot = NSDiffableDataSourceSnapshot<IntUUID>()
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

猜你喜欢

转载自juejin.im/post/7038895063203577887