Pinterest風のレイアウトをUICollectionViewCompositionalLayoutを使って実装する

11 min read読了の目安(約10000字

概要

  • UICollectionViewDiffableDataSourceUICollectionViewCompositionalLayoutを使ったモダンなUICollectionViewを使ってPinterest風のレイアウトを実装してみました
  • 本記事では下記の画面の実装を行います

-w760

所感まとめ

  • UICollectionViewCompositionalLayoutでは.fractionalWidth .fractionalHeightなど比率を使うことができ、抽象的に表現で扱いやすいと思いました
    • カスタムのUICollectionViewLayoutを定義するUICollectionView Custom Layout Tutorial: Pinterestと比較すると…
    • 記事の通りかなりゴリゴリに計算する必要があったのですが、それと比較するとかなり分かりやすく書けるようになります
  • またNSCollectionLayoutItemNSCollectionLayoutGroupを使ってグループ単位でレイアウトを考えればいいので、話がシンプルになるのが良かったです
    • 個人的な感覚としてはStackViewのようだなと思いました

参考

サンプル - GitHub

実装

前提

Model - PinItem.swift

  • Cellに対応するデータクラスです
  • UICollectionViewDiffableDataSourceNSDiffableDataSourceSnapshotで扱うために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別に分ける必要がないので、Sectionenum要素は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()
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をまとめたallColumnsLayoutGroup1.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()
    }
}

Dec-20-2020 02-39-20

まとめ

  • 以上UICollectionViewDiffableDataSourceUICollectionViewCompositionalLayoutを使ったPinterest風のレイアウト実装でした。
  • 比率で考えられたりGroup単位に分けられたりでシンプルに書ける魅力があるので、是非触ってみてください!