方便排版的 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 不同高度的對齊問題,相關說明可參考以下連結。