🏓
Pinterest風のレイアウトをUICollectionViewCompositionalLayoutを使って実装する
概要
-
UICollectionViewDiffableDataSource
とUICollectionViewCompositionalLayout
を使ったモダンなUICollectionView
を使ってPinterest風のレイアウトを実装してみました - 本記事では下記の画面の実装を行います
所感まとめ
-
UICollectionViewCompositionalLayout
では.fractionalWidth
.fractionalHeight
など比率を使うことができ、抽象的に表現で扱いやすいと思いました- カスタムの
UICollectionViewLayout
を定義するUICollectionView Custom Layout Tutorial: Pinterestと比較すると… - 記事の通りかなりゴリゴリに計算する必要があったのですが、それと比較するとかなり分かりやすく書けるようになります
- カスタムの
- また
NSCollectionLayoutItem
とNSCollectionLayoutGroup
を使ってグループ単位でレイアウトを考えればいいので、話がシンプルになるのが良かったです- 個人的な感覚としては
StackView
のようだなと思いました
- 個人的な感覚としては
参考
-
UICollectionView Tutorial: Getting Started
-
UICollectionView
の基礎の基礎
-
-
UICollectionView Custom Layout Tutorial: Pinterest
- カスタムの
UICollectionViewLayout
を使ったPinterest風レイアウトの実装
- カスタムの
-
iOS Tutorial: Collection View and Diffable Data Source
-
UICollectionViewDiffableDataSource
とNSDiffableDataSourceSnapshot
の説明と実装
-
-
Modern Collection Views with Compositional Layouts
-
UICollectionViewCompositionalLayout
の説明と実装
-
-
時代の変化に応じて進化するCollectionView ~Compositional LayoutsとDiffable Data Sources~
-
WWDC
の発表がまとまっていて嬉しいです - 今回の記事で扱っていない
Section
の実装はこの記事で紹介されているApple公式のサンプルが参考になります
-
サンプル - GitHub
実装
前提
- 参考で紹介した通り、データとレイアウトに関してはそれぞれ以下の記事が詳しいです
Model - PinItem.swift
- Cellに対応するデータクラスです
-
UICollectionViewDiffableDataSource
とNSDiffableDataSourceSnapshot
で扱うためにHashable
への適合が必要で、そのために必要なメソッドを定義します
struct PinItem: Hashable {
// MARK: - Methods for Hashable
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: PinItem, rhs: PinItem) -> Bool {
lhs.id == rhs.id
}
// MARK: - Properties
var id = UUID().uuidString
var serialNumber: Int // 通し番号(レイアウトのために設定)
var itemColor = UIColor.random()
// cellの幅を1.0としたときの相対的なcellの高さ。
// 実際はコンテンツに合わせて計算する
var fractionalHeight = CGFloat.random(in: 0.5...2.0)
}
Controller - PinterestCollectionViewController.swift
- 今回はコンテンツをSection別に分ける必要がないので、
Section
のenum
要素は1つです - また利便性のため
typealias
でいくつか定義しておきます
// MARK: - Definitions
// 今回は1sectionのみ使用する
enum Section {
case main
}
typealias DataSource = UICollectionViewDiffableDataSource<Section, PinItem>
// NSDiffableDataSourceSnapshot: diffable-data-sourceが、
// 表示するセクションとセルの数の情報を参照するためのクラス
typealias Snapshot = NSDiffableDataSourceSnapshot<Section, PinItem>
// MARK: - Properties
private lazy var dataSource = makeDataSource() // lazy: ViewControllerの初期化後に呼ぶ必要があるため
private var pinItems = PinItem.demoPinItems()
-
DataSource(:=UICollectionViewDiffableDataSource)
は以下の通り作成します - ここは従来のcollectionView(_:cellForItemAt:)
と同様ですね
private func makeDataSource() -> DataSource {
let dataSource = DataSource(collectionView: collectionView, cellProvider: { (collectionView, indexPath, pinItem) -> UICollectionViewCell? in
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PinItemCell.reuseIdentifer,
for: indexPath) as? PinItemCell
cell?.pinItem = pinItem
return cell
})
return dataSource
}
- レイアウトの設定は、下記の通り
collectionView.collectionViewLayout
で指定します - 今回はSectionが1つだけなので、Section1つに関してだけのレイアウトを定義すればOKです
collectionView.collectionViewLayout = generateLayout()
private func generateLayout() -> UICollectionViewLayout {
let layoutGroup = generateLayoutGroup(withPinItems: pinItems)
// NSCollectionLayoutSection: セクションを表すクラス
// 最終的に作成したNSCollectionLayoutGroupを適用する
let section = NSCollectionLayoutSection(group: layoutGroup)
// 最終的にレンダリングされる単位になります。
let layout = UICollectionViewCompositionalLayout(section: section)
return layout
}
- それでは
generateLayoutGroup(withPinItems: pinItems)
の詳細を見ていきます - まずはデータの並び替えを行います
- 前提として、アイテムの配置の順番は、数字が若いものがより上に配置されるようにしたいです
- しかしレイアウトのロジックの都合上、左のカラムから下に向かってデータを配置させる必要がでてきます
- よって一旦ごにょっと計算を行い、レイアウトに適するようにデータを並び替えます
private func generateLayoutGroup(withPinItems pinItems: [PinItem]) -> NSCollectionLayoutGroup {
let columns = calculateAndArrangePinItems(pinItems: pinItems)
...
}
- (この辺のロジックは本筋ではないのでさらっといきましょう)
// レイアウト用にPinItemを並び替える
// [<1column目のPinItem>,<2column目のPinItem>,...]
private func calculateAndArrangePinItems(pinItems: [PinItem]) -> [PinItemColumn] {
// Columnの箱を用意
var pinItemColumns = [PinItemColumn]()
for _ in 0..<itemsPerRow {
pinItemColumns.append(PinItemColumn())
}
for pinItem in pinItems {
var minimumHeight = CGFloat.greatestFiniteMagnitude
var minimumColumnIndex = 0
for (column_i, pinItemColumn) in pinItemColumns.enumerated() {
// 現在最も低い高さのColumnを探す
let columnHeight = pinItemColumn.calculateFractionalHeight() // 冗長な計算なのでバッファを持たせても良さそう
if columnHeight < minimumHeight {
minimumHeight = columnHeight
minimumColumnIndex = column_i
}
}
pinItemColumns[minimumColumnIndex].pinItems.append(pinItem)
}
return pinItemColumns
}
- では本題のレイアウトです
- 全体像としては下記のような入れ子になっています
- ちょうど
StackView
の入れ子のようですね
- ちょうど
- ではまずCell単位の
NSCollectionLayoutItem
から話を始めます -
NSCollectionLayoutItem
がデータ、ここではPinItem
に一対一で対応します -
NSCollectionLayoutSize
に関して、ひとつ上のグループ(=後述のcolumnLayoutGroup
)の中でどれだけの割合を占めるかという理解がポイントです
let layoutItem = NSCollectionLayoutItem(
layoutSize: NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .fractionalWidth(pinItem.fractionalHeight)
)
)
- 例えば以下の場合は
columnLayoutGroup
の幅に対して倍率が1.0
、つまり同じ幅であることを示します
widthDimension: .fractionalWidth(1.0)
- また高さに関して、
pinItem.fractionalHeight
に具体的な値を入れて考えてみます。- ここで使うのは
fractionalHeight
ではないので混乱注意です
- ここで使うのは
-
columnLayoutGroup
の幅に対して倍率が2.0
、つまり幅の2倍の高さになるということを意味します
heightDimension: .fractionalWidth(2.0)
- 続いてColumn単位の
NSCollectionLayoutGroup.vertical
を見ていきます -
.vertical
というのは先程作成したNSCollectionLayoutItem
を縦に並べるからですね-
subitems
には先程定義したNSCollectionLayoutItem
の配列を指定しています
-
- 指定する値は先の
NSCollectionLayoutItem
の場合と同様です
let columnLayoutGroup = NSCollectionLayoutGroup.vertical(
layoutSize: NSCollectionLayoutSize(
widthDimension: .fractionalWidth(columnFractionalWidth),
heightDimension: .fractionalWidth(column.calculateFractionalHeight() * columnFractionalWidth)
),
subitems: layoutItems
)
- Columnの幅に関しては、先程と同様にひとつ上のグループ、Columnをまとめた
allColumnsLayoutGroup
を1.0
としたときの幅の倍率を指定します - 今回は均等にカラム数で割るだけOKです
widthDimension: .fractionalWidth(columnFractionalWidth),
private var columnFractionalWidth: CGFloat {
1.0 / CGFloat(itemsPerRow)
}
- Columnの高さに関しては、Cellの
layoutItem
でと同様の計算をしたものを合算すればOKです -
columnFractionalWidth
をかけているのは、column.calculateFractionalHeight()
が返すのがwidth=1.0
に対応する高さのためです
heightDimension: .fractionalWidth(column.calculateFractionalHeight() * columnFractionalWidth)
// columnの幅を1.0としたときのcolumnの高さ
func calculateFractionalHeight() -> CGFloat {
var columnFractionalHeight: CGFloat = .zero
for pinItem in pinItems {
columnFractionalHeight += pinItem.fractionalHeight
}
return columnFractionalHeight
}
- 最後にColumnを水平にまとめた
NSCollectionLayoutGroup.horizontal
を作成します - グループの高さは、Columnの中で最も高いものに合わせます
// Columnをグループにまとめる
// グループ高さはColumnの高さが最大のものを採用する
var maxHeight: CGFloat = .zero
columns.forEach { column in
let height = column.calculateFractionalHeight() * columnFractionalWidth
if maxHeight < height {
maxHeight = height
}
}
let allColumnsLayoutGroup = NSCollectionLayoutGroup.horizontal(
layoutSize: NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .fractionalWidth(maxHeight)
),
subitems: columnLayoutGroups
)
return allColumnsLayoutGroup
- 以上でレイアウトは完成です!
削除処理
- Cellを選択すると削除して再度レイアウトされるようにしてみます
- 自動でつけられるアニメーションが気持ち良いです
- こういったアニメーションがつけられるのも、
UICollectionViewDiffableDataSource
を使う1つの利点だと思います
extension PinterestCollectionViewController {
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
pinItems.remove(at: indexPath.row)
// 一旦通し番号でデータを整列させてから…
pinItems.sort { (firstPinItem, secondPinItem) -> Bool in
firstPinItem.serialNumber < secondPinItem.serialNumber
}
// 再度レイアウトの計算をする
collectionView.collectionViewLayout = generateLayout()
applySnapshot()
}
}
まとめ
- 以上
UICollectionViewDiffableDataSource
とUICollectionViewCompositionalLayout
を使ったPinterest風のレイアウト実装でした。 - 比率で考えられたりGroup単位に分けられたりでシンプルに書ける魅力があるので、是非触ってみてください!
Discussion