iOS App 程式開發

Compositional Layout 詳解 讓你簡單操作 CollectionView!

隨著手機 App 介面和硬體不斷進化,App 介面的已經越來越複雜。此文會簡單地介紹 Compositional Layout ,讓操作 CollectionView 變得更加容易,一步一步帶你建構屬於自己的 CollectionView。
Compositional Layout 詳解 讓你簡單操作 CollectionView!
Compositional Layout 詳解 讓你簡單操作 CollectionView!
In: iOS App 程式開發, Swift 程式語言, SwiftUI 框架, Xcode

隨著手機 App 介面和硬體的不斷進化,現在 App 介面的複雜度已經跟以往不能同日而語了。以前的 UI 可能就是一個簡單的 TabelView,把所有資訊都一視同仁地列出來,上面也不太會有甚麼複雜的互動,就程式的撰寫上,一個或兩個 UICollectionView 就有辦法滿足大部份的需求。還記得很久以前的 AppStore 嗎?它就是一個簡單的列表,沒有任何花俏的功能。

2008-appstore

2008 年的 AppStore(圖片來源:Apple Newsroom

到了今天,因為在手機上瀏覽內容的需求大增,YouTube 或 Neftflix 等服務都開始著重在手機原生的瀏覽體驗。而列表的設計,也從簡單的垂直滾動、只有一個 Cell 的列表等等,到現在的能夠水平垂直滾動、具有多種 Cell 的列表,複雜度也隨之上升不少。UICollectionViewLayout 提供了非常具有彈性的方法,讓 CollectionView 隨著各種需求改變 layout,不過問題是它的使用非常麻煩,你需要自己計算各個 Cell 的大小和位置,也要自己考慮效能的問題,開發上非常綁手綁腳。

不過,痛苦的時代已經過去了!在 WWDC 2019 上所推出的 Compositional Layout,就是為了要解決這樣的問題。Compositional Layout 把計算 Cell 大小和位置的邏輯都包裝起來,轉化成簡單的設定物件操作,同時還是維持非常高的彈性,並且效能也非常地好。不幸的是,如果你準備開始來學這個精美的 Compositional Layout,開心地點開 Apple Document 裡面的文件,你會發現它們都長這樣:

apple-document

完全沒有畫面!Compositional Layout 相關的所有文件,在這個時間點(2019 年 10 月)都還是空的 😱。所幸,Compositional Layout 並不複雜,從 WWDC 提供的影片跟範例程式,加上 SDK 標頭檔的註解,我們還是可以了解到幾乎所有 Compositional Layout 的使用方法。接下來,我們就會在這篇文章中,簡單地從觀念開始,一步一步地帶你了解怎麼使用 Compositional Layout,來建構你的 CollectionView!

大綱

在這篇文章中,你將會學習到:

  • Compositional Layout 的基本概念
  • Compositional Layout 的各種 layout 元件
  • 各種 UI 套路
  • 與資料整合後的設計方法

在開始前,建議先準備好一張已經設定好 data source 的 CollectionView,這樣你就可以跟著底下的說明,一個一個抽換 layout 並且看到它們的效果。

這麼快就準備好了!?那我們就開始吧!(沒要等人的意思)

Compositional Layout 的基本概念

讓我們先來回想一下,基本的 CollectionView 的 layout 大概是長這樣:

CollectionView-layout

在每一個 section 裡面有數個 item,如果我們想要設定這些 item 的 layout,我們可以直接使用 Apple 提供的 UICollectionViewFlowLayout,做簡單的垂直 layout 或是水平 layout;又或者可以在 CollectionView 裡一個類別為 UICollectionViewLayout 的 property 裡,計算每一個 item 的位置,然後回傳給 CollectionView,讓它去根據我們的計算結果來 layout。

用上面的工具,簡單的 layout 當然難不倒我們這些每天跟神奇需求奮戰的攻城獅!但是如果今天,我們收到一個需求,希望要做一個像 AppStore 的編輯推薦一樣三個 item 疊在一起,並且可以橫向滾動的介面,像下面這樣:

applestore-layout

請問單兵該如何處置?直接 subclass UICollectionViewLayout,把每一個 item 的位置跟大小都算出來,並且最佳化 layout 計算的效能,絕對是工程師的浪漫!但是現實是,當你完成了這個驚世巨作的時候,時程也已經浪漫地延後了一個月了。

Compositional Layout 想要解決的問題,就是讓設定 layout 可以像堆積木一樣,拿現成的東西組一組,就能組出各種不一樣的變化,而不用每次都針對一個設計寫一份新的 layout 程式。換句話說,Compositional Layout 跟原本的 UICollectionViewLayout 最大的不同,就是它是用許許多多的物件,來描述一個 layout 的樣子。在 subclass UICollectionViewLayout 的時候,我們就是從零開始,把所有 item 的大小位置都算好告訴系統;而相對地,Compositional Layout 就像是,我們手邊有幾個代表 layout 方式的物件,像是這個區塊有三個item或是這個 item 的大小要跟容器一樣,拿我們需要的,組合這些物件去請系統依照這些物件來設定 layout,細節的計算就不是我們需要擔心的。

讓我們直接來看看 Compositional Layout 的架構:

Compositional-Layout-1

可以看到,在 section 裡面,原本是直接擺放 item,現在多了一個 group,其它的部份則是跟原本的架構差不多,不過就像小卷跟透抽一樣,雖然長得很像,但是兩個其實是完全不一樣的東西,千萬不要認錯!(小卷跟透抽可以認錯沒關係 (誤))

在設定 Composition Layout 的時候,我們的流程會像這樣:先創造出屬於 item 的 layout 物件,並且設定好大小、邊界距離等等,每一種長相不一樣的 item,就會有一個專屬的元件來描述它。設定好 item 之後,再創造一個 group layout 物件,把剛剛產生的 item 物件都丟到這個 group 裡,並且設定好 group 本身的大小、邊界距離等等,再丟到屬於 section 的 layout 物件裡,以此類推,到最後,一個 CollectionView 的長相,就會由一個最大的 layout 物件來描述,它裡面包含了不同的 sectionsection 裡又包含了 group,而 group 裡包含了各種 item。所以上面的這張圖,同時也是這些物件的階層圖,你可以把這些方塊,都當成是程式裡一個一個的物件。

這張架構圖在組合 layout 的時候非常重要,我們接下來會一個一個解釋怎麼樣設定每一個階層的元件,還有怎麼把這些東西組合起來。順帶一題,group 這個元件現在看起來可能跟我們的市長一樣沒有甚麼用處,不過它可以說是整個 Compositional Layout 的核心之一,我們會在實作的章節中,展現它的威力,一定要用力看下去!

Compositional Layout 的各種基本元件

在這個章節,我們會來介紹龐大的 Compositional Layout 的各種元件,也就是描述 layout 的那些積木。為了讓全貌更加清楚,現在就讓我們直接從動手做開始吧!

Compositional-Layout-2

現在我們想要做一個像上圖一樣簡單的橫向滾動的 CollectionView,這樣的 layout 要怎樣利用 compositional layout 來表現呢?二話不說來看一下 code:

備註:往後畫面裡 Cell 裡的 (x, y) 字樣,代表的是這個 cell 在 dataSource 中,是屬於第 x section 的第 y 個 item。

// ViewController.swift

// 初始化 compositional layout
lazy var CollectionViewLayout: UICollectionViewLayout = {
    // 4
    let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.2),
                                          heightDimension: .absolute(120))
    let item = NSCollectionLayoutItem(layoutSize: itemSize)

    // 3
    let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
                                           heightDimension: .absolute(120))
    let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
                                                   subitems: [item])
    group.interItemSpacing = .fixed(8)

    // 2
    let section = NSCollectionLayoutSection(group: group)
    section.orthogonalScrollingBehavior = .continuous
    // 增加 section 邊緣的空間
    section.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5)

    // 1
    let layout = UICollectionViewCompositionalLayout(section: section)
    return layout
}()

