iOS13 Compositional Layout

前言

UITableView 和 UICollectionView 是我們開發者最常用的控件了,大量的流式布局需要這兩個控件來實現,因此這兩個控件也是 Apple 重點優化的對象。在往屆 WWDC 中,我們已經受益于 UITableViewDataSourcePrefetching 、優化版 Autolayout 等帶來的性能提升,以及 UITableViewDragDelegate 帶來的原生拖拽功能。今年,Apple 帶來了全新的 Compositional Layout 。它將徹底顛覆 UICollectionView 的布局體驗,大大拓展 UICollectionView 的可塑性。

背景

早期的 App 設計相對簡單,使用 UICollectionViewFlowLayout 可以應付大多數使用場景。而隨著應用的發展,越來越多的頁面趨于復雜化,UICollectionViewFlowLayout 在面對復雜布局往往會顯得力不從心,或者非常復雜,需要進行大量的計算和判斷。而自由度更高的 UICollectionViewLayout 則有著更高的接入門檻,稍有不慎還容易出現各種各樣的 bug 。

image

我們就拿 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 所有元素渲染出來的總高度,即 Group 的高度。
  4. 最終,我們會通過 Frame 或 AutoLayout對 UICollectionView 進行尺寸設置。

通過上面的解析,你能夠在腦中勾畫出這個 UICollectionView 長什么樣子嗎?好吧,其實我也不能,但好在我能夠跑一下代碼看下實際但結果。

image

結果就是一個類似 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))
}

image

如圖,使用簡單的描述,我們就可以得到以父視圖(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 添加自定義錨。

image

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

image

NSCollectionLayoutDecorationItem

有沒有遇到過這樣的UI需求?

image

以往要實現這樣的樣式往往會非常復雜,而如今我們終于可以自定義 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 我們可以自定義出層級更加復雜的布局。

image

這個 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 已經為你全部實現!

image

而如果我希望做一個類似 App Store 中部這樣滾動的布局呢?

image

這會稍稍有些復雜。首先,如果你仔細閱讀文檔,你會發現 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 的滾動功能,那么會是什么樣子的?

image

每個 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 。


image

理解了這其中的層級關系和特性,能夠幫助你寫出更靈活、性能更好的 UI !

總結

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

image

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

Using Collection View Compositional Layouts and Diffable Data Sources

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容