iOS13 Compositional Layout

前言

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 。

image

我們就拿 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è):

  1. Composable 可組合的
  2. Flexible 靈活的
  3. 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)分別描述 ItemGroup 、 Section 的大小、間距等屬性。

如何解讀上面這段代碼?

  1. 首先 Item 的高度為44定高,寬度是父視圖(Group)寬度的 100% 。
  2. Group 的尺寸描述使用了和 Item 完全相同的的 size ,即高度為44定高,寬度是父視圖(Section)寬度的 100% 。
  3. Section 的寬度是 UICollectionView的寬度,高度默認(rèn)為其 Group 所有元素渲染出來(lái)的總高度,即 Group 的高度。
  4. 最終,我們會(huì)通過(guò) Frame 或 AutoLayout對(duì) UICollectionView 進(jìn)行尺寸設(shè)置。

通過(guò)上面的解析,你能夠在腦中勾畫(huà)出這個(gè) UICollectionView 長(zhǎng)什么樣子嗎?好吧,其實(shí)我也不能,但好在我能夠跑一下代碼看下實(shí)際但結(jié)果。

image

結(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))
}

image

如圖,使用簡(jiǎn)單的描述,我們就可以得到以父視圖(Item 的父視圖為 Group)為基準(zhǔn)的比例尺寸。它不僅被用于描述 Item 的大小,同樣也用于 Group。

了解完這個(gè)基礎(chǔ)之后,讓我們看看 NSCollectionLayoutDimension 是如何在 Compositional Layout 中發(fā)揮作用的。

  1. NSCollectionLayoutSize

    class NSCollectionLayoutSize {
        init(widthDimension: NSCollectionLayoutDimension,
    }
    

    單純用于描述 Item 的大小,使用到了上面介紹的 NSCollectionLayoutDimension。

  2. NSCollectionLayoutItem

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

    用于描述一個(gè) 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 布局。它也提供了垂直 / 水平兩種方向。同時(shí)你也可以實(shí)現(xiàn) NSCollectionLayoutGroupCustomItemProvider 自定義 Group 的布局方式。

    它同樣接收一個(gè) NSCollectionLayoutDimension ,用于確定 Group 的大小。需要注意的是,當(dāng) Item 使用了 fractionalWidth / fractionalHeight 時(shí), Group 的大小會(huì)影響 Item 的大小。

    此外,它還有一個(gè) subitems 參數(shù),類型為 NSCollectionLayoutItem 數(shù)組,用于傳遞 Item 。

  4. 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 添加自定義錨。

image

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 樣式。

image

NSCollectionLayoutDecorationItem

有沒(méi)有遇到過(guò)這樣的UI需求?

image

以往要實(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ù)雜的布局。

image

這個(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)!

image

而如果我希望做一個(gè)類似 App Store 中部這樣滾動(dòng)的布局呢?

image

這會(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 不支持多種大小的 ItemItem + 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ì)是什么樣子的?

image

每個(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)描述位于 SectionItem 的中間層。這樣說(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 。


image

理解了這其中的層級(jí)關(guān)系和特性,能夠幫助你寫(xiě)出更靈活、性能更好的 UI !

總結(jié)

Compositional Layout 為我們帶來(lái)了更加可塑易用的 CollectionView 布局以及多維度瀑布流,對(duì)于 UICollectionView 而言是一個(gè)全新的升級(jí),它將賦予 UICollectionView 更多的可能性。不過(guò)限于 iOS 13 的版本限制,我們還需要一段時(shí)間才能真正用上它,不過(guò)我已經(jīng)等不及了。

image

官方的Demo,幾乎展示了Compositional Layout 的所有布局,支持 iOS 和 macOS。強(qiáng)烈推薦大家跟著代碼和結(jié)果走一遍!

Using Collection View Compositional Layouts and Diffable Data Sources

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容