Compositional Layoutsで複雑なUIのCollectionViewを実装する

WWDC2019で発表されたCompositional Layoutsは、どうやら複雑なレイアウトのUICollectionViewを簡単に実現することができるらしいという事で、調査と実装をしてみました。
今回は、Compositional Layoutsを利用して、インスタグラムやWearで使われているようなUIを実現。割とサクッと実現できました。

サンプルコードはこちらに載せています。

f:id:muchan611:20191103070948p:plain:w300

Compositional Layoutsざっくり説明

そもそもCompositional Layoutsとはどういうものなのかを簡単にまとめます。(※WWDC2019の動画やデモコードを調べた上での、私なりの理解なので間違っていたらご指摘いただきたいです🙏)

まず、Compositional LayoutsはItem、Group、Section の3つのComponentで構成されているようです。

f:id:muchan611:20191103070932p:plain

そして、それらは階層構造になっており、図にすると以下のような感じ

f:id:muchan611:20191103070914p:plain

  • Item
    • レイアウトを構成する一番小さな部品で、Cellやヘッダー、フッターになるもの
  • Group
    • Itemを一つにまとめて構成するレイアウトの基本単位
  • Section
    • Groupをまとめて、それらに対してInsetsなどを追加できる
  • Layout

実際にこのLayoutをCollectionViewに適用するコードは簡単で、UICollectionViewの初期化時に生成したレイアウトを渡すだけです。
こんな感じ👇

var collectionView: UICollectionView! = nil

override func viewDidLoad() {
    super.viewDidLoad()
    collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createLayout())
    ...
}