func viewDidLoad() {
    super.viewDidLoad()
    // 指定 layout 的方法為 UICollectionViewCompositionalLayout
    self.CollectionView = UICollectionView(frame: view.bounds, CollectionViewLayout: CollectionViewLayout)
}

我們先從 viewDidLoad 開始看起。首先,最重要的就是要告訴我們的 CollectionView 使用 compositional layout,來設定 CollectionView 的 layout。在開始解釋 layout 設定之前,我們先來回顧一下之前提到的 compositional layout 架構圖:

Compositional-Layout-1

最外層的是一個 layout 物件,在 compositional layout 裡,是一個類別為 UICollectionViewCompositionalLayout 的物件。

再回到我們的 code,從最底下開始,// 1 的部份,就是初始化這個最外層的 layout 物件,並且把它整包回傳給 CollectionView 使用。

往上一點看到 // 2 這個部份,你可以看到我們設定了一個 NSCollectionLayoutSection,對應圖上的 section,這個 section 是被包含在 UICollectionViewCompositionalLayout 裡面的。

接著,回想一下我們的需求,我們需要讓這個 CollectionView 可以被橫向滾動,所以我們需要在 section 物件裡,加上可以橫向滾動的描述,也就是這一行:

section.orthogonalScrollingBehavior = .continuous

這個 orthogonalScrollingBehavior 的出現,可以說解救了無數的 iOS/macOS 工程師,再也不用在 cell 裡面放一個 CollectionView、或是轉 90 度的 TableView 了 🍻!這個 orthogonalScrollingBehavior 一旦被設定, CollectionView 的 section 實作會變成一個特別的橫向 scroll view,對於使用這個參數的我們,完全不用去考慮這個多出來的 scroll view,傳遞資料給 cell 還是可以像以前一樣,在 dataSource 裡面直接傳遞就好,可以說是非常方便!

雖然我們一直說「橫向滾動」,但其實 orthogonalScrollingBehavior 代表的意義,是我要讓這個 section 可以與 CollectionView 滾動的 90 度方向滾動。一般 CollectionView 如果是垂直滾動的話,這個參數的意義就是 section 可以橫向滾動。如果 CollectionView 是橫向滾動的話,這個參數的意義就變成讓 section 可以垂直滾動。它還可以有許多不同的設定值如下:

  • none:顧名思義,就是不會有垂直向的滾動(預設值)
  • continuous:連續的滾動
  • continuousGroupLeadingBoundary:連續的滾動,但會最後停在 group 的前緣
  • paging:每次會滾動跟 CollectionView 一樣寬(或一樣高)的距離
  • groupPaging:每次會滾動一個 group
  • groupPagingCentered:每次會滾動一個 group,並且停在 group 置中的地方

接著我們來看一下 // 3 ,也就是設定 group 的 code。這邊我們會先看到設定 group 大小的 code:

// 3
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
                                           heightDimension: .absolute(120))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
                                                   subitems: [item])

NSCollectionLayoutSize 這個類別非常地重要,它是描述所有 groupitem 的元件。上面這段 code 的意思,就是「設定寬度跟 section 一樣 (1x),高度是絕對值 120」。widthDimensionheightDimension 是類別為 NSCollectionLayoutDimension 的兩個參數。NSCollectionLayoutDimension 就是用來描述寬度或高度的物件,它有以下幾種 builder method,可以用來產生描述寬度跟高度的物件:

  • fractionalWidthfractionalHeight:寬度或高度是容器的某個比例,比方 1(一樣)或 0.5(一半)
  • absolute:指定一個絕對值
  • estimated:設定這個 itemitem 為 self-sizing,並且給定一個預測值,就像我們原本在設定 self-sizing 時一樣

而 groupSize 的下面一行,NSCollectionLayoutGroup.horizontal(layoutSize: subitems) 則是產生一個描述 group 的物件,指定它的大小,並且指定這個 group 裡面有那些種類的 item,這裡的 subitems 會放入描述 item 長相的物件,我們待會會再介紹這個物件。

回到我們的 group,上面 // 3 部份的 code,可以描述出下面這樣大小的 group 出來:

Compositional-Layout-3

最後讓我們來看一下 // 4 這個部份的 code:

// 4 
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.2),
                                          heightDimension: .absolute(120))
let item = NSCollectionLayoutItem(layoutSize: itemSize)

// ...

let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
                                                   subitems: [item])

// ...

let section = NSCollectionLayoutSection(group: group)

itemSize 跟剛剛的 groupSize,是一樣的 NSCollectionLayoutSize,所以聰明的你,應該可以猜到這個 widthDimension: .fractionalWidth(0.2), heightDimension: .absolute(120) 要描述的是怎樣的大小了吧?猜不到嗎?(都自己在講)

最後綜合上面所有的 code,所描述的 CollectionView 就會長得像下面這張圖(沒有要解釋的意思):

Compositional-Layout-4

雖然我們 item 的寬度設定為容器的 0.2 倍,但是因為 interItemSpacing 的關係,group 裡面放不下五個 item,所以最右邊會有一個多出來的空間。在這裡,你應該就可以了解到 group 的用處是甚麼了,我們來看看在 UI 上,這樣的設定會有怎樣的效果:

Compositional-Layout-5

group 的功用,就是把一個或多個 item 圈在一起,利用這個特性,我們就可以做出許許多多的變化。以往要在橫向滾動的 section 裡,加上垂直排列的三個 cell 是非常困難的一件事,但是現在我們只需要設定一個 vertical group,讓裡面包含三個 item,這樣就可以做出像 AppStore 一樣的效果了!而回到我們最一開始的需求,我們希望有一個能夠橫向滾動的 section,並且 itemitem 之間都是等間距的,我們該怎麼做呢?

lazy var CollectionViewLayout: UICollectionViewLayout = {
    // 1
    let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
                                            heightDimension: .absolute(120))
    let item = NSCollectionLayoutItem(layoutSize: itemSize)

    // 2
    let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.2), heightDimension: .absolute(120))
    let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

    // 3
    group.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: nil, top: nil, trailing: .fixed(8), bottom: nil)

    let section = NSCollectionLayoutSection(group: group)
    section.orthogonalScrollingBehavior = .continuous
    section.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5)
    let layout = UICollectionViewCompositionalLayout(section: section)
    return layout
}()

你可以看到 // 1item 設定成跟 group 一樣寬度,// 2group 的寬度等於容器的 0.2 倍。也就是說,現在一個 group 裡面,只會放一個 item,並且這個 itemgroup 是一樣大小的。不過我們還是要設定一下 itemitem 的間距,現在改成要設定 groupgroup 之間的間距了,所以看到 // 3,我們利用 group.edgeSpacing 來設定 group 的邊界距離。這個 edgeSpacing 是一個型別為 NSCollectionLayoutEdgeSpacing 的 property,它的 initializer 長這樣: init(leading: NSCollectionLayoutSpacing?, top: NSCollectionLayoutSpacing?, trailing: NSCollectionLayoutSpacing?, bottom: NSCollectionLayoutSpacing?),利用這個物件,我們可以描述一個方型的上下左右的邊界距離,而 NSCollectionLayoutSpacing 則類似上面看到的 NSCollectionLayoutDimension,提供了兩個 class method 讓你描述距離:

  • fixed:指定固定的距離
  • flexible:指定一個可伸長的最小距離

記不起來也沒關係,晚點我們還會有更多關於 flexible 的應用!

最後依照上面的 layout 設定,我們就可以得到一個很正確的橫向滾動 CollectionView 囉!

Compositional-Layout-6

接下來,我們要來實作一下各種 Compositional Layout 的套路,看看它到底能夠為我們平常的工作帶來甚麼改變。

Compositional Layout 的套路

Item 垂直相疊的橫向滾動(AppStore)

appstore-ui

這是上面提到類似 AppStore 的 UI,但是每一個高度都稍微不太一樣,在看 code 之前,可以先想一下如何利用 group 跟多個 item 來達到這樣的效果。好,時間到,我們來看一下解答:

// 提供三種不同形狀的 item 
let layoutSize = NSCollectionLayoutSize(widthDimension: .absolute(110), heightDimension: .absolute(45))
let item = NSCollectionLayoutItem(layoutSize: layoutSize)
let layoutSize2 = NSCollectionLayoutSize(widthDimension: .absolute(110), heightDimension: .absolute(65))
let item2 = NSCollectionLayoutItem(layoutSize: layoutSize2)
let layoutSize3 = NSCollectionLayoutSize(widthDimension: .absolute(110), heightDimension: .absolute(85))
let item3 = NSCollectionLayoutItem(layoutSize: layoutSize3)

// 給剛好大小的 group
let groupLayoutSize = NSCollectionLayoutSize(widthDimension: .absolute(110), heightDimension: .absolute(205))

// 用 .vertical 指明我們的 group 是垂直排列的
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupLayoutSize, subitems: [item, item2, item3])

// 這裡指的是垂直的間距了
group.interItemSpacing = .fixed(5)

group.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: nil, top: nil, trailing: .fixed(10), bottom: nil)

let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
let layout = UICollectionViewCompositionalLayout(section: section)
return layout

在這個例子中,一個 group 裡面包含了三個 item,並且 item 是垂直排列的。不同於最一開始在 item 是水平排列的情況,這裡 group.interItemSpacing 從代表水平間距變成了垂直間距。這個行為的變化,也剛好帶出了一個重點,為甚麼 item 的間距可以只用一個 interItemSpacing 就能代表,但是 group 之間的間距卻需要用 NSCollectionLayoutEdgeSpacing 這樣有四個方向的參數才能代表呢?那是因為從 NSCollectionLayoutGroup 的 builder function,我們可以看出來 group 裡面的排列,只會有三種狀況:

  • .horizontal:水平排列
  • .vertical:垂直排列
  • .custom:完全客制

在前面兩種狀況下,interItemSpacing 指的不是水平間距就是垂直間距,所以不需要指定方向系統也能知道這個 spacing 代表的意義。而 .custom 這個 builder function 有點特別,其實就是我們熟悉的、計算一個一個 cell 的大小跟位置的方法,後面我們會提到它的使用方法。

Nested group(巢狀)

nested-group-1

我們現在要來介紹一個不太實用的功能(等等先別急者轉台),我們可以利用 Compositional Layout 做出像上面這樣,左邊是一個大 item ,右邊是兩個小 item 的巢狀排列。

在介紹巢狀排列的做法之前,我們要先來看一下一個非常有趣的特性,下面是 NSCollectionLayoutGroup 的類別定義:

open class NSCollectionLayoutGroup : NSCollectionLayoutItem, NSCopying {
    ....
}

有沒有發現,表面上看起來是個 group 的類別,其實它是 item NSCollectionLayoutItem 的一個子類別!也就是說,任何可以放 NSCollectionLayoutItem 的地方,都可以改成放 NSCollectionLayoutGroup 了!得益於這個設計,我們就能夠實現我們的巢狀 layout 夢想了!它的設計很簡單:

nested-group-2

我們用一個橫向的 group,裡面再擺一個縱向的 subGroup,用圖來看可能比較難理解,讓我們用工程師的方法來溝通:

let layoutSize = NSCollectionLayoutSize(widthDimension: .absolute(65), heightDimension: .absolute(120))
let item = NSCollectionLayoutItem(layoutSize: layoutSize)

let layoutSize2 = NSCollectionLayoutSize(widthDimension: .absolute(65), heightDimension: .absolute(45))
let item2 = NSCollectionLayoutItem(layoutSize: layoutSize2)
let layoutSize3 = NSCollectionLayoutSize(widthDimension: .absolute(65), heightDimension: .absolute(65))
let item3 = NSCollectionLayoutItem(layoutSize: layoutSize3)

// 右邊的子 group
let subGroupSize = NSCollectionLayoutSize(widthDimension: .absolute(65), heightDimension: .absolute(120))
let subGroup = NSCollectionLayoutGroup.vertical(layoutSize: subGroupSize, subitems: [item2, item3])
subGroup.interItemSpacing = .fixed(10)

// 包含一個左邊的 item 跟右邊的子 group 的大 group
let groupLayoutSize = NSCollectionLayoutSize(widthDimension: .absolute(135), heightDimension: .absolute(120))

// 同時在 group 裡面放 group 跟 item 兩種 layout 物件
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupLayoutSize, subitems: [item, subGroup])

group.interItemSpacing = .fixed(5)
group.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: nil, top: nil, trailing: .fixed(10), bottom: nil)

let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
let layout = UICollectionViewCompositionalLayout(section: section)
return layout

這個 group 同時也是 item 的特性,讓許許多多複雜的 layout,都能夠轉化成 groupitem 的組合。

Header 的實作 —— Boundary Supplementary Item

section-header-1

介紹完用不太到 (?) 的功能之後,我們要來介紹一個非常實用的功能:幫 section 加上 header!

大家都知道,在 UICollectioView 裡面,header 或 footer 都是一種 supplementary view,附加在 section 的旁邊。Compositional Layout 當然也有辦法設定這些 supplementary 的 layout,並且邏輯上跟設定其它的 layout 是一樣的,讓我們從設定 header 這個例子,來看看要怎麼樣在 compositional layout 裡面設定 supplementary 的長相:

// ...

let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

// ...

// 設定 header 的大小
let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(40))

// 負責描述 supplementary item 的物件
let headerItem = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top, absoluteOffset: CGPoint(x: 0, y: -5))

let section = NSCollectionLayoutSection(group: group)

// 指定 supplementary item 給 section 
section.boundarySupplementaryItems = [headerItem]

let layout = UICollectionViewCompositionalLayout(section: section)
return layout

首先你會先發現這裡有一個新的 layout 物件 NSCollectionLayoutBoundarySupplementaryItem,它的 initializer 長這樣:NSCollectionLayoutBoundarySupplementaryItem(layoutSize: NSCollectionLayoutSize, elementKind: String, alignment: NSRectAlignment, absoluteOffset: CGPoint),從這裡我們大概可以看出來,這個物件描述了 supplementary view 的大小 (layoutSize)、它的 identifier(elementKind,跟 dataSource 裡的 elementKind 指的是一樣的東西)、它相對於 section 的位置 (alignment)、以及它相對於 section 的距離 (absoluteOffset)。這些參數可以用下圖一言以敝之:

section-header-2

以此類推,如果你想要做 footer,只要把 alignment 設定成 .bottom 就可以了。同樣如果是橫向捲動的 section,header 的 alignment 可能就需要是 .leading,這些參數的可塑性非常高,你可以透過指定這些參數,自由做出你想要的(或者通常是被強迫要做的)UI 設計。

當然,你還是要記得在 UICollectionViewDataSource 裡面實作下面這個 method,根據收到的 viewForSupplementaryElementOfKind 來提供對應的 header view:

optional func CollectionView(_ CollectionView: UICollectionView, 
viewForSupplementaryElementOfKind kind: String, 
                          at indexPath: IndexPath) -> UICollectionReusableView

並且在 CollectionView 初始化時,也要記得註冊這個 supplementary view:

CollectionView.register(HeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "HeaderView")

小紅點 badge —— Supplementary Item

badge-1

如果你看上面 NSCollectionLayoutBoundarySupplementaryItem 的定義:

open class NSCollectionLayoutBoundarySupplementaryItem : NSCollectionLayoutSupplementaryItem, NSCopying {
// ...
}

你會發現它就是一個基於 NSCollectionLayoutSupplementaryItem 提供一些方便的 method、並且預設在 section 之外的 supplementary item。那這個 NSCollectionLayoutSupplementaryItem 有甚麼不一樣呢?Boundary supplementary item 基本上就是用來描述一個預設存在 section 外圍的 view,而 supplementary item 就是更廣泛地用來描述所有在 section 之上的東西,像是我們現在要來介紹的,在 section 左上角的小紅點。

讓我們來看看它的實作:

// ...

let badgeSize = NSCollectionLayoutSize(widthDimension: .absolute(20), heightDimension: .absolute(20))
let badgeContainerAnchor = NSCollectionLayoutAnchor(edges: [.top, .leading], absoluteOffset: CGPoint(x: 10, y: 10))
            let badgeItemAnchor = NSCollectionLayoutAnchor(edges: [.bottom, .trailing], absoluteOffset: CGPoint(x: 0, y: 0))
let badgeItem = NSCollectionLayoutSupplementaryItem(layoutSize: badgeSize, elementKind: BadgeView.supplementaryKind, containerAnchor: badgeContainerAnchor, itemAnchor: badgeItemAnchor)

let item = NSCollectionLayoutItem(layoutSize: layoutSize, supplementaryItems: [badgeItem])

// ...

在這裡,我們多了一個新同學:NSCollectionLayoutAnchor。它的功用就跟它名稱暗示的一樣,是要用來指定錨點 (anchor) 的。對於一個 NSCollectionLayoutSupplementaryItem 來說,你除了要指定它的大小、element kind 之外,你也要指定它的兩個錨點,containerAnchoritemAnchor,用圖看就可以了解 containerAnchoritemAnchor 分別代表甚麼:

badge-2

從上圖我們可以看出來,itemAnchor 代表的是 item 用來對齊的錨點,而 containerAnchor 代表的是包含這個 itemsectiongroup 用來對齊的錨點。NSCollectionLayoutSupplementaryItem 會在 layout 的時候,將 item 跟 container 的錨點對齊,所以透過這兩種參數,我們就可以把 supplementary view 擺在任何我們想擺的地方。不過 itemAnchor 這個參數一般比較不常用,如果我們不在初始化 NSCollectionLayoutSupplementaryItem 時設定,那預設的 itemAnchor 就會在 item 的左上角 (0, 0) 的位置,光是利用 containerAnchor 就足以設定出小紅點的位置了,也就是說上面的例子我們可以簡化成這樣:

let badgeContainerAnchor = NSCollectionLayoutAnchor(edges: [.top, .leading], absoluteOffset: CGPoint(x: -10, y: -10))
let badgeItem = NSCollectionLayoutSupplementaryItem(layoutSize: badgeSize, elementKind: BadgeView.supplementaryKind, containerAnchor: badgeContainerAnchor)

結果在 UI 上是一模一樣的。當然如果你想要把 containerAnchor 設定在光年之外,再用 itemAnchor 拉回來也沒有人會阻止你。

在這邊我們還是要不厭其煩地提醒,記得實作 UICollectionViewDataSource 裡的:

optional func CollectionView(_ CollectionView: UICollectionView, 
viewForSupplementaryElementOfKind kind: String, 
                          at indexPath: IndexPath) -> UICollectionReusableView

並且處理好 BadgeView.supplementaryKind 這個 element kind 的 supplementary view 的回傳喔!

提供 section 背景的 decoration item

decoration-item

看到上面這個 layout,有做過的人就知道這是所有 UI 工程師的痛 😭。這是一個有四個 itemsection,並且它有一個圓角的灰色背景。以往這樣的 layout 你就一定要提供一個 decoration view,並且實作 UICollectionViewLayout 計算好它的大小,其它的方法都只會比算數學更複雜,還有看過像漢堡一樣做出上、中、下三種長相的 item,再把它們疊起來的。

不過現在,compositional layout 也有辦法控制 decoration view 的 layout 了!🎉 首先跟 supplementary 一樣,我們需要有一個 decoration view:

class SectionBackgroundDecorationView: UICollectionReusableView { ... }

在設定 layout 時的 code 長這樣:

// ...

let section = NSCollectionLayoutSection(group: group)

// 設定 decoration view 
let decorationItem = NSCollectionLayoutDecorationItem.background(elementKind: SectionBackgroundDecorationView.elementKind)
decorationItem.contentInsets = NSDirectionalEdgeInsets(top: 40, leading: 10, bottom: 10, trailing: 10)
section.decorationItems = [decorationItem]

let layout = UICollectionViewCompositionalLayout(section: section)

// 註冊 decoration view
layout.register(SectionBackgroundDecorationView.self, forDecorationViewOfKind: SectionBackgroundDecorationView.elementKind)

return layout 

你會看到跟 supplementary layout 一樣的邏輯:我們創造了一個 NSCollectionLayoutDecorationItem.background,並且設定好它對應的 elementKindcontentInsets,在 layout 的時候,剛剛新增的 SectionBackgroundDecorationView 就會被拿來當作背景貼在 section 的後面,附加設定好的邊界空間。跟 supplementary view 不一樣的地方是,要記得最後在回傳 layout 時,註冊這個 decoration view,而不是跟 CollectionView 註冊喔!

完全客制化的 Custom Layout

繼幾個實用的例子後,是時候來介紹一下不太常見的 layout 了。這是一個具有上下兩個 group,每個 group 都有三個 item 的神秘 UI:

custom-layout

利用這個乍看之下像小朋友下樓梯的 layout,我們可以來看看怎樣做出完全客制的 group layout,也就是如何利用 NSCollectionLayoutGroup.custom 這個 builder method:

let height: CGFloat = 120.0
let groupLayoutSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(height))
let group = NSCollectionLayoutGroup.custom(layoutSize: groupLayoutSize) { (env) -> [NSCollectionLayoutGroupCustomItem] in
    let size = env.container.contentSize
    let spacing: CGFloat = 8.0
    let itemWidth = (size.width-spacing*4)/3.0
    return [
        NSCollectionLayoutGroupCustomItem(frame: CGRect(x: 0, y: 0, width: itemWidth, height: height/3.0)),
        NSCollectionLayoutGroupCustomItem(frame: CGRect(x: (itemWidth+spacing), y: height/3.0, width: itemWidth, height: height/3.0)),
        NSCollectionLayoutGroupCustomItem(frame: CGRect(x: ((itemWidth+spacing)*2), y: height*2/3.0, width: itemWidth, height: height/3.0))
    ]
}

這個地方看起來有點複雜,讓我們先從 .custom 的定義開始看起:

open class func custom(layoutSize: NSCollectionLayoutSize, itemProvider: @escaping NSCollectionLayoutGroupCustomItemProvider)

這裡的 .custom 是指明我們這個 group 裡面的 layout 要我們自己來決定,它的第一個參數 layoutSize 想必大家都已經非常熟悉,就是指定這個 group 的大小。那麼甚麼是 itemProvider 呢?它其實是一個 (NSCollectionLayoutEnvironment) -> [NSCollectionLayoutGroupCustomItem] 的 closure,在系統準備呈現 item 之前,會呼叫這個 closure,請 closure 提供每一個 item 的位置資訊。這個 closure 會傳入一個代表容器的 NSCollectionLayoutEnvironment 物件進來,然後我們就可以利用這個 environment 提供的資訊,來計算 item 的大小跟位置,並且在 closure 把算好的 item 大小跟位置,透過型別是 NSCollectionLayoutGroupCustomItem 的物件傳出來,回傳值是一個 array,代表的是你需要指明這個 group 總共會有幾個 item。所以你會看到上面我們透過 env.container.contentSize 拿到容器的大小,也就是 group 的大小。利用這個資訊,算出這個 group 裡面的三個物件的絕對位置,再用 NSCollectionLayoutGroupCustomItem 打包位置資訊傳回去;而拿到這些資訊的 NSCollectionLayoutGroup,就可以知道在這個 group 裡面要怎樣呈現這些 item 了。

flexible 的應用 —— 置中與等寬排列

讓我們回來看看剛剛小紅點的例子,這是一個 item 被擺在螢幕正中間,而 group 寬度跟螢幕等寬的 layout:

layout-1

一般來說,如果我們甚麼事都不做,item 是會被由左至右排列的,就像這樣:

layout-2

如果我們希望前面多一點空隙,我們可以利用先前看過的 edgeSpacing 來達成,像是:

item.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .absolute(20), top: nil, trailing: nil, bottom: nil)

不過如果我們希望這個 item 可以被置中,直接指定 spacing 是 20 是行不通的,因為螢幕的寬度是會變化的,那我們要怎樣做到不管螢幕大或小,item 都要被擺在 group 的中間呢,這就是 NSCollectionLayoutSpacing.flexible 神奇的地方了:

item.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .flexible(0), top: nil, trailing: .flexible(0), bottom: nil)

在這裡我們直接指定左邊跟右邊的空間都是 flexible,並且最小值是 0,也就是告訴系統:這個 item 的左右邊的間距都是彈性的,只要保留 0 以上的空間就好。因為左邊跟右邊的空間都可以在 0 以上往上增長,最後的效果,就會是這個 item 會被擺在 group 的正中間,就像上面的圖一樣。另外一個 flexible 很好用的例子,是當我們想要把 item 等距擺放,而且距離隨著螢幕大小變化的時候。

像是在 iPhone 上:

layout-3

而在 iPad 上則是:

layout-4

你可以看到 itemitem 之間的間距是會跟著螢幕調整的,所以 item 不會向左或向右歪一邊,而是在對齊在中間並且盡可能地伸展空隙。這邊使用的設定是另外一個我們很熟悉的 interItemSpacing

group.interItemSpacing = .flexible(8)

透過指定 item 間的空間為 flexible,系統就會自動去調整 item 之間的距離。是不是終於有點實用了呢!


除了上面這些基本套路之外,還有非常多種可能的組合方式,不過你會發現,只要了解了基本的這些 group、item、spacing、dimention 等等的原理,任何變化都變得十分單純,就像是找到正確的積木就能堆出城堡一樣。

如果對於任何類別有疑問,非常推薦閱讀 SDK 的註解文件(在 Xcode 裡可以用 Control + Command + 點選某個類別名稱),你會看到很有趣且精美的註解,像是 NSCollectionLayoutEdgeSpacing 的註解:

edge-spacing

對工程師來說,畫 ascii art 可能比在網站上畫一般的圖更容易?

另外一個也很推薦的資源一樣是 Apple 官方的資料:Using CollectionView Compositional Layouts and Diffable Data Sources | Apple Developer Documentation。這個是一個範例程式,裡面涵蓋了幾乎所有常見的 Compositional Layout 的套路,而且每一種套路都被切分成一個個 view controller,你只要執行範例 app,對照 UI 上標示的 view controller 的名稱,就可以找到對應功能的範例程式。

Compositional Layout 與資料

上面全部內容都圍繞在如何利用組合的方式,做出適當的 UICollectionViewLayout 給 CollectionView 使用。不過我們在組合這些 layout 元件的時候,其實只處理到只有一種 section 的情況。一般更常見的 UI,不同的 section 就會有不一樣的設計,像是 AppStore 就至少有大張的橫向滾動 feature,跟小一點但兩、三個 item 垂直並列的橫向滾動兩種。如果你回去(不用真的回去)看我們的 code,可以看到一個 UICollectionViewLayout 其實只有包含一個 section 的 layout 元件:

lazy var CollectionViewLayout: UICollectionViewLayout = {

    let item = NSCollectionLayoutItem(layoutSize: itemSize)

    let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
                                                   subitems: [item])

    let section = NSCollectionLayoutSection(group: group)

    // 只有一個 section 被包含在 layout 裡
    let layout = UICollectionViewCompositionalLayout(section: section)
    return layout
}()

那我們要怎麼處理一張 CollectionView 裡面,有多種不一樣的 section 設計呢?Compositional Layout 除了上面看到的單一 section 的 initializer 之外,它另外也提供了可以注入一個 provider closure 的 initializer,它的定義是這樣:

init(sectionProvider: (Int, NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection)

從原本輸入一個靜態的 section 元件,改成輸入一個 closure,這個 closure 負責接收 section 的 index 跟容器的 environment,回傳對應的 section layout 元件。使用上會長這個樣子:

lazy var CollectionViewLayout: UICollectionViewLayout = {

    if let sectionLayoutProvider = self.sectionProvider {
        let layout = UICollectionViewCompositionalLayout { (sectionIndex, environment) -> NSCollectionLayoutSection? in
            switch sectionIndex {
            case 0:
                return featureSectionlayout
            case 1:
                return pileSectionLayout
            default:
                return normalSectionLayout
            }
        }
        return layout
    } else {
        fatalError("Section provider is not ready")
    }
}()

這個程式會根據不同的 section,回傳組好的 section layout 元件。有了這個 section provider 之後,我們就可以不用在創造 CollectionView 的時候決定好所有的 layout,某些設計的變化可以透過 section provider 在下一個 collection layout 循環時再提供就 ok 了。

結論

透過這個可以組合的 compositional layout,我們可以利用組合的方式做出我們想要的 layout,而不用一個一個算每一個 item 的位置跟大小,很大幅度地簡化了 layout 的過程。而且,compositional layout 跟 data source 還是相當獨立的兩個部份,你可以使用 compositional layout、加上原本的 data source;也可以使用新的 diffable data source,在抽換的時候完全不需要改變彼此的程式碼的。

如果你看完才發現,compositional layout 只能用在 iOS 13 以上的機器,先別急著關電視,已經有神人 Kishikawa Katsumi 把這一套 compositional layout 實作出來,讓你可以在 iOS 12 及以下的 SDK 中使用:IBPCollectionViewCompositionalLayout。他也有來今年的 iPlayground.io,介紹這個有趣的 compositional layout、以及他的 back porting 喔。

參考資料

作者
Huang ShihTing
I’m ShihTing Huang(黃士庭). I brew iOS app, front-end web app, and of course, coffee and beer!
評論
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。