前言
UITableView 和 UICollectionView 是我们开发者最常用的控件了,大量的流式布局需要这两个控件来实现,因此这两个控件也是 Apple 重点优化的对象。在往届 WWDC 中,我们已经受益于 UITableViewDataSourcePrefetching 、优化版 Autolayout 等带来的性能提升,以及 UITableViewDragDelegate 带来的原生拖拽功能。今年,Apple 带来了全新的 Compositional Layout 。它将彻底颠覆 UICollectionView 的布局体验,大大拓展 UICollectionView 的可塑性。
背景
早期的 App 设计相对简单,使用 UICollectionViewFlowLayout 可以应付大多数使用场景。而随着应用的发展,越来越多的页面趋于复杂化,UICollectionViewFlowLayout 在面对复杂布局往往会显得力不从心,或者非常复杂,需要进行大量的计算和判断。而自由度更高的 UICollectionViewLayout 则有着更高的接入门槛,稍有不慎还容易出现各种各样的 bug 。
我们就拿 App Store为例,它包含了大小不一的
Item
,以及可以上下、左右滑动的交互。假如你是开发者,你会如何搭建这个 UI ?你可能会使用多个 UICollectionView 嵌套在一个 UIScrollerView 中,因为 UICollectionView 的滚动轴只能有一个(横向 / 竖向)。但如果我告诉你,在新版 iOS 13 中,这个页面只使用了一个 UICollectionView ,你会有什么感觉。你一定很好奇它是怎么做到的。其中的秘密就是 Compositional Layout 。
介绍
Compositional Layout 是此次随 iOS 13 一同发布的全新 UICollectionView 布局。它的目标有三个:
- Composable 可组合的
- Flexible 灵活的
- Fast 快
为了达到上面这三个目标,Compositional Layout 在原有 UICollectionViewLayout Item
Section
的基础上,增加了一层 Group
的概念。多个 Item
组成一个 Group
,多个 Group
组成一个 Section
。
说了这么多,还不如上代码
// Create a List by Specifying Three Core Components: Item, Group and Section
let size = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(44.0))
let item = NSCollectionLayoutItem(layoutSize: size)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: size, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
let layout = UICollectionViewCompositionalLayout(section: section)
可以看到,为了能够将复杂的布局描述清楚,我们需要创建多个类来分别描述 Item
、 Group
、 Section
的大小、间距等属性。
如何解读上面这段代码?
- 首先
Item
的高度为44定高,宽度是父视图(Group
)宽度的 100% 。 -
Group
的尺寸描述使用了和Item
完全相同的的 size ,即高度为44定高,宽度是父视图(Section
)宽度的 100% 。 -
Section
的宽度是 UICollectionView的宽度,高度默认为其Group
所有元素渲染出来的总高度,即Group
的高度。 - 最终,我们会通过 Frame 或 AutoLayout对 UICollectionView 进行尺寸设置。
通过上面的解析,你能够在脑中勾画出这个 UICollectionView 长什么样子吗?好吧,其实我也不能,但好在我能够跑一下代码看下实际但结果。
结果就是一个类似 UITableView 的布局。
好吧,我承认这有点难。因为我们看代码的顺序都是从上而下,但假如 Compositional Layout 层级的尺寸依赖于父视图,我们就不得不结合父视图和自身的布局来推倒出最终的布局,这需要一定的空间想象力。
在上面这个例子中,每一个 “UITableViewCell” 就是一个 Item
,也是一个 Group
,而整个 “UITableViewCell” 只包含了一个 Section
。
所以看到这里你一定会好奇,我们为什么需要 Group
这么一个东西?很抱歉我需要将这个疑问留到最后。
核心布局
我们先来谈谈最基础的核心布局。
在详细介绍 Compositional Layout 中用到的四大类之前,我们需要先来了解一下,一个新的用于描述尺寸大小的类。
NSCollectionLayoutDimension
过去,我们可以使用 CGSize 来描述一个固定大小的 Item
。后来,我们拥有了 estimatedItemSize
来描述一个动态计算大小的 Item
,并且给它一个预估的值。但更多的时候,为了适配不同的屏幕尺寸,我们需要根据屏幕的宽度手动计算出 Item
的大小(比如限定一行只显示3个 Item
)。
如何用简洁优雅的方式去描述上面三种场景呢?答案是 NSCollectionLayoutDimension
class NSCollectionLayoutDimension {
class func fractionalWidth(_ fractionalWidth: CGFloat) -> Self
class func fractionalHeight(_ fractionalHeight: CGFloat) -> Self
class func absolute(_ absoluteDimension: CGFloat) -> Self
class func estimated(_ estimatedDimension: CGFloat) -> Self
}
NSCollectionLayoutDimension
添加了根据父视图的比例来描述尺寸的 fractionalWidth / fractionalHeight 的方法,并将定值、自适应、比例这三大描述方式统一分装了起来。
我们来看一个例子。
let size = NSCollectionLayoutDimension(widthDimension: .fractionalWidth(0.25),
heightDimension: .fractionalWidth(0.25))
}
如图,使用简单的描述,我们就可以得到以父视图(
Item
的父视图为 Group
)为基准的比例尺寸。它不仅被用于描述 Item
的大小,同样也用于 Group
。
了解完这个基础之后,让我们看看 NSCollectionLayoutDimension 是如何在 Compositional Layout 中发挥作用的。
-
NSCollectionLayoutSize
class NSCollectionLayoutSize { init(widthDimension: NSCollectionLayoutDimension, }
单纯用于描述
Item
的大小,使用到了上面介绍的 NSCollectionLayoutDimension。 -
NSCollectionLayoutItem
class NSCollectionLayoutItem { convenience init(layoutSize: NSCollectionLayoutSize) var contentInsets: NSDirectionalEdgeInsets }
用于描述一个
Item
的完整布局信息,包含了上面的尺寸 NSCollectionLayoutSize ,以及边距 NSDirectionalEdgeInsets。 -
NSCollectionLayoutGroup
class NSCollectionLayoutGroup: NSCollectionLayoutItem { class func horizontal(layoutSize: NSCollectionLayoutSize, subitems: [NSCollectionLayoutItem]) -> Self class func vertical(layoutSize: NSCollectionLayoutSize, subitems: [NSCollectionLayoutItem]) -> Self class func custom(layoutSize: NSCollectionLayoutSize, itemProvider: NSCollectionLayoutGroupCustomItemProvider) -> Self }
用于描述
Group
布局。它也提供了垂直 / 水平两种方向。同时你也可以实现 NSCollectionLayoutGroupCustomItemProvider 自定义Group
的布局方式。它同样接收一个 NSCollectionLayoutDimension ,用于确定
Group
的大小。需要注意的是,当Item
使用了 fractionalWidth / fractionalHeight 时,Group
的大小会影响Item
的大小。此外,它还有一个 subitems 参数,类型为 NSCollectionLayoutItem 数组,用于传递
Item
。 -
NSCollectionLayoutSection
class NSCollectionLayoutSection { convenience init(layoutGroup: NSCollectionLayoutGroup) var contentInsets: NSDirectionalEdgeInsets }
用于描述
Section
布局信息。同样可以通过修改 contentInsets 来改变Section
的边距。
以上就是用于描述 Compositional Layout 用到的四个类。通过对布局的精确描述,我们就能够得到可塑性非常强的 UICollectionView布局,而无需重写复杂的 UICollectionViewLayout 。不过,Compositional Layout 的可玩性还不止于此,如果想要进一步的自定义,需要使用到一些额外的高级布局技巧。
高级布局
NSCollectionLayoutAnchor
对于 Item 而言,我们可能会有类似 iOS 桌面小圆点的需求。通过 NSCollectionLayoutAnchor ,我们可以很容易的给 Item 添加自定义小控件。
// NSCollectionLayoutAnchor
let badgeAnchor = NSCollectionLayoutAnchor(edges: [.top, .trailing],
fractionalOffset: CGPoint(x: 0.3, y: -0.3))
let badgeSize = NSCollectionLayoutSize(widthDimension: .absolute(20),
heightDimension: .absolute(20))
let badge = NSCollectionLayoutSupplementaryItem(layoutSize: badgeSize, elementKind: "badge", containerAnchor: badgeAnchor)
let item = NSCollectionLayoutItem(layoutSize: itemSize, supplementaryItems: [badge])
同样是通过多个类来分别描述 Anchor 的方位、大小和视图,我们就可以非常方便地为 Item 添加自定义锚。
NSCollectionLayoutBoundarySupplementaryItem
Headers 和 Footers 是也我们经常用到的组件,这次 Compositional Layout 弱化了 Header 和 Footer 的概念,他们都是 NSCollectionLayoutBoundarySupplementaryItem
,只不过你可以通过描述其相对于 Section
的位置(top / bottom)来达到过去 Header 和 Footer 的效果。
// NSCollectionLayoutBoundarySupplementaryItem
let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: "header", alignment: .top)
let footer = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: footerSize, elementKind: "footer", alignment: .bottom)
header.pinToVisibleBounds = true
section.boundarySupplementaryItems = [header, footer]
pinToVisibleBounds
属性则是用来描述 NSCollectionLayoutBoundarySupplementaryItem
划出屏幕后是否留在 CollectionView 的最上端,也就是之前 Plain style
的 Header 样式。
NSCollectionLayoutDecorationItem
有没有遇到过这样的UI需求?
以往要实现这样的样式往往会非常复杂,而如今我们终于可以自定义 Section 的背景啦。
// Section Background Decoration Views
let background = NSCollectionLayoutDecorationItem.background(elementKind: "background")
section.decorationItems = [background]
// Register Our Decoration View with the Layout
layout.register(MyCoolDecorationView.self, forDecorationViewOfKind: "background")
通过NSCollectionLayoutDecorationItem
,我们可以为 Section
的背景添加自定义视图,其加载方式和 Item
Header
Footer
一样通过,需要先 register
。
Estimated Self-Sizing
在添加了如此多自定义特性之后,Compositional Layout 依旧支持自适应尺寸。这极大方便了我们对动态内容的展示,同时对 Dynamic text 这类系统特性也能有更好的支持。
// Estimated Self-Sizing
let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(44.0))
let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize,
header.pinToVisibleBounds = true
elementKind: "header",
alignment: .top)
section.boundarySupplementaryItems = [header, footer]
Nested NSCollectionLayoutGroup
不知道你有没有发现,NSCollectionLayoutGroup
初始化方法中的 subitems
参数类型为 NSCollectionLayoutItem
数组,而 NSCollectionLayoutGroup
同样继承自 NSCollectionLayoutItem
,也就是说,NSCollectionLayoutGroup
内可以嵌套 NSCollectionLayoutGroup
。这样作的目的是,通过嵌套 Group
我们可以自定义出层级更加复杂的布局。
这个 Group 用代码如何描述?
// Nested NSCollectionLayoutGroup
let leadingItem = NSCollectionLayoutItem(layoutSize: leadingItemSize) let trailingItem = NSCollectionLayoutItem(layoutSize: trailingItemSize)
let trailingGroup = NSCollectionLayoutGroup.vertical(layoutSize: trailingGroupSize) subitem: trailingItem, count: 2)
let containerGroup = NSCollectionLayoutGroup.horizontal(layoutSize: containerGroupSize, subitems: [leadingItem, trailingGroup])
想一想如此复杂的布局如果自己去实现 UICollectionViewLayout 将会是多么复杂,如今通过简洁而抽象的 Compositional Layout API 我们可以非常直观的描述这一布局。
Orthogonal Scrolling Sections
这个特性就是我们前面提到的,让 Section 可以滚动起来的特性。
// Orthogonal Scrolling Sections
section.orthogonalScrollingBehavior = .continuous
通过设置 Section 的 orthogonalScrollingBehavior 参数,我们可以实现多种不同的滚动方式。
// Orthogonal Scrolling Sections
enum UICollectionLayoutSectionOrthogonalScrollingBehavior: Int {
case none
case continuous
case continuousGroupLeadingBoundary
case paging
case groupPaging
case groupPagingCentered
}
orthogonalScrollingBehavior
参数是一个 UICollectionLayoutSectionOrthogonalScrollingBehavior
类型的枚举,包含了我们在实际开发者会用到的几乎所有滚动方式,比如常见的自由滚动,按page滚动,以及按 Group 滚动(包含以 Group Leading 为边界和以 Group Center 为边界)。以往要实现类似的效果,我们大多需要自己实现 UICollectionViewLayout 或者干脆求助类似 AnimatedCollectionViewLayout 这样的第三方库,如今 Apple 已经为你全部实现!
而如果我希望做一个类似 App Store 中部这样滚动的布局呢?
这会稍稍有些复杂。首先,如果你仔细阅读文档,你会发现 NSCollectionLayoutGroup 有一个我们之前没有提到的 API 。
open class func vertical(layoutSize: NSCollectionLayoutSize, subitem: NSCollectionLayoutItem, count: Int) -> Self
它相比默认的 API ,subitem
不再接收数组而只接收单一的 Item
(意味着这个模式下,Group
不支持多种大小的 Item
或 Item
+ Group
的组合,但聪明的你一定想到了可以先构建一个组合的 Group
然后传进这个 API 中),同时多了一个 count
。这个 count
会让 Group
尝试在其限定的大小内塞入 count
个数的 Item
。最终达到的效果就是类似
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item, item, item])
不过上面的代码不会生效,因为 subitems
关注的是不同的 Item
的组合,而非实际 Item
的个数,因此 subitems
会对数组内的 Item 去重。因此如果你希望在一个 Group 中塞入多个 Item,后者是你唯一的选择。
看到这里你是否对 Group 的作用有了一点感觉?上面的例子中,如果我们关闭 Section 的滚动功能,那么会是什么样子的?
每个
Group
中还是会有 3 个 Item
,只不过由于 Section
的宽度限制,下一个 Group
不得不排布到上一个 Group
的下放,结果展示出来的还是一个类似 TableView 的布局。当我们打开 Section
的滚动模式,奇迹发生了。由于 Section
可以滚动,因此它存在类似于 ScrollerView 的 ContentView
,它的子 View
可以在更大的范围内渲染,因此之后的 Group
可以跟随在之前的 Group
右侧,并最终填充 Section 的整个 ContentView。
现在你该知道 Apple 为什么要引入 Group
的概念了吧。其实我在看 Advances in Collection View Layout 的时候也是闷的,直到最后看到了 App Store 的例子我才明白了,为了能够实现多纬度的滚动(实际上是赋予了 Section
滚动的特性),原有的层级就不足以描述一个完整的多维度 CollectionView ,需要一个额外的层级来描述位于 Section
和 Item
的中间层。这样说可能会略显生涩,大家可以把现在的 Section
想象成原来的 CollectionView ,而新的 Group
就是原来的 Section
。由于现在 Section
充当了之前 CollectionView 的角色被赋予了滚动的特性,因此需要一个额外的层级来描述之前 Section
所描述的 “一组 Item
的” 关系 。 Group
便由此出现。
可以说 Group 的存在是完全服务于这个可滚动 Section 的。可滚动的 Section 为 CollectionView 增加了一个纬度的信息流,如果你的 CollectionView 没有多维滚动的需求,那么你会发现使用 Compositional Layout 的 Group 是一个完全没有必要的事情。
复习
正如我前面所说,Compositional Layout 的层级关系依次是 Item > Group > Section > Layout 。
理解了这其中的层级关系和特性,能够帮助你写出更灵活、性能更好的 UI !
总结
Compositional Layout 为我们带来了更加可塑易用的 CollectionView 布局以及多维度瀑布流,对于 UICollectionView 而言是一个全新的升级,它将赋予 UICollectionView 更多的可能性。不过限于 iOS 13 的版本限制,我们还需要一段时间才能真正用上它,不过我已经等不及了。
官方的Demo,几乎展示了Compositional Layout 的所有布局,支持 iOS 和 macOS。强烈推荐大家跟着代码和结果走一遍!
Using Collection View Compositional Layouts and Diffable Data Sources