34
21

More than 3 years have passed since last update.

【Swift】UICollectionViewCompositionalLayoutを使って数種類のカスタムレイアウトを実装してみる

Last updated at Posted at 2020-01-19

はじめに

WWDC2019で新たに発表された、UICollectionViewのレイアウト手法であるUICollectionViewCompositionalLayoutを使っていくつかカスタムレイアウトを作ってみました。
※ iOS13以降の環境にて、今回作成したサンプルを動かせます

UICollectionViewCompositionalLayoutとは

詳細については、WWDCのセッションを参考にして頂ければと思いますが、ざっくり言うと、

  • iOS13からUICollectionViewCompositionalLayoutが登場したことで、UICollectionViewFlowLayoutUICollectionViewDelegateFlowLayoutに加えて、UICollectionViewのレイアウトを定義する方法が1つ増えた。
  • iOS13からUICollectionViewDiffableDataSourceが登場したことで、UICollectionViewDataSourceを準拠して行なっていたDataSource管理の方法が増えた。(こちらについては本記事では解説していません)

※ UICollectionViewDataSourceのみでDataSource管理を行なった場合でも、UICollectionViewCompositionalLayoutは使用できます。

※ より詳細な情報については、こちらの記事がかなり詳しくまとめてくださっています。
時代の変化に応じて進化するCollectionView ~Compositional LayoutsとDiffable Data Sources~

階層構造

UICollectionViewCompositionalLayoutにおける階層構造は、Item、Group、Sectionなどの概念から成り立っています。

スクリーンショット 2020-01-19 20.38.31.png

実装するレイアウト

今回はUICollectionViewCompositionalLayoutを使用して、3種類のレイアウトを実装してみました。
スクリーンショット 2020-01-19 20.43.22.png

1. グリッド形式(3×n)

スクリーンショット 2020-01-19 20.58.28.png

このレイアウトは、赤枠で囲んだ部分のグループを用意し、×nの形式で表示させる属性を持たせたセクションを用意します。

SectionType.swift
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に割り当てます。

ViewController.swift
collectionView.collectionViewLayout = 用意したUICollectionViewCompositionalLayout
// 既に構成されているレイアウトを更新する場合は、invalidateLayout()を呼びます。
collectionView.collectionViewLayout.invalidateLayout()

CompositionalLayout-Grid.gif

2. 異なるサイズのItem併用 (Instagram風)

スクリーンショット 2020-01-19 20.58.41.png

このレイアウトは、緑枠で囲った各グループを用意して、それらを結合した1つのセクションを用意します。

SectionType.swift
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のレイアウトに割り当てます。

CompositionalLayout-Instagram.gif

3. 複数Section併用 (Netflix風)

スクリーンショット 2020-01-19 20.58.56.png

Appleが公式で公開しているサンプルをみた感じ、「ヘッダーを付与する」や「itemを横スクロールさせる」といった実装は、セクション(NSCollectionLayoutSection)を対象に振る舞いを指定する方法が一般的なようなので、上記レイアウトを実装する場合は、「複数のセクションを用意して実行時に渡ってくるindexPath.sectionの値に合わせて適切なセクションを返す」という流れで実装してみました。

公式サンプル
https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/using_collection_view_compositional_layouts_and_diffable_data_sources

SectionType.swift
/// 縦長の長方形が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時に以下のイニシャライザを使います。

UICollectionViewCompositionalLayout.h

 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風を意識して、他のセルと違うセルクラスを使用しています)

CompositionalLayout-Netflix.gif

ソースコード

今回作成したサンプルのソースは以下のリポジトリにあります。
https://github.com/ddd503/CompositionalLayouts-Sample

34
21
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
34
21