前言
UITableView 和 UICollectionView 是我們開(kāi)發(fā)者最常用的控件了,大量的流式布局需要這兩個(gè)控件來(lái)實(shí)現(xiàn),因此這兩個(gè)控件也是 Apple 重點(diǎn)優(yōu)化的對(duì)象。在往屆 WWDC 中,我們已經(jīng)受益于 UITableViewDataSourcePrefetching 、優(yōu)化版 Autolayout 等帶來(lái)的性能提升,以及 UITableViewDragDelegate 帶來(lái)的原生拖拽功能。今年,Apple 帶來(lái)了全新的 Compositional Layout 。它將徹底顛覆 UICollectionView 的布局體驗(yàn),大大拓展 UICollectionView 的可塑性。
背景
早期的 App 設(shè)計(jì)相對(duì)簡(jiǎn)單,使用 UICollectionViewFlowLayout 可以應(yīng)付大多數(shù)使用場(chǎng)景。而隨著應(yīng)用的發(fā)展,越來(lái)越多的頁(yè)面趨于復(fù)雜化,UICollectionViewFlowLayout 在面對(duì)復(fù)雜布局往往會(huì)顯得力不從心,或者非常復(fù)雜,需要進(jìn)行大量的計(jì)算和判斷。而自由度更高的 UICollectionViewLayout 則有著更高的接入門(mén)檻,稍有不慎還容易出現(xiàn)各種各樣的 bug 。
我們就拿 App Store為例,它包含了大小不一的
Item ,以及可以上下、左右滑動(dòng)的交互。假如你是開(kāi)發(fā)者,你會(huì)如何搭建這個(gè) UI ?你可能會(huì)使用多個(gè) UICollectionView 嵌套在一個(gè) UIScrollerView 中,因?yàn)?UICollectionView 的滾動(dòng)軸只能有一個(gè)(橫向 / 豎向)。但如果我告訴你,在新版 iOS 13 中,這個(gè)頁(yè)面只使用了一個(gè) UICollectionView ,你會(huì)有什么感覺(jué)。你一定很好奇它是怎么做到的。其中的秘密就是 Compositional Layout 。
介紹
Compositional Layout 是此次隨 iOS 13 一同發(fā)布的全新 UICollectionView 布局。它的目標(biāo)有三個(gè):
- Composable 可組合的
- Flexible 靈活的
- Fast 快
為了達(dá)到上面這三個(gè)目標(biāo),Compositional Layout 在原有 UICollectionViewLayout Item Section 的基礎(chǔ)上,增加了一層 Group 的概念。多個(gè) Item 組成一個(gè) Group ,多個(gè) Group 組成一個(gè) Section 。
說(shuō)了這么多,還不如上代碼
// 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)
可以看到,為了能夠?qū)?fù)雜的布局描述清楚,我們需要?jiǎng)?chuàng)建多個(gè)類來(lái)分別描述 Item 、 Group 、 Section 的大小、間距等屬性。
如何解讀上面這段代碼?
- 首先
Item的高度為44定高,寬度是父視圖(Group)寬度的 100% 。 -
Group的尺寸描述使用了和Item完全相同的的 size ,即高度為44定高,寬度是父視圖(Section)寬度的 100% 。 -
Section的寬度是 UICollectionView的寬度,高度默認(rèn)為其Group所有元素渲染出來(lái)的總高度,即Group的高度。 - 最終,我們會(huì)通過(guò) Frame 或 AutoLayout對(duì) UICollectionView 進(jìn)行尺寸設(shè)置。
通過(guò)上面的解析,你能夠在腦中勾畫(huà)出這個(gè) UICollectionView 長(zhǎng)什么樣子嗎?好吧,其實(shí)我也不能,但好在我能夠跑一下代碼看下實(shí)際但結(jié)果。
結(jié)果就是一個(gè)類似 UITableView 的布局。
好吧,我承認(rèn)這有點(diǎn)難。因?yàn)槲覀兛创a的順序都是從上而下,但假如 Compositional Layout 層級(jí)的尺寸依賴于父視圖,我們就不得不結(jié)合父視圖和自身的布局來(lái)推倒出最終的布局,這需要一定的空間想象力。
在上面這個(gè)例子中,每一個(gè) “UITableViewCell” 就是一個(gè) Item ,也是一個(gè) Group ,而整個(gè) “UITableViewCell” 只包含了一個(gè) Section 。
所以看到這里你一定會(huì)好奇,我們?yōu)槭裁葱枰?Group 這么一個(gè)東西?很抱歉我需要將這個(gè)疑問(wèn)留到最后。
核心布局
我們先來(lái)談?wù)勛罨A(chǔ)的核心布局。
在詳細(xì)介紹 Compositional Layout 中用到的四大類之前,我們需要先來(lái)了解一下,一個(gè)新的用于描述尺寸大小的類。
NSCollectionLayoutDimension
過(guò)去,我們可以使用 CGSize 來(lái)描述一個(gè)固定大小的 Item 。后來(lái),我們擁有了 estimatedItemSize 來(lái)描述一個(gè)動(dòng)態(tài)計(jì)算大小的 Item ,并且給它一個(gè)預(yù)估的值。但更多的時(shí)候,為了適配不同的屏幕尺寸,我們需要根據(jù)屏幕的寬度手動(dòng)計(jì)算出 Item 的大?。ū热缦薅ㄒ恍兄伙@示3個(gè) Item )。
如何用簡(jiǎn)潔優(yōu)雅的方式去描述上面三種場(chǎng)景呢?答案是 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 添加了根據(jù)父視圖的比例來(lái)描述尺寸的 fractionalWidth / fractionalHeight 的方法,并將定值、自適應(yīng)、比例這三大描述方式統(tǒng)一分裝了起來(lái)。
我們來(lái)看一個(gè)例子。
let size = NSCollectionLayoutDimension(widthDimension: .fractionalWidth(0.25),
heightDimension: .fractionalWidth(0.25))
}
如圖,使用簡(jiǎn)單的描述,我們就可以得到以父視圖(
Item 的父視圖為 Group)為基準(zhǔn)的比例尺寸。它不僅被用于描述 Item 的大小,同樣也用于 Group。
了解完這個(gè)基礎(chǔ)之后,讓我們看看 NSCollectionLayoutDimension 是如何在 Compositional Layout 中發(fā)揮作用的。
-
NSCollectionLayoutSize
class NSCollectionLayoutSize { init(widthDimension: NSCollectionLayoutDimension, }單純用于描述
Item的大小,使用到了上面介紹的 NSCollectionLayoutDimension。 -
NSCollectionLayoutItem
class NSCollectionLayoutItem { convenience init(layoutSize: NSCollectionLayoutSize) var contentInsets: NSDirectionalEdgeInsets }用于描述一個(gè)
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布局。它也提供了垂直 / 水平兩種方向。同時(shí)你也可以實(shí)現(xiàn) NSCollectionLayoutGroupCustomItemProvider 自定義Group的布局方式。它同樣接收一個(gè) NSCollectionLayoutDimension ,用于確定
Group的大小。需要注意的是,當(dāng)Item使用了 fractionalWidth / fractionalHeight 時(shí),Group的大小會(huì)影響Item的大小。此外,它還有一個(gè) subitems 參數(shù),類型為 NSCollectionLayoutItem 數(shù)組,用于傳遞
Item。 -
NSCollectionLayoutSection
class NSCollectionLayoutSection { convenience init(layoutGroup: NSCollectionLayoutGroup) var contentInsets: NSDirectionalEdgeInsets }用于描述
Section布局信息。同樣可以通過(guò)修改 contentInsets 來(lái)改變Section的邊距。
以上就是用于描述 Compositional Layout 用到的四個(gè)類。通過(guò)對(duì)布局的精確描述,我們就能夠得到可塑性非常強(qiáng)的 UICollectionView布局,而無(wú)需重寫(xiě)復(fù)雜的 UICollectionViewLayout 。不過(guò),Compositional Layout 的可玩性還不止于此,如果想要進(jìn)一步的自定義,需要使用到一些額外的高級(jí)布局技巧。
高級(jí)布局
NSCollectionLayoutAnchor
對(duì)于 Item 而言,我們可能會(huì)有類似 iOS 桌面小圓點(diǎn)的需求。通過(guò) 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])
同樣是通過(guò)多個(gè)類來(lái)分別描述 Anchor 的方位、大小和視圖,我們就可以非常方便地為 Item 添加自定義錨。
NSCollectionLayoutBoundarySupplementaryItem
Headers 和 Footers 是也我們經(jīng)常用到的組件,這次 Compositional Layout 弱化了 Header 和 Footer 的概念,他們都是 NSCollectionLayoutBoundarySupplementaryItem ,只不過(guò)你可以通過(guò)描述其相對(duì)于 Section 的位置(top / bottom)來(lái)達(dá)到過(guò)去 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 屬性則是用來(lái)描述 NSCollectionLayoutBoundarySupplementaryItem 劃出屏幕后是否留在 CollectionView 的最上端,也就是之前 Plain style 的 Header 樣式。
NSCollectionLayoutDecorationItem
有沒(méi)有遇到過(guò)這樣的UI需求?
以往要實(shí)現(xiàn)這樣的樣式往往會(huì)非常復(fù)雜,而如今我們終于可以自定義 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")
通過(guò)NSCollectionLayoutDecorationItem ,我們可以為 Section 的背景添加自定義視圖,其加載方式和 Item Header Footer 一樣通過(guò),需要先 register 。
Estimated Self-Sizing
在添加了如此多自定義特性之后,Compositional Layout 依舊支持自適應(yīng)尺寸。這極大方便了我們對(duì)動(dòng)態(tài)內(nèi)容的展示,同時(shí)對(duì) Dynamic text 這類系統(tǒng)特性也能有更好的支持。
// 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
不知道你有沒(méi)有發(fā)現(xiàn),NSCollectionLayoutGroup 初始化方法中的 subitems 參數(shù)類型為 NSCollectionLayoutItem 數(shù)組,而 NSCollectionLayoutGroup 同樣繼承自 NSCollectionLayoutItem ,也就是說(shuō),NSCollectionLayoutGroup 內(nèi)可以嵌套 NSCollectionLayoutGroup 。這樣作的目的是,通過(guò)嵌套 Group 我們可以自定義出層級(jí)更加復(fù)雜的布局。
這個(gè) 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])
想一想如此復(fù)雜的布局如果自己去實(shí)現(xiàn) UICollectionViewLayout 將會(huì)是多么復(fù)雜,如今通過(guò)簡(jiǎn)潔而抽象的 Compositional Layout API 我們可以非常直觀的描述這一布局。
Orthogonal Scrolling Sections
這個(gè)特性就是我們前面提到的,讓 Section 可以滾動(dòng)起來(lái)的特性。
// Orthogonal Scrolling Sections
section.orthogonalScrollingBehavior = .continuous
通過(guò)設(shè)置 Section 的 orthogonalScrollingBehavior 參數(shù),我們可以實(shí)現(xiàn)多種不同的滾動(dòng)方式。
// Orthogonal Scrolling Sections
enum UICollectionLayoutSectionOrthogonalScrollingBehavior: Int {
case none
case continuous
case continuousGroupLeadingBoundary
case paging
case groupPaging
case groupPagingCentered
}
orthogonalScrollingBehavior 參數(shù)是一個(gè) UICollectionLayoutSectionOrthogonalScrollingBehavior 類型的枚舉,包含了我們?cè)趯?shí)際開(kāi)發(fā)者會(huì)用到的幾乎所有滾動(dòng)方式,比如常見(jiàn)的自由滾動(dòng),按page滾動(dòng),以及按 Group 滾動(dòng)(包含以 Group Leading 為邊界和以 Group Center 為邊界)。以往要實(shí)現(xiàn)類似的效果,我們大多需要自己實(shí)現(xiàn) UICollectionViewLayout 或者干脆求助類似 AnimatedCollectionViewLayout 這樣的第三方庫(kù),如今 Apple 已經(jīng)為你全部實(shí)現(xiàn)!
而如果我希望做一個(gè)類似 App Store 中部這樣滾動(dòng)的布局呢?
這會(huì)稍稍有些復(fù)雜。首先,如果你仔細(xì)閱讀文檔,你會(huì)發(fā)現(xiàn) NSCollectionLayoutGroup 有一個(gè)我們之前沒(méi)有提到的 API 。
open class func vertical(layoutSize: NSCollectionLayoutSize, subitem: NSCollectionLayoutItem, count: Int) -> Self
它相比默認(rèn)的 API ,subitem 不再接收數(shù)組而只接收單一的 Item (意味著這個(gè)模式下,Group 不支持多種大小的 Item 或 Item + Group 的組合,但聰明的你一定想到了可以先構(gòu)建一個(gè)組合的 Group 然后傳進(jìn)這個(gè) API 中),同時(shí)多了一個(gè) count。這個(gè) count 會(huì)讓 Group 嘗試在其限定的大小內(nèi)塞入 count 個(gè)數(shù)的 Item 。最終達(dá)到的效果就是類似
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item, item, item])
不過(guò)上面的代碼不會(huì)生效,因?yàn)?subitems 關(guān)注的是不同的 Item 的組合,而非實(shí)際 Item 的個(gè)數(shù),因此 subitems 會(huì)對(duì)數(shù)組內(nèi)的 Item 去重。因此如果你希望在一個(gè) Group 中塞入多個(gè) Item,后者是你唯一的選擇。
看到這里你是否對(duì) Group 的作用有了一點(diǎn)感覺(jué)?上面的例子中,如果我們關(guān)閉 Section 的滾動(dòng)功能,那么會(huì)是什么樣子的?
每個(gè)
Group 中還是會(huì)有 3 個(gè) Item,只不過(guò)由于 Section 的寬度限制,下一個(gè) Group 不得不排布到上一個(gè) Group 的下放,結(jié)果展示出來(lái)的還是一個(gè)類似 TableView 的布局。當(dāng)我們打開(kāi) Section 的滾動(dòng)模式,奇跡發(fā)生了。由于 Section 可以滾動(dòng),因此它存在類似于 ScrollerView 的 ContentView ,它的子 View 可以在更大的范圍內(nèi)渲染,因此之后的 Group 可以跟隨在之前的 Group 右側(cè),并最終填充 Section 的整個(gè) ContentView。
現(xiàn)在你該知道 Apple 為什么要引入 Group 的概念了吧。其實(shí)我在看 Advances in Collection View Layout 的時(shí)候也是悶的,直到最后看到了 App Store 的例子我才明白了,為了能夠?qū)崿F(xiàn)多緯度的滾動(dòng)(實(shí)際上是賦予了 Section 滾動(dòng)的特性),原有的層級(jí)就不足以描述一個(gè)完整的多維度 CollectionView ,需要一個(gè)額外的層級(jí)來(lái)描述位于 Section 和 Item 的中間層。這樣說(shuō)可能會(huì)略顯生澀,大家可以把現(xiàn)在的 Section 想象成原來(lái)的 CollectionView ,而新的 Group 就是原來(lái)的 Section。由于現(xiàn)在 Section 充當(dāng)了之前 CollectionView 的角色被賦予了滾動(dòng)的特性,因此需要一個(gè)額外的層級(jí)來(lái)描述之前 Section 所描述的 “一組 Item 的” 關(guān)系 。 Group 便由此出現(xiàn)。
可以說(shuō) Group 的存在是完全服務(wù)于這個(gè)可滾動(dòng) Section 的??蓾L動(dòng)的 Section 為 CollectionView 增加了一個(gè)緯度的信息流,如果你的 CollectionView 沒(méi)有多維滾動(dòng)的需求,那么你會(huì)發(fā)現(xiàn)使用 Compositional Layout 的 Group 是一個(gè)完全沒(méi)有必要的事情。
復(fù)習(xí)
正如我前面所說(shuō),Compositional Layout 的層級(jí)關(guān)系依次是 Item > Group > Section > Layout 。
理解了這其中的層級(jí)關(guān)系和特性,能夠幫助你寫(xiě)出更靈活、性能更好的 UI !
總結(jié)
Compositional Layout 為我們帶來(lái)了更加可塑易用的 CollectionView 布局以及多維度瀑布流,對(duì)于 UICollectionView 而言是一個(gè)全新的升級(jí),它將賦予 UICollectionView 更多的可能性。不過(guò)限于 iOS 13 的版本限制,我們還需要一段時(shí)間才能真正用上它,不過(guò)我已經(jīng)等不及了。
官方的Demo,幾乎展示了Compositional Layout 的所有布局,支持 iOS 和 macOS。強(qiáng)烈推薦大家跟著代碼和結(jié)果走一遍!
Using Collection View Compositional Layouts and Diffable Data Sources