func createLayout() -> UICollectionViewLayout {
  //ここでUICollectionViewCompositionalLayoutを作成し返却
  let layout = UICollectionViewCompositionalLayout {
            (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
      //各レイアウトの定義
  }
  return layout
}

複雑なUIを実現する

上記を踏まえて、サンプルUIの実現方法について説明します。 今回の構成ですが、3つのItemを持つGroupを、4つ組み合わせて1つのSectionにしています。

図で説明すると以下のような構成です。4つのGroupでSectionを構成しています。

f:id:muchan611:20191103175407p:plain:w300

各Groupの構成は以下の通りです。

TypeA・TypeB

サイズの違うItemを扱うため、Groupの中も2つのGroupで構成するようにしています。 2つの小さなItemで構成されるGroupと、1つのItemで構成されるGroupを1つのGroupにまとめています(言葉にすると分かりにくい...)。

TypeC

こちらは、同じサイズの3つのItemを一つのGroupにまとめています。

実装

コードにするとちょっと長いですが、以下の通りです。
CollectionViewも含む全体のコードは、こちらで公開しています。

func createLayout() -> UICollectionViewLayout {
    let sideInset: CGFloat = 18
    let insideInset: CGFloat = 8
    let topInset: CGFloat = 8
    let viewWidth: CGFloat = view.bounds.width
    let smallSquareWidth: CGFloat = (viewWidth - (sideInset * 2 + insideInset * 2)) / 3
    let mediumSquareWidth: CGFloat = smallSquareWidth * 2 + insideInset
    let nestedGroupHeight: CGFloat = mediumSquareWidth + topInset
    let smallSquareGroupHeight: CGFloat = smallSquareWidth + topInset
    
    
    let layout = UICollectionViewCompositionalLayout {
        (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in

        let nestedGroupTypeA: NSCollectionLayoutGroup = {
            let smallSquareItem = NSCollectionLayoutItem(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                  heightDimension: .absolute(smallSquareWidth + insideInset)))
            smallSquareItem.contentInsets = NSDirectionalEdgeInsets(top: topInset, leading: 0, bottom: 0, trailing: insideInset)
            let smallSquareGroup = NSCollectionLayoutGroup.vertical(
                layoutSize: NSCollectionLayoutSize(widthDimension: .absolute(smallSquareWidth + insideInset),
                                                  heightDimension: .fractionalHeight(1.0)),
                subitem: smallSquareItem, count: 2)

            let mediumSquareItem = NSCollectionLayoutItem(
                layoutSize: NSCollectionLayoutSize(widthDimension: .absolute(mediumSquareWidth),
                                                  heightDimension: .fractionalHeight(1.0)))
            mediumSquareItem.contentInsets = NSDirectionalEdgeInsets(top: topInset, leading: 0, bottom: 0, trailing: 0)

            let nestedGroup = NSCollectionLayoutGroup.horizontal(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                  heightDimension: .absolute(nestedGroupHeight)),
                subitems: [smallSquareGroup, mediumSquareItem])
            return nestedGroup
        }()
        
        let nestedGroupTypeB: NSCollectionLayoutGroup = {
            let mediumSquareItem = NSCollectionLayoutItem(
                layoutSize: NSCollectionLayoutSize(widthDimension: .absolute(mediumSquareWidth + insideInset),
                                                  heightDimension: .fractionalHeight(1.0)))
            mediumSquareItem.contentInsets = NSDirectionalEdgeInsets(top: topInset, leading: 0, bottom: 0, trailing: insideInset)

            let smallSquareItem = NSCollectionLayoutItem(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                  heightDimension: .absolute(smallSquareWidth + insideInset)))
            smallSquareItem.contentInsets = NSDirectionalEdgeInsets(top: topInset, leading: 0, bottom: 0, trailing: 0)
            let smallSquareGroup = NSCollectionLayoutGroup.vertical(
                layoutSize: NSCollectionLayoutSize(widthDimension: .absolute(smallSquareWidth),
                                                  heightDimension: .fractionalHeight(1.0)),
                subitem: smallSquareItem, count: 2)

            let nestedGroup = NSCollectionLayoutGroup.horizontal(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                  heightDimension: .absolute(nestedGroupHeight)),
                subitems: [mediumSquareItem, smallSquareGroup])
            return nestedGroup
        }()
        
        let nestedGroupTypeC: NSCollectionLayoutGroup = {
            let smallSquareItem = NSCollectionLayoutItem(
                layoutSize: NSCollectionLayoutSize(widthDimension: .absolute(smallSquareWidth),
                                                  heightDimension: .fractionalHeight(1.0)))

            let smallSquareGroup = NSCollectionLayoutGroup.horizontal(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                  heightDimension: .absolute(smallSquareGroupHeight)),
                subitem: smallSquareItem,
                count: 3)
            smallSquareGroup.interItemSpacing = .fixed(insideInset)
            smallSquareGroup.contentInsets = NSDirectionalEdgeInsets(top: topInset, leading: 0, bottom: 0, trailing: 0)
            
            return smallSquareGroup
        }()
        
        let group = NSCollectionLayoutGroup.vertical(
        layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                           heightDimension: .absolute(nestedGroupHeight * 2 + smallSquareGroupHeight * 2)),
        subitems: [nestedGroupTypeA, nestedGroupTypeC, nestedGroupTypeB, nestedGroupTypeC])
        
        let section = NSCollectionLayoutSection(group: group)
        section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: sideInset, bottom: 0, trailing: sideInset)
        return section

    }
    return layout
}

実装は複雑に見えますが、考え方はシンプルです。 複数のItemをGroupにまとめて小さなパーツを作り、そのGroupを更に複数まとめてひとつのSectionを作る事で、複雑なUIを実現しています。

各ItemやGroupのサイズ指定方法については、ひとつ上のComponentの高さや幅に対する割合を指定する方法や、絶対値を指定する方法などがあります。 詳しくはドキュメントに書かれています。

今回は、左右のマージンやitem間のスペースを固定したため、絶対値で指定する箇所が多いですが、UIに合わせて指定方法を決めると良いかと思います。

ちなみに、UICollectionViewの初期化時に createLayout() を渡していますが、collectionView の invalidatelayout() が呼ばれると createLayout() も呼ばれるので、画面回転時には今まで通りinvalidatelayout()を呼んであげれば再計算してくれます。便利!👏

もっと知りたいという方

WWDC2019の動画で、デモコードの解説をしています。動画と見比べながらデモコードをいじってみると、結構分かりやすくておすすめです!

WWDC2019動画(Advances in Collection View Layout)
https://developer.apple.com/videos/play/wwdc2019/215

デモコード
https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/using_collection_view_compositional_layouts_and_diffable_data_sources

便利なCompositional Layouts、積極的に活用していきたい!🎉