iOS13 Compositional Layout

8,871

前言

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 布局。它的目标有三个:

  1. Composable 可组合的
  2. Flexible 灵活的
  3. 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)

可以看到,为了能够将复杂的布局描述清楚,我们需要创建多个类来分别描述 ItemGroupSection 的大小、间距等属性。

如何解读上面这段代码?

  1. 首先 Item 的高度为44定高,宽度是父视图(Group)宽度的 100% 。
  2. Group 的尺寸描述使用了和 Item 完全相同的的 size ,即高度为44定高,宽度是父视图(Section)宽度的 100% 。
  3. Section 的宽度是 UICollectionView的宽度,高度默认为其 Group 所有元素渲染出来的总高度。
  4. 最终,我们会通过 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 中发挥作用的。

  1. NSCollectionLayoutSize

    class NSCollectionLayoutSize {
        init(widthDimension: NSCollectionLayoutDimension,
    }
    

    单纯用于描述 Item 的大小,使用到了上面介绍的 NSCollectionLayoutDimension。

  2. NSCollectionLayoutItem

    class NSCollectionLayoutItem {
        convenience init(layoutSize: NSCollectionLayoutSize)
        var contentInsets: NSDirectionalEdgeInsets
    }
    

    用于描述一个 Item 的完整布局信息,包含了上面的尺寸 NSCollectionLayoutSize ,以及边距 NSDirectionalEdgeInsets。

  3. 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

  4. 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 不支持多种大小的 ItemItem + 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 ,需要一个额外的层级来描述位于 SectionItem 的中间层。这样说可能会略显生涩,大家可以把现在的 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上的 App Store 已经用上了新的 Compositional Layout ,不过在 iPad 上旋转动画的性能不是很好,可见目前版本的 Compositional Layout 还有待优化的控件。不过限于 iOS 13 的版本限制,我们还需要一段时间才能真正用上它,但我已经等不及了。

官方的Demo,几乎展示了Compositional Layout 的所有布局,支持 iOS 和 macOS。强烈推荐大家跟着代码和结果走一遍!

Using Collection View Compositional Layouts and Diffable Data Sources