LoginSignup
7
9

More than 3 years have passed since last update.

写真アプリ風レイアウトをUICollectionViewCompositionalLayoutで爆速開発(約150行の実装)

Last updated at Posted at 2020-02-06

はじめに

本記事ではiOS13から利用可能になった UICollectionViewCompositionalLayout を利用して、インスタグラムの検索画面のようなパネルレイアウトを作成する方法を記載します。

UICollectionViewCompositionalLayout は、複雑なレイアウトを簡単で宣言的な記述で実現できるレイアウトクラスで、今回のデモに関しては約150行足らずですべての実装を完了することができました。

実際のAppの挙動は下記にgif動画を添付しています。

成果物
image

サンプルコード
https://github.com/chocoyama/InstaLikeLayout

ViewControllerの実装は↓だけです。(約35行)
https://github.com/chocoyama/InstaLikeLayout/blob/master/InstaLikeLayout/ViewController.swift

UICollectionViewCompositionalLayoutとは

まずは簡単にこのクラスの説明をしておきます。
これは、iOS13で提供されたレイアウトのクラスで、以下の特徴を持ちます。

  • 宣言型タイプのAPIでレイアウトを組むことができる
  • 小さなレイアウトをつなぎ合わせていく形でレイアウトを組んでいく
  • サブクラス化は必要ない
  • レイアウトが複雑になっても、それに比例してコードの量が増えない
  • セクションごとにレイアウトを変更するといったことができる

関連クラス

関連クラスとしては以下のものがあり、それぞれがレイアウトされるViewを抽象化しています。
これらを組み合わせながら具体的なサイズや位置を決めていきます。

  • NSCollectionLayoutSection
    • セクションひとつ分を表すもので、NSCollectionLayoutGroupを渡して初期化する
  • NSCollectionLayoutGroup
    • セルをグルーピングして細かくレイアウトを制御するための集合
  • NSCollectionLayoutItem
    • レイアウトの最小単位でセル1つ分にあたるもの

詳細な実装は後述します。

サイズの指定

サイズ指定は上記の関連クラスに対して NSCollectionLayoutSize を受け渡して指定します。
これはGroupやItemのサイズを指定するもので以下のような形で指定します。

NSCollectionLayoutItem(
    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(2/3),
                                       heightDimension: .fractionalHeight(1.0))
)

さらに、引数に受け渡す NSCollectionLayoutDimension は以下のような種類があり、実現したいレイアウトによってそれぞれ使い分けます。

open class NSCollectionLayoutDimension : NSObject, NSCopying {
    // 親のwidthとの相対値
    open class func fractionalWidth(_ fractionalWidth: CGFloat) -> Self
    // 親のheightとの相対値
    open class func fractionalHeight(_ fractionalHeight: CGFloat) -> Self
    // 絶対値指定
    open class func absolute(_ absoluteDimension: CGFloat) -> Self
    // 推定サイズを指定して、自動計算させる
    open class func estimated(_ estimatedDimension: CGFloat) -> Self
    // ...

これらを指定することで親からの相対値や絶対値指定などでセルのサイズを決定していきます。
ルートのGroupはCollectionViewを基準としてサイズが決定され、子Groupは親Groupを基準に、Itemは属しているGroupを基準にサイズが決定されていきます。

実装

ここからは今回作成するレイアウトの実装の説明に入ります。
全体的な方針としては以下のような感じになります。

  • セクションごとに柔軟にレイアウトを変えられるCompositionalLayoutの特徴を活用し、3パターンのレイアウトパターンをセクションごとに適用していく
    1. 左側のセルが大きく、右側には2x1の小さいセルが表示されるレイアウト
    2. 右側のセルが大きく、左側には2x1の小さいセルが表示されるレイアウト
    3. 全て同じサイズのセルが2x3で敷き詰められているレイアウト
  • セクションごとにレイアウトを決めるので、扱いやすいようにデータもセクションごとにまとめて持っておく
    • これをCompositionalLayoutに与えてレイアウトさせる

レイアウトパターンの定義

まず、CompositionalLayoutを作るにあたって、レイアウトを大きく3パターンに分けました。

  1. 左側が大きいアイテムで、右側2行に小さいアイテムを配置したレイアウト(leadingLarge)
  2. 右側が大きいアイテムで、左側2行に小さいアイテムを配置したレイアウト (trailingLarge)
  3. 全て同じサイズのアイテムで2x3のパネルにアイテムを配置したレイアウト(spread)

これらのレイアウトパターンに応じて、それぞれを1セクションとして扱っていくことにします。
これをコードで定義すると以下のようになります。

enum Kind: Int, CaseIterable {
    // ※ spreadが2つに分かれているのは、後述する処理で、この定義順でレイアウトを作っていくためです
    case leadingLarge, spread1, trailingLarge, spread2

    // 各レイアウトパターンが表示するアイテムの件数を返却します
    var numberOfItemsInSection: Int {
        switch self {
        case .leadingLarge, .trailingLarge: return 3
        case .spread1, .spread2: return 6
        }
    }
}

Sectionのモデルを定義

次に、作成するレイアウトがデータを扱いやすくするために、レイアウト用のモデル(Section)を作成します。
各セクションに表示するセル1つ分にあたるモデル(Item)は画面によって変わるため、Genericsで抽象化しておきます。
Sectionモデルでは、このItemの配列とレイアウトパターン(Kind)を保持します。

また、合わせてItemの配列をSectionの形式に変換するbuild関数も定義しました。
変換ロジックはprotocolを用いてDI可能にし、要件によって柔軟に表示をコントロールできるようにしています。
(今回のサンプルではKindで定義されているレイアウトの順番で順々に表示していくだけです。)

struct Section<Item: Hashable>: Hashable {
    let id = UUID()
    let kind: Kind
    let items: [Item]

    static func build(_ items: [Item], with strategy: LayoutStrategy) -> [Section<Item>] {
        strategy.buildSections(for: items)
    }
}

protocol LayoutStrategy {
    func buildSections<Item>(for items: [Item]) -> [Layout.Section<Item>]
}

Itemの配列をSectionの配列に変換

ここで、実際にSectionを作っていく実装を行います。
内容について詳細は書きませんが、Kindのenumで定義されている順番で、それぞれのレイアウト用の件数分Itemを取り出してSectionにしています。

/// Layout.Kindで定義されている1セクションに表示するセルの数ごとに分割していく
struct RegularOrderLayoutStrategy: LayoutStrategy {
    func buildSections<Item>(for items: [Item]) -> [Layout.Section<Item>] {
        var sections = [Layout.Section<Item>]()

        var kind: Layout.Kind = .leadingLarge
        var tmpItems: [Item] = []
        for item in items {
            if tmpItems.count == kind.numberOfItemsInSection {
                sections.append(.init(kind: kind, items: tmpItems))
                kind = next(from: kind)
                tmpItems = []
            }
            tmpItems.append(item)
        }
        sections.append(.init(kind: kind, items: tmpItems))

        return sections
    }

    private func next(from kind: Layout.Kind) -> Layout.Kind {
        // 定義されている次の値を返却する
        Layout.Kind(rawValue: kind.rawValue + 1 == Layout.Kind.allCases.count ? 0 : kind.rawValue + 1)!
    }
}

Layoutの作成

これで準備が整いました。
あとは、UICollectionViewCompositionalLayoutを作成して実際のレイアウトの記述をしていくだけです。
順番に見ていきます。

UICollectionViewCompositionalLayoutの初期化

まずは、最初に UICollectionViewCompositionalLayout のイニシャライザを呼び出します。
該当のクラスはいくつかイニシャライザが提供されていますが、今回利用しているのは下記のもので、セクションごとに異なるレイアウトを設定できます。
UICollectionViewCompositionalLayoutSectionProvider からセクションのインデックスを取得できるので、これを用いて分岐を行います。

public typealias UICollectionViewCompositionalLayoutSectionProvider = (Int, NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection?
open class UICollectionViewCompositionalLayout : UICollectionViewLayout {
    // ...    
    public init(sectionProvider: @escaping UICollectionViewCompositionalLayoutSectionProvider)
    // ...
}

レイアウトの構造

今回は大きく分けて3パターンのレイアウトがあるので、それぞれを構造化すると以下のようになります。
具体的にどのようにサイズを指定してるかは後述している実装コードに記載があります。

  • leadingLarge
    • NSCollectionLayoutSection
      • NSCollectionLayoutGroup
        • NSCollectionLayoutItem(左側の大きなセル、幅は)
        • NSCollectionLayoutGroup
          • NSCollectionLayoutItem(右側の小さなセル)
          • NSCollectionLayoutItem(右側の小さなセル)
  • spread
    • NSCollectionLayoutSection
      • NSCollectionLayoutGroup
        • NSCollectionLayoutGroup
          • NSCollectionLayoutItem(左側の小さなセル)
          • NSCollectionLayoutItem(左側の小さなセル)
        • NSCollectionLayoutGroup
          • NSCollectionLayoutItem(中央の小さなセル)
          • NSCollectionLayoutItem(中央の小さなセル)
        • NSCollectionLayoutGroup
          • NSCollectionLayoutItem(右側の小さなセル)
          • NSCollectionLayoutItem(右側の小さなセル)
  • trailingLarge
    • NSCollectionLayoutSection
      • NSCollectionLayoutGroup
        • NSCollectionLayoutGroup
          • NSCollectionLayoutItem(左側の小さなセル)
          • NSCollectionLayoutItem(左側の小さなセル)
        • NSCollectionLayoutItem(右側の大きなセル)

(汚いですが)これを図にすると以下のようになります。

image

実装コード

上記に示した構造は下記のようなコードで宣言されます。

static func build<Item>(for sections: [Section<Item>]) -> UICollectionViewCompositionalLayout {
    UICollectionViewCompositionalLayout { (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
        let largeItem = NSCollectionLayoutItem(
            layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(2/3), // 1. 幅:親Groupの幅(CollectionViewの幅)の2/3
                                               heightDimension: .fractionalHeight(1.0)) // 2. 高さ:親Groupの高さ(CollectionViewの高さの4/10)に合わせる
        )

        let smallGroup = NSCollectionLayoutGroup.vertical(
            layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1/3), // 3. 幅:CollectionViewの幅の1/3
                                               heightDimension: .fractionalHeight(1.0)), // 4. 高さ:親Groupの高さ(CollectionViewの高さの4/10)に合わせる
            subitem: NSCollectionLayoutItem(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), // 5. 幅:親Groupの幅(CollectionViewの幅の1/3)に合わせる
                                                   heightDimension: .fractionalHeight(1.0)) // 6. 高さ: 親Groupの高さ(CollectionViewの高さの4/10)をcountで割った値にする
            ),
            count: 2 // 2件分表示されるように自動計算させる
        )

        let nestedGroup = NSCollectionLayoutGroup.horizontal(
            layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), // 7. CollectionViewの幅に合わせる
                                               heightDimension: .fractionalHeight(4/10)), // 8. CollectionViewの高さの4/10
            subitems: {
                switch sections[sectionIndex].kind {
                case .leadingLarge: return [largeItem, smallGroup]
                case .spread1, .spread2: return [smallGroup, smallGroup, smallGroup]
                case .trailingLarge: return [smallGroup, largeItem]
                }
            }()
        )
        return NSCollectionLayoutSection(group: nestedGroup)
    }
}

非常にシンプルで宣言的な形で、かつ少ない行数でレイアウトの記述ができていることがわかると思います。

(また汚いですが、一応図との対応も書きました)
image

ViewControllerでの適合

以上でレイアウトの作成は完了したので、最後に作成したレイアウトをCollectionViewにセットすれば終了です。

let sections: [Layout.Section<UIColor>] = {
    let colors = (0..<1000).map { _ in
        UIColor(red: (CGFloat(arc4random_uniform(255)) + 1) / 255,
                green: (CGFloat(arc4random_uniform(255)) + 1) / 255,
                blue: (CGFloat(arc4random_uniform(255)) + 1) / 255,
                alpha: 1.0)
    }
    return Layout.Section.build(colors, with: RegularOrderLayoutStrategy())
}()

collectionView.setCollectionViewLayout(Layout.build(for: sections), animated: false)

最後に

長々と書いてしまいましたが、全体を見るととてもシンプルな実装で複雑なレイアウトが組めていることがわかると思うので、下記にサンプルコードも載せています。

CompositionalLayoutを使うと、AppStoreのようなレイアウトも簡単に作れるので、iOS13以降をサポートするアプリは積極的に使っていきましょう〜 :thumbsup:

7
9
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
7
9