方便排版的 UICollectionViewCompositionalLayout 初體驗

從 iOS 13 開始,iOS SDK 推出了 UICollectionViewCompositionalLayout,幫助我們更方便排版 collection view 的內容。從前在 collection view 需要大費周章才能實現的排版,利用 UICollectionViewCompositionalLayout 只要幾行程式就能搞定。除了簡單的格子狀照片牆排版,UICollectionViewCompositionalLayout 還能實現一些複雜的畫面。

以下圖的 App Store 為例,UICollectionViewCompositionalLayout 可幫我們克服畫面裡的兩大難題。

  • 整個 collection view 上下滑動,裡面的內容可左右滑動。
  • 分頁滑動區塊會露出前一個和下一個的部分內容。

接下來就讓我們以熱血的聖鬥士星矢漫畫封面為例,一步步從零開始認識 UICollectionViewCompositionalLayout。

文章重點整理

  • 將 collection view 的 collectionViewLayout 改成 compositional layout
  • Item,Group,Section & 固定大小的 absolute
  • 控制 item 排列方向的 NSCollectionLayoutGroup.vertical & NSCollectionLayoutGroup.horizontal
  • 依比例設定 item & group 大小的 fractionalWidth & fractionalHeight,比方格子狀照片牆(grid)
  • 自動計算合適大小的 estimated
  • 設定間距
  • 控制內容的捲動方向
  • 控制捲動行為的 orthogonalScrollingBehavior,分頁和露出前一個 / 下一個的部分內容
  • 每個 section 採用不同的排版,比方整個頁面垂直捲動,頁面裡有水平捲動的區塊

設定 UICollectionViewCompositionalLayout 前的準備

1. storyboard 畫面設計

  • collection view 的寬度為螢幕寬度,高度為 300。
  • collection view 的 data source 為 view controller。
  • cell 裡裝 image view,image view 佔滿 cell,image view 的大小等於 cell 的大小。
  • cell 大小設為 100 * 137,cell 之間的間距 10。
  • cell 的類別 & reuse id 為 CollectionViewCell,連結 image view 的 outlet。
class CollectionViewCell: UICollectionViewCell {
@IBOutlet weak var imageView: UIImageView!

}

2. 設定 cell 的數量和內容,連結 collection view 的 outlet

  • 方法 1: 定義 UICollectionViewDataSource 的相關 function
class ViewController: UIViewController {
@IBOutlet weak var collectionView: UICollectionView!
}

extension ViewController: UICollectionViewDataSource {

func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 15
}

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "\(CollectionViewCell.self)", for: indexPath) as! CollectionViewCell
cell.imageView.image = UIImage(named: "聖鬥士星矢\(indexPath.item + 1)")
return cell
}

}
  • 方法 2: 使用 UICollectionViewDiffableDataSource
class ViewController: UIViewController {

@IBOutlet weak var collectionView: UICollectionView!
var dataSource: UICollectionViewDiffableDataSource<Int, Int>!

override func viewDidLoad() {
super.viewDidLoad()
createDataSource()
createSnapshot()
}

func createSnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Int, Int>()
snapshot.appendSections([0])
snapshot.appendItems(Array(0...14))
dataSource.apply(snapshot, animatingDifferences: false)
}

func createDataSource() {
dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView, cellProvider: { collectionView, indexPath, itemIdentifier in
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "\(CollectionViewCell.self)", for: indexPath) as! CollectionViewCell
cell.imageView.image = UIImage(named: "聖鬥士星矢\(indexPath.item + 1)")
return cell
})
}
}

Flow Layout 的 collection view

現在我們已經可以執行 App 了。collection view 預設的排版是 flow layout,預設的捲動方向是 Vertical。

畫面將如下圖的格子狀排版,畫面可上下捲動,由於每個 cell 大小為 100 * 137,因此在寬度 390 的 iPhone 12 一排將顯示 3 個 cell。

將 collection view 的 collectionViewLayout 改成 compositional layout

接下來終於可以進入重頭戲了。將 collection view 的 collectionViewLayout 從預設的 flow layout 改成 compositional layout。

如下圖所示,compositional layout 控制畫面的排版,它將畫面分成多個 Section,Section 裡分成一個個 Group,Group 裡分成一個個 Item。

相較於 flow layout 的 collection view,它多了 Group,Group 讓它變得更彈性,更容易做出複雜的排版。接下來我們將透過實際的例子學習Section,Group & Item 排版的概念。

Item,Group,Section & 固定大小的 absolute

class ViewController: UIViewController {
@IBOutlet weak var collectionView: UICollectionView!

override func viewDidLoad() {
super.viewDidLoad()
collectionView.collectionViewLayout = generateLayout()
}

func generateLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(100), heightDimension: .absolute(137))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(390), heightDimension: .absolute(150))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
return UICollectionViewCompositionalLayout(section: section)
}
}

說明:

collectionView.collectionViewLayout = generateLayout()

將 collectionViewLayout 設為 UICollectionViewCompositionalLayout,generateLayout 將回傳 UICollectionViewCompositionalLayout。

let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(100), heightDimension: .absolute(137))
let item = NSCollectionLayoutItem(layoutSize: itemSize)

item 對應到 collection view cell,它控制 cell 的大小。在此我們設定 item size 為 100 * 137,NSCollectionLayoutDimension 的 absolute 可指定固定的大小。

let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(390), heightDimension: .absolute(145))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

group 裡可以有很多 item,在此我們設定 group size 為 390 * 145,然後利用 NSCollectionLayoutGroup.horizontal 產生 group。horizontal 將讓 group 裡的 item 沿著水平方向排列,參數 subitems 傳入 [item] 表示 group 裡排列的是剛剛產生的 item。

let section = NSCollectionLayoutSection(group: group)

section 裡可以有很多 group ,產生 NSCollectionLayoutSection 時傳入 group 表示 section 裡排列的是剛剛產生的 group。

return UICollectionViewCompositionalLayout(section: section)

利用 section 產生 UICollectionViewCompositionalLayout。

結果

如下圖所示,group 裡的 item 將沿著水平方向排列,由於 group size 為 390 * 145,item size 為 100 * 137,因此一排可顯示三本書。section 裡的 group 預設將由上而下排列,由上而下依序為 Group0,Group 1,Group 2 等,我們可上下滑動瀏覽內容。(ps: 後面會再說明如何調整 group 排列的方向,我們也可以讓它變成由左而右排列)

當 group 的寬度超過 collection view 的寬度

接著讓我們試試 group 的寬度超過 collection view 的寬度會發生什麼事 ?

func generateLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(100), heightDimension: .absolute(137))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(650), heightDimension: .absolute(145))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
return UICollectionViewCompositionalLayout(section: section)
}

group 寬度 650,item 寬度 100,所以一個 group 可以排列 6 本書。在 iPhone 12 時,collection view 寬度只有 390,當 group 寬度超過 collection view 寬度時,我們將可水平滑動瀏覽內容。

控制 item 排列方向的 NSCollectionLayoutGroup

除了 NSCollectionLayoutGroup.horizontal,我們也可以用 NSCollectionLayoutGroup.vertical 產生 group。NSCollectionLayoutGroup.vertical 將讓 item 沿著垂直方向排列。

func generateLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(80), heightDimension: .absolute(80))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(390), heightDimension: .absolute(180))
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
return UICollectionViewCompositionalLayout(section: section)
}

如下圖所示,group 的高度為 180,item 的高度為 80,因此一個 group 可以裝兩個 item。

依比例設定 item & group 大小的 fractionalWidth & fractionalHeight,比方格子狀照片牆(grid)

使用 absolute 設定固定大小很方便,不過更多時候我們會使用較彈性的 fractionalWidth & fractionalHeight,它可以依比例設定 item & group 的大小。

比方一排 3 張照片的格子狀照片牆,透過 fractionalWidth & fractionalHeight 將可輕易做到,不用再像以前 flow layout 時手動計算大小。

func generateLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1/3), heightDimension: .fractionalHeight(1))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(1/3))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
return UICollectionViewCompositionalLayout(section: section)
}

說明

let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1/3), heightDimension: .fractionalHeight(1))
let item = NSCollectionLayoutItem(layoutSize: itemSize)

設定 item size 時,fractional 指的是相對於 group 的大小, widthDimension 傳入 fractionalWidth(1/3) 代表 item 的寬度是 group 寬度的 1/3,heightDimension 傳入 fractionalHeight(1) 代表 item 的高度等於 group 高度。因此水平排列時一個 group 可以裝 3 個 item。

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

設定 group size 時,fractional 依據以下兩種情況有不同的意思:

  • group 被包在 section 裡。
  • group 被包在另一個 group 裡。

我們先看比較簡單的情況,當 group 被包在 section 裡,fractional 指的是相對於 collection view 的大小,widthDimension 傳入 .fractionalWidth(1) 代表 group 的寬度等於 collection view 的寬度,heightDimension 傳入 fractionalWidth(1/3) 代表 group 的高度等於 collection view 寬度的 1/3。

綜合以上條件, item 的寬度跟高度都會是 collection view 寬度的 1/3,因此 collection view 一排將顯示 3 個 item,每個 item 會是正方形 。

ps: group & section 若有設定 contentInsets,fractional 計算大小時會先扣除間距,相關說明後面會說明。

接著讓我們再看看其它幾個例子。

  • 畫面一開始顯示時剛好顯示 2 個 group,圖片完全不會被切到。

將 group size 的高度設為 fractionalHeight(1/2),group 的高度等於 collection view 高度的 1/2。

func generateLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1/3), heightDimension: .fractionalHeight(1))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1/2))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
return UICollectionViewCompositionalLayout(section: section)
}
  • 指定 item 寬高比例 1:2

剛剛我們看到實現正方形 item 的方法,現在讓我們看看另一種搭配 estimated 的方法。我們將 itemSize 的寬高分別設為 fractionalWidth(1/3) & fractionalWidth(2/3),因此比例會是 1:2。至於 group 的大小,我們將高度設為 estimated(100),它將自動計算合適的高度,在此 group 的高度將變成剛好容納 item 的高度。

func generateLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1/3), heightDimension: .fractionalWidth(2/3))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(100))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
return UICollectionViewCompositionalLayout(section: section)
}

讓 collection view 轉向時維持排版

使用 fractional 還有另一個好處,在 iPhone 轉向時 collection view 可以維持排版。以下例子將讓 collection view 一排顯示三張圖片,不管 iPhone 在直向還是橫向。

func generateLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1/3), heightDimension: .fractionalHeight(1))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(1/3))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
return UICollectionViewCompositionalLayout(section: section)
}

指定 group 裡的 item 數量

利用 let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(1/3)) 可讓 group 裡水平排列的 item 裝三個,不過我們也可以用另一種寫法,生成 group 時在參數 count 裡傳入 item 的數量。

func generateLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(1/3))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 3)
let section = NSCollectionLayoutSection(group: group)
return UICollectionViewCompositionalLayout(section: section)
}

值得注意的,當我們用 count 指定數量時,item size 的 widthDimension 傳什麼就不重要了,因為它會自己算出一個 item 要多寬。

搭配 auto layout 設定 item 的大小比例

讓 item 固定大小比例有很多方法,現在讓我們再看另一種搭配 auto layout 的方法。在 storyboard 設定 image view 的比例為 1:1,priority 設為 999。(當 priority 為 1000 時,執行時 Xcode 將顯示 auto layout breaking constraint 的警告訊息。)

item 的寬度由 group 裡裝 3 個 item 自動計算,而 item 的高度 & group 高度都設為 estimated,它將自動計算適合的高度。由於我們設定 image view 的比例為 1:1,因此 item 和 group 的高度將等於 item 的寬度。

func generateLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(100))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(100))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 3)
let section = NSCollectionLayoutSection(group: group)
return UICollectionViewCompositionalLayout(section: section)
}

設定間距

我們可以透過很多方法設定間距,比方 item 的 contentInsets 或 group 的 interItemSpacing 都可以設定 item 間的間距,不過卻有小小的差異,讓我們看看以下的例子。

func generateLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(100), heightDimension: .absolute(100))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(390), heightDimension: .absolute(100))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
group.interItemSpacing = .fixed(30)
let section = NSCollectionLayoutSection(group: group)
return UICollectionViewCompositionalLayout(section: section)
}

group.interItemSpacing = .fixed(30) 讓 item 間有著 30 的間距,item 的大小維持 100 * 100。

func generateLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(100), heightDimension: .absolute(100))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
item.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 30)
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(390), heightDimension: .absolute(100))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
return UICollectionViewCompositionalLayout(section: section)
}

item.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 30) 將在原本 100 * 100 的 item 裡加入右邊 30 的間距,因此 item 的大小將變成 70 * 100,不再是正方形。

若要維持 item 100 * 100 的大小,還有一個方法是設定 edgeSpacing,例如以下例子。

item.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .fixed(0), top: .fixed(0), trailing: .fixed(30), bottom: .fixed(0))

了解 contentInsets,interItemSpacing & edgeSpacing 的差別後,我們將可運用以下 property 調整間距。

  • NSCollectionLayoutItem 的 contentInsets & edgeSpacing。
  • NSCollectionLayoutGroup 的 contentInsets,interItemSpacing & edgeSpacing。
  • NSCollectionLayoutSection 的 contentInsets & interGroupSpacing。

接著讓我們試試在一排顯示 3 張圖片,item 彼此的間距是 10,item 跟邊界的間距也是 10。

  • 範例1: group 的高度固定為 200
func generateLayout() -> UICollectionViewLayout {
let padding: Double = 10
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(200))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 3)
group.interItemSpacing = .fixed(padding)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = padding
section.contentInsets = NSDirectionalEdgeInsets(top: padding, leading: padding, bottom: padding, trailing: padding)
return UICollectionViewCompositionalLayout(section: section)

}

說明

group.interItemSpacing = .fixed(padding)

設定 item 之間的間距。

section.interGroupSpacing = padding

設定 group 之間的間距。

section.contentInsets = NSDirectionalEdgeInsets(top: padding, leading: padding, bottom: padding, trailing: padding)

設定 section 跟 collection view 邊界的間距。

若是 item 的寬度是以 fractionalWidth(1/3) 計算時,我們不能用 interGroupSpacing 設定 item 間的間距,而要改用 item 的 contentInsets。我們將 item contentInsets 的 leading 設為 10,section contentInsets 的 trailing 設為 10,因此水平方向的 item 間距以及 item 跟邊界的間距都會是 10。

func generateLayout() -> UICollectionViewLayout {
let padding: Double = 10
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1/3), heightDimension: .fractionalHeight(1))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
item.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: padding, bottom: 0, trailing: 0)

let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(200))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = padding
section.contentInsets = NSDirectionalEdgeInsets(top: padding, leading: 0, bottom: padding, trailing: padding)
return UICollectionViewCompositionalLayout(section: section)

}
  • 範例2: item 比例固定為 1:1

方法 1: 使用 estimated 設定 group 高度

func generateLayout() -> UICollectionViewLayout {
let padding: Double = 10
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1/3), heightDimension: .fractionalWidth(1/3))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
item.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: padding, bottom: 0, trailing: 0)

let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(100))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = padding
section.contentInsets = NSDirectionalEdgeInsets(top: padding, leading: 0, bottom: padding, trailing: padding)
return UICollectionViewCompositionalLayout(section: section)

}

方法 2: 使用 auto layout 計算 group 高度

先在 storyboard 設定 image view 的比例為 1:1,然後利用以下程式設定排版。

func generateLayout() -> UICollectionViewLayout {
let padding: Double = 10
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(100))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(100))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 3)
group.interItemSpacing = .fixed(padding)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = padding
section.contentInsets = NSDirectionalEdgeInsets(top: padding, leading: padding, bottom: padding, trailing: padding)
return UICollectionViewCompositionalLayout(section: section)
}

當我們設定 contentInsets 時,它也會影響 fractional 計算的大小。

  • item size 以 fractional 計算:

group 若有設定 contentInsets,則須扣除 contentInsets 再乘 fractional 設定的比例。

  • group size 以 fractional 計算

section 若有設定 contentInsets,則要考慮 contentInsets。group 垂直排列時,須扣除 contentInsets 的 leading & trailing 再乘 fractional 設定的比例。group 水平排列時,須扣除 contentInsets 的 top & bottom 再乘 fractional 設定的比例。

控制內容的捲動方向

方法1: 設定 UICollectionViewCompositionalLayoutConfiguration 的 scrollDirection

當 collection view 採用 UICollectionViewCompositionalLayout 時,預設的捲動方向是 vertical,group 將延著垂直方向排列。

我們可透過 UICollectionViewCompositionalLayoutConfiguration 的 scrollDirection 設定 horizontal 或 vertical。當我們設為 horizontal 時,collection view 將變成水平捲動,group 將延著水平方向排列。

func generateLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(100), heightDimension: .fractionalHeight(1))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(250), heightDimension: .fractionalHeight(1))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
let configuration = UICollectionViewCompositionalLayoutConfiguration()
configuration.scrollDirection = .horizontal
return UICollectionViewCompositionalLayout(section: section, configuration: configuration)
}

方法 2: 設定 section 的 orthogonalScrollingBehavior

section 裡 group 預設的排列方向會跟 UICollectionViewCompositionalLayoutConfiguration 的 scrollDirection 一致,由於 scrollDirection 預設為 vertical,因此 group 會由上而下排列。

當 section 的 orthogonalScrollingBehavior 不是 none 時, group 的排列方向會跟 scrollDirection 正交。

什麼是正交呢?它就像唱反調,喜歡跟別人不一樣。換句話說,當 scrollDirection 為 vertical,group 將延水平方向排列。當 scrollDirection 為 horizontal,group 將延垂直方向排列。

以下例子我們將 orthogonalScrollingBehavior 設為 continuous,由於 scrollDirection 預設為 vertical,因此 group 將延水平方向排列,我們可以水平捲動。

func generateLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(100), heightDimension: .fractionalHeight(1))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(250), heightDimension: .fractionalHeight(1))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuous
return UICollectionViewCompositionalLayout(section: section)
}

值得注意的,剛剛的例子是 section 裡的內容可以水平捲動,但 collection view 本身還是垂直捲動。若想將 collection view 本身的捲動關掉,記得要將它的 isScrollEnabled 設為 false

控制捲動行為的 orthogonalScrollingBehavior,分頁和露出前一個 / 下一個的部分內容

我們可以透過 orthogonalScrollingBehavior 調整畫面捲動的行為,接下來讓我們看看幾種不同的捲動行為。

continuous

func generateLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuous
return UICollectionViewCompositionalLayout(section: section)
}

畫面水平捲動,可停留在任何一個位置,沒有分頁的效果。

paging

func generateLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .paging
return UICollectionViewCompositionalLayout(section: section)
}

畫面水平捲動,搭配分頁效果,分頁的寬度是 collection view 的寬度。

不過當 group 之間有間距時,剛剛的分頁效果卻會產生問題。因為 paging 時分頁的寬度是 collection view 的寬度,因此滑動後我們會看到間距露出來。

func generateLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 10
section.orthogonalScrollingBehavior = .paging
return UICollectionViewCompositionalLayout(section: section)
}

groupPaging

groupPaging 可解決剛剛的問題,它的分頁將以 group 為單位,滑動停止時將剛好顯示 group 的起點。

func generateLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 10
section.orthogonalScrollingBehavior = .groupPaging
return UICollectionViewCompositionalLayout(section: section)
}

如下圖所示,滑動停止時一定會剛好顯示 group 的起點,不會露出間距。

分頁,露出上一個 / 下一個的部分內容

接著讓我們挑戰 iOS App 時常看到的酷炫設計,不只有分頁,還可以露出前一個 / 下一個的部分內容。

露出下一個的部分內容: groupPaging

func generateLayout() -> UICollectionViewLayout {
let space: Double = 10
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.9), heightDimension: .fractionalHeight(1))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = space
section.orthogonalScrollingBehavior = .groupPaging
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: space, bottom: 0, trailing: space)
return UICollectionViewCompositionalLayout(section: section)
}

說明

let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.9), heightDimension: .fractionalHeight(1))

group 的寬度是 collection view 寬度的 0.9 倍,因此畫面上將露出下一個的部分內容。

section.orthogonalScrollingBehavior = .groupPaging

滑動停止時將剛好停在 group 的起點。

露出上一個 / 下一個的部分內容: groupPagingCentered

不只是露出下一個,我們也可以露出上一個的部分內容。我們可以改用 groupPagingCentered。滑動停止時它將讓 group 置中,因此前一個跟下一個都可以露出。(ps: 以下例子若採用 interGroupSpacing 設定間距會出問題,因此要從 item 的 contentInsets 設間距,間距將是 space * 2)

func generateLayout() -> UICollectionViewLayout {
let space: Double = 5
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
item.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: space, bottom: 0, trailing: space)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.9), heightDimension: .fractionalHeight(1))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .groupPagingCentered
return UICollectionViewCompositionalLayout(section: section)
}

continuousGroupLeadingBoundary

最後還有一個特別的捲動效果 continuousGroupLeadingBoundary,它同時具有快速滑動跟分頁的優點。

  • 它可以快速滑動,不像分頁一次只能滑一個。
  • 當滑動停止時,它將剛好停在 group 的起點,所以圖片不會被切到。
func generateLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(1))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalHeight(1), heightDimension: .fractionalHeight(1))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 10
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
return UICollectionViewCompositionalLayout(section: section)
}

彼得潘大力一滑,從一開始的第一集跑到第八集漫畫。

多個 section

剛剛看到的例子只產生一個 NSCollectionLayoutSection,因此只會有一種 section 的版面設計。就算我們的 App 有多個 section,每個 section 會是同樣的排版,例如以下例子:

class ViewController: UIViewController {

@IBOutlet weak var collectionView: UICollectionView!

override func viewDidLoad() {
super.viewDidLoad()
collectionView.collectionViewLayout = generateLayout()
}

func generateLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(1))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(300), heightDimension: .absolute(300))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 10
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 30, trailing: 0)
section.orthogonalScrollingBehavior = .groupPaging
return UICollectionViewCompositionalLayout(section: section)
}
}

extension ViewController: UICollectionViewDataSource {

func numberOfSections(in collectionView: UICollectionView) -> Int {
return 2
}

func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
if section == 0 {
return 15
} else {
return 8
}
}

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "\(CollectionViewCell.self)", for: indexPath) as! CollectionViewCell
if indexPath.section == 0 {
cell.imageView.image = UIImage(named: "聖鬥士星矢\(indexPath.item + 1)")
} else {
cell.imageView.image = UIImage(named: "spider\(indexPath.item + 1)")
}
return cell
}
}

collection view 有兩個 section,分別顯示帥氣的聖鬥士星矢跟 spider man。

每個 section 採用不同的排版,比方整個頁面垂直捲動,頁面裡有水平捲動的區塊

我們也可以產生多個 NSCollectionLayoutSection,讓每個 section 採用不同的排版。

ps: 在實驗以下範例前,先回到 storyboard,將 collection view 高度 300 的條件拿掉,加入跟 safe area bottom 間距 0 的條件。

範例 1: 第一個 section 一次顯示一張圖,第二個 section 一次顯示兩張圖

func generateLayout() -> UICollectionViewLayout {
UICollectionViewCompositionalLayout { sectionIndex, environment in

let itemCountInGroup = sectionIndex == 0 ? 1 : 2
let ratio = 1 / Double(itemCountInGroup)
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(ratio), heightDimension: .fractionalWidth(ratio))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(100))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 10
section.orthogonalScrollingBehavior = .groupPaging
return section
}
}

說明

生成 UICollectionViewCompositionalLayout 時改用以下的 init。

init(sectionProvider: @escaping UICollectionViewCompositionalLayoutSectionProvider)

UICollectionViewCompositionalLayoutSectionProvider 的定義如下

typealias UICollectionViewCompositionalLayoutSectionProvider = (Int, NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection?

在參數 sectionProvider 傳入生成 NSCollectionLayoutSection 的 closure。我們有 2 個 section,因此 closure 將呼叫兩次,參數 sectionIndex 分別是 0 & 1,我們可依據 sectionIndex 設計不同的排版,回傳不同的 section。範例裡依據 sectionIndex 計算不同的 item size,

範例 2: 整個頁面垂直捲動,頁面裡有水平捲動的區塊

var seiyaSection: NSCollectionLayoutSection {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(1))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(300), heightDimension: .absolute(300))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 10
section.orthogonalScrollingBehavior = .groupPaging
return section
}

var spiderManSection: NSCollectionLayoutSection {
let padding: Double = 10
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1/2), heightDimension: .fractionalWidth(1/2))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
item.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: padding, bottom: 0, trailing: 0)

let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(100))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = padding
section.contentInsets = NSDirectionalEdgeInsets(top: padding, leading: 0, bottom: padding, trailing: padding)
return section
}

func generateLayout() -> UICollectionViewLayout {
UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in
if sectionIndex == 0 {
return self.seiyaSection
} else {
return self.spiderManSection
}
}
}

定義回傳 NSCollectionLayoutSection 的 computed property seiyaSection & spiderManSection。值得注意的,在產生 UICollectionViewCompositionalLayout 時必須加上 capture list [unowned self],不然會產生 reference cycle 的記憶體問題。

UICollectionViewCompositionalLayout 常見排版範例

collection view cell 不同高度的對齊問題

UICollectionViewCompositionalLayout 還可以完美解決 flow layout 在 cell 不同高度的對齊問題,相關說明可參考以下連結。

--

--

彼得潘的 iOS App Neverland
彼得潘的 Swift iOS App 開發問題解答集

彼得潘的iOS App程式設計入門,文組生的iOS App程式設計入門講師,彼得潘的 Swift 程式設計入門,App程式設計入門作者,http://apppeterpan.strikingly.com