はじめに
WWDC2019で新たに発表された、UICollectionViewのレイアウト手法であるUICollectionViewCompositionalLayoutを使っていくつかカスタムレイアウトを作ってみました。
※ iOS13以降の環境にて、今回作成したサンプルを動かせます
UICollectionViewCompositionalLayoutとは
詳細については、WWDCのセッションを参考にして頂ければと思いますが、ざっくり言うと、
- iOS13からUICollectionViewCompositionalLayoutが登場したことで、UICollectionViewFlowLayoutやUICollectionViewDelegateFlowLayoutに加えて、UICollectionViewのレイアウトを定義する方法が1つ増えた。
- iOS13からUICollectionViewDiffableDataSourceが登場したことで、UICollectionViewDataSourceを準拠して行なっていたDataSource管理の方法が増えた。(こちらについては本記事では解説していません)
※ UICollectionViewDataSourceのみでDataSource管理を行なった場合でも、UICollectionViewCompositionalLayoutは使用できます。
※ より詳細な情報については、こちらの記事がかなり詳しくまとめてくださっています。
時代の変化に応じて進化するCollectionView ~Compositional LayoutsとDiffable Data Sources~
階層構造
UICollectionViewCompositionalLayoutにおける階層構造は、Item、Group、Sectionなどの概念から成り立っています。
実装するレイアウト
今回はUICollectionViewCompositionalLayoutを使用して、3種類のレイアウトを実装してみました。
1. グリッド形式(3×n)
このレイアウトは、赤枠で囲んだ部分のグループを用意し、×nの形式で表示させる属性を持たせたセクションを用意します。
private func gridSection(collectionViewBounds: CGRect) -> NSCollectionLayoutSection {
let itemCount = 3 // 横に並べる数
let lineCount = itemCount - 1
let itemSpacing = CGFloat(1) // セル間のスペース
let itemLength = (collectionViewBounds.width - (itemSpacing * CGFloat(lineCount))) / CGFloat(itemCount)
// 1つのitemを生成
// .absoluteは固定値で指定する方法
let item = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .absolute(itemLength),
heightDimension: .absolute(itemLength)))
// itemを3つ横並びにしたグループを生成
// .fractional~は親Viewとの割合
let items = NSCollectionLayoutGroup.horizontal(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .fractionalHeight(1.0)),
subitem: item,
count: itemCount)
// グループ内のitem間のスペースを設定
items.interItemSpacing = .fixed(itemSpacing)
// 生成したグループ(items)が縦に並んでいくグループを生成(実質これがセクション)
let groups = NSCollectionLayoutGroup.vertical(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(itemLength)),
subitems: [items])
// 用意したグループを基にセクションを生成
// 基本的にセルの数は意識しない、セルが入る構成(セクション)を用意しておくだけで勝手に流れてく
let section = NSCollectionLayoutSection(group: groups)
// セクション間のスペースを設定
section.interGroupSpacing = itemSpacing
return section
}
必要なセクション(NSCollectionLayoutSection)を用意したら、それを基にUICollectionViewCompositionalLayoutを生成できます。
UICollectionViewCompositionalLayout(section: 生成したSection)
あとは用意したUICollectionViewCompositionalLayoutクラスをCollectionViewに割り当てます。
collectionView.collectionViewLayout = 用意したUICollectionViewCompositionalLayout
// 既に構成されているレイアウトを更新する場合は、invalidateLayout()を呼びます。
collectionView.collectionViewLayout.invalidateLayout()
2. 異なるサイズのItem併用 (Instagram風)
このレイアウトは、緑枠で囲った各グループを用意して、それらを結合した1つのセクションを用意します。
private func largeAndSmallSquareSection(collectionViewBounds: CGRect) -> NSCollectionLayoutSection {
let itemSpacing = CGFloat(2) // セル間のスペース
// 小itemが縦に2つ並んだグループ
let itemLength = (collectionViewBounds.width - (itemSpacing * 2)) / 3
let largeItemLength = itemLength * 2 + itemSpacing
let item = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .absolute(itemLength),
heightDimension: .absolute(itemLength)))
let verticalItemTwo = NSCollectionLayoutGroup.vertical(layoutSize: NSCollectionLayoutSize(widthDimension: .absolute(itemLength),
heightDimension: .absolute(largeItemLength)),
subitem: item,
count: 2)
verticalItemTwo.interItemSpacing = .fixed(itemSpacing)
// 大item + 小item*2 のグループ
let largeItem = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .absolute(largeItemLength),
heightDimension: .absolute(largeItemLength)))
let largeItemLeftGroup = NSCollectionLayoutGroup.horizontal(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(largeItemLength)),
subitems: [largeItem, verticalItemTwo])
largeItemLeftGroup.interItemSpacing = .fixed(itemSpacing)
let largeItemRightGroup = NSCollectionLayoutGroup.horizontal(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(largeItemLength)),
subitems: [verticalItemTwo, largeItem])
largeItemRightGroup.interItemSpacing = .fixed(itemSpacing)
// 小ブロックが縦に2つ並んだグループを横に3つ並べたグループ
let twoThreeItemGroup = NSCollectionLayoutGroup.horizontal(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(largeItemLength)),
subitem: verticalItemTwo,
count: 3)
twoThreeItemGroup.interItemSpacing = .fixed(itemSpacing)
// 各グループを縦に並べたグループ
let subitems = [largeItemLeftGroup, twoThreeItemGroup, largeItemRightGroup, twoThreeItemGroup]
let groupsSpaceCount = CGFloat(subitems.count - 1)
let heightDimension = NSCollectionLayoutDimension.absolute(largeItemLength * CGFloat(subitems.count) + (itemSpacing * groupsSpaceCount))
// MEMO: 高さの計算は後に追加するスペース分も足す
let groups = NSCollectionLayoutGroup.vertical(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: heightDimension),
subitems: subitems)
groups.interItemSpacing = .fixed(itemSpacing)
let section = NSCollectionLayoutSection(group: groups)
section.interGroupSpacing = itemSpacing
return section
}
同様に、セクションを基にUICollectionViewCompositionalLayoutを用意してCollectionViewのレイアウトに割り当てます。
3. 複数Section併用 (Netflix風)
Appleが公式で公開しているサンプルをみた感じ、「ヘッダーを付与する」や「itemを横スクロールさせる」といった実装は、セクション(NSCollectionLayoutSection)を対象に振る舞いを指定する方法が一般的なようなので、上記レイアウトを実装する場合は、「複数のセクションを用意して実行時に渡ってくるindexPath.sectionの値に合わせて適切なセクションを返す」という流れで実装してみました。
/// 縦長の長方形が1つだけのセクション
private func verticalRectangleSection(collectionViewBounds: CGRect) -> NSCollectionLayoutSection {
let verticalRectangleHeight = collectionViewBounds.height * 0.7
let verticalRectangleItem = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .fractionalHeight(1.0)))
let verticalRectangleGroup = NSCollectionLayoutGroup.vertical(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(verticalRectangleHeight)),
subitem: verticalRectangleItem,
count: 1)
return NSCollectionLayoutSection(group: verticalRectangleGroup)
}
/// 縦長の長方形が横スクロールするセクション(ヘッダー付き)
private func rectangleHorizonContinuousWithHeaderSection(collectionViewBounds: CGRect) -> NSCollectionLayoutSection {
let headerHeight = CGFloat(50)
let headerElementKind = "header-element-kind"
let insetSpacing = CGFloat(5)
let rectangleItemWidth = collectionViewBounds.width * 0.9 / 3
let rectangleItemHeight = rectangleItemWidth * (4/3)
let rectangleItem = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .fractionalHeight(1.0)))
let horizonRectangleGroup = NSCollectionLayoutGroup.horizontal(layoutSize: NSCollectionLayoutSize(widthDimension: .absolute(rectangleItemWidth),
heightDimension: .absolute(rectangleItemHeight)),
subitem: rectangleItem,
count: 1)
horizonRectangleGroup.contentInsets = NSDirectionalEdgeInsets(top: insetSpacing, leading: insetSpacing, bottom: insetSpacing, trailing: insetSpacing)
let horizonRectangleContinuousSection = NSCollectionLayoutSection(group: horizonRectangleGroup)
let sectionHeaderItem = NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(headerHeight)),
elementKind: headerElementKind,
alignment: .top)
sectionHeaderItem.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: insetSpacing, bottom: 0, trailing: insetSpacing)
horizonRectangleContinuousSection.boundarySupplementaryItems = [sectionHeaderItem] // セクションに対してヘッダーを付与
horizonRectangleContinuousSection.orthogonalScrollingBehavior = .continuous // セクションに対して横スクロール属性を付与
horizonRectangleContinuousSection.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: insetSpacing, bottom: 0, trailing: insetSpacing)
return horizonRectangleContinuousSection
}
/// 正方形が1つだけのセクション(ヘッダー付き)
private func squareWithHeaderSection(collectionViewBounds: CGRect) -> NSCollectionLayoutSection {
let itemLength = collectionViewBounds.width
let headerHeight = CGFloat(50)
let headerInsetSpacing = CGFloat(10)
let headerElementKind = "header-element-kind"
let squareItem = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0)))
let squareGroup = NSCollectionLayoutGroup.vertical(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(itemLength)),
subitem: squareItem,
count: 1)
let squareSection = NSCollectionLayoutSection(group: squareGroup)
let sectionHeaderItem = NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(headerHeight)),
elementKind: headerElementKind,
alignment: .top)
sectionHeaderItem.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: headerInsetSpacing, bottom: headerInsetSpacing, trailing: 0)
squareSection.boundarySupplementaryItems = [sectionHeaderItem]
return squareSection
}
用意した3種類のセクションをindexPath.sectionに合わせて返します。
※ CollectionView側のセクション数は別途指定しています。(今回は4)
各セクションをindexPath.sectionに合わせて返す場合は、UICollectionViewCompositionalLayoutのinit時に以下のイニシャライザを使います。
public init(sectionProvider: @escaping UICollectionViewCompositionalLayoutSectionProvider)
public init(sectionProvider: @escaping UICollectionViewCompositionalLayoutSectionProvider, configuration: UICollectionViewCompositionalLayoutConfiguration)
実装例
let layout = UICollectionViewCompositionalLayout { (section, _) -> NSCollectionLayoutSection? in
let layoutSection = 渡ってきたsectionに応じたNSCollectionLayoutSectionを返す
return layoutSection
}
return layout
上記の流れで、用意した3種類のNSCollectionLayoutSectionを4つのindexPath.sectionに対してそれぞれ適したものを返すことで、レイアウトを組みます。
今回のindexPath.sectionに対するNSCollectionLayoutSectionの順は以下。
0: 縦長の長方形が1つだけのNSCollectionLayoutSection
1: 縦長の長方形が横スクロールするNSCollectionLayoutSection(ヘッダー付き)
2: 縦長の長方形が横スクロールするNSCollectionLayoutSection(ヘッダー付き)
3: 正方形が1つだけのNSCollectionLayoutSection(ヘッダー付き)
※ 「縦長の長方形が1つだけのセクション」は、Netflix風を意識して、他のセルと違うセルクラスを使用しています)
ソースコード
今回作成したサンプルのソースは以下のリポジトリにあります。
https://github.com/ddd503/CompositionalLayouts-Sample