【Swift】iOS13のCollectionView、CompositionalLayoutsでモダンにレイアウトする

2020-08-01

おはようございます!しょーです!

最近は社内ペアプロをよくやっており、問題解決に非常に役立っています。
一人で悩む時間が減るし気づきが多くて非常に有意義な時間になっている。続けていこう。

今日はCollectionViewの話をしようと思います。
iOS13より使用できる、Composinal Layoutsというプロパティの説明になります。

それでは行ってみよう。

なぜCollectionView?

今までリストを作成するときはUITableViewをしようしてリストを作成していました。

ですがAppleがUICollectionViewを今後推奨していくとのことで、もしかしたら近い未来にTableViewがなくなってしまう未来がきてしまうかもしれません。
なので、今まで逃げてきたCollectionViewを勉強して、今後は採用して行こうと思ったのです。

基本的なCollectionViewの使い方

基本的な使い方はUITableViewと一緒ですが、今までのCollectionViewを使用するとなると

  • UICollectionViewDelegate
  • UICollectionViewDataSource
  • UICollectionViewDelegateFlowLayout

の三つの構成が必要になります。

UICollectionViewDelegate

セルが選択された場合のデリゲートメソッドなど

UICollectionViewDataSource

cellForItemAtなどセルの生成を行うメソッドなど

UICollectionViewDelegateFlowLayout

CollectionViewのレイアウトを決めるメソッドなど

TableViewの場合はdelegateとdatasourceの二つでメインの構成を行っていましたが、これにFlowLayoutというレイアウトを決めるメソッドが必要になってきます。

これにより、単一画面に複数のCollectionViewを配置したい場合、それぞれのCollectionViewを用意しなければいけないし、それぞれにtagを追加してユニークに扱わなければいけないので複数対応が困難でした。

ここでこのCollectionViewの複雑性を改善できるのが、iOS13より使用できるCompositionalLayoutsというプロパティです。

なぜ Compositional Layouts?

CompositionalLayoutsは上記の構造を

  • Layout
  • Section
  • Group
  • Item

と分けて構成することで、より簡潔に記述することができます。
構成イメージは以下のような感じ。

複数のCollectionViewを単一画面に実装したい場合、従来の実装方法ではそれぞれのViewに対してCollectionViewを用意してあげて、Sectionを切ったり、tagを付与してユニークにしたりして対応する必要がありました。
ですがCompositionalLayoutsをしようすると、セクションによってそれぞれのCollectionViewの実装を分けられるので、一つのCollectionViewオブジェクトで完結することができます。

他のメリットとしては、Itemサイズの記述を画面に対しての比率で記述することができるので、self-sizingなItemを作成できるのも魅力です。

実装方法は思ったより簡単なので、実際に上記のViewを実装していこう!

CompositionalLayoutsを実装する

CollectionViewを用意する

インスタンスを作成しましょう。

class HomeViewController: UIViewController {

    // MARK:- UI Parts
    private var collectionView: UICollectionView! = nil

    // MARK:- Properties
    private lazy var dataSource: UICollectionViewDiffableDataSource<Section, Applince>! = nil

    override func viewDidLoad() {
        super.viewDidLoad()

        initViews()
        configureDataSource() // 次パートで作成するメソッド
    }

    private func initViews() {
        view.backgroundColor = .white

        // ここでインスタンスを代入、collectionViewLayoutにはこれから作成するCompositionalLayoutのメソッドを入れておく
        collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: generateLayout())
        guard let collectionView = collectionView else { return }

        view.addSubview(collectionView)
        collectionView.backgroundColor = .white
        collectionView.register(...)
    }
}

lazy var で作成してもいいかと思いますが、今回は後からオブジェクトを代入します。
collectionViewLayoutにいれるgenerateLayout()というレイアウト生成メソッドの規模が大きいからです、、

layout部分の作成を行う

レイアウトの作成を行います。従来までしようしていたFlowLayoutやUICollectionViewDatasourceは必要ありません。
(Delegateはセルタップの実装などで必要になってきますが)

// 1
private enum Section: CaseIterable {
    case applianceHorizontal
    case article
}


// use for above ios 13
extension HomeViewController {
    // 2
    private func generateLayout() -> UICollectionViewLayout {
        let layout = UICollectionViewCompositionalLayout { (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
            let sectionLayoutKind = Section.allCases[sectionIndex]
            switch sectionLayoutKind {
            case .applianceHorizontal: return self.generateApplianceHorizontalLayout()
            case .article: return self.generateArticleLayout()
            }
        }
        return layout
    }

    // 3
    private func generateApplianceHorizontalLayout() -> NSCollectionLayoutSection {
        // appliance item
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(2/3))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)

        // group
        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(2/3))
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 1)

        // secction
        let section = NSCollectionLayoutSection(group: group)
        section.orthogonalScrollingBehavior = .paging

        return section
    }

    // 4
    private func generateArticleLayout() -> NSCollectionLayoutSection {
        // full article
        let fullArticleItemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1/4))
        let fullArticleItem = NSCollectionLayoutItem(layoutSize: fullArticleItemSize)

        // two pair
        let pairArticleItemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1/2), heightDimension: .fractionalHeight(1))
        let pairArticleItem = NSCollectionLayoutItem(layoutSize: pairArticleItemSize)
        let pairGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1/4))
        let pairGroup = NSCollectionLayoutGroup.horizontal(layoutSize: pairGroupSize, subitem: pairArticleItem, count: 2)

        // combine full + pair by vertical
        let nestedGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))
        let nestedGroup = NSCollectionLayoutGroup.vertical(layoutSize: nestedGroupSize, subitems: [fullArticleItem, pairGroup])

        // section
        let section = NSCollectionLayoutSection(group: nestedGroup)

        return section
    }

    // 5
    private func configureDataSource() {
        dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collcetionView, indexPath, appliance in
            let sectionType = Section.allCases[indexPath.section]
            switch sectionType {
            case .applianceHorizontal:
                let cell = self.collectionView.dequeueReusableCell(for: indexPath, as: ApplianceHolizontalCell.self)
                cell.configure()
                return cell
            case .article:
                let cell = self.collectionView.dequeueReusableCell(for: indexPath, as: ArticleCell.self)
                return cell
            }

        }

        let snapshot = snapshotForCurrentState()
        dataSource.apply(snapshot, animatingDifferences: false)
    }

    # 6
    private func snapshotForCurrentState() -> NSDiffableDataSourceSnapshot<Section, Applince> {
        var snapshot = NSDiffableDataSourceSnapshot<Section, Appiance>()
        // Appliance section
        snapshot.appendSections([Section.applianceHorizontal])
        snapshot.appendItems(appliancesDummy)

        // Article section
        snapshot.appendSections([Section.article])
        snapshot.appendItems(appliancesDummy2)

        return snapshot
    }
}

1

enumでsectionを切り分けます。ここで切ったセクションが上記イメージでグリーンになっているSectionです。
この数だけ、CollectionViewのセクションを作成できるとイメージしてください。
今回は二つのSectionをLayoutに含めたいので適当に二つ作成します。

  • Appliance ( 上部のCollectionView, 横スクロール )
  • Article ( 横2ペア、1つ横いっぱい、横2ペア、1つ横いっぱい、、、、の繰り返し )

というレイアウトを作成する想定です。

2

先ほど作成したcollectionViewLayoutに入れていたメソッドです。
ここで、1で作成したenumごとにLayoutを入れていくことにより、各Sectionのレイアウトを作成します。

3

各セクションのレイアウトをそれぞれのメソッド内で作成します。
特徴的なのは、それぞれのサイズ指定方法です。
固定サイズをいれる書き方もありますが、fractionalWidth、fractionalHeightを使用する事により、画面サイズに対しての比率でサイズを決めることができます。(0=0%, 1=100%)

let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(2/3))

今回のアイテムサイズは

  • 横幅は1で画面一杯
  • 縦幅は画面に対して2/3

の大きさを指定しています。

groupのレイアウトも、このパートはitemを同じサイズなので同様の比率を指定しています。

そしたら作成したgroupeSizeとitemを用いてgroupを作成するのですが、このパートの条件は

  • 横スクロール
  • groupに対して一列

なのでhorizontalを使用し、count:1を指定してあげます。

 let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 1)

そしたらsectionとしてgroupをsectionに追加してあげましょう。

let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .paging

横スクロールの場合、ページングしたい場合は上記のプロパティでページング設定を行うことができます。

4

基本的には上記3と書き方は一緒ですが、groupやitemのコンビネーションが増えるので少々書き方が異なります。

この場合、

  • item2つが横並びのgroup
  • item1つが横いっぱいのgroup

    の二つのグループがあり、さらにそれらが縦に連なっているセクションになります。

そのようなレイアウトを実装したい場合は、各group事にレイアウトを作成し、最後に縦にネストしてあげる事により実現します。

let nestedGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))
let nestedGroup = NSCollectionLayoutGroup.vertical(layoutSize: nestedGroupSize, subitems: [fullArticleItem, pairGroup])

(下にいくほど動的にitemが増える場合はsubitemsの構成を変えなければいけないのかな??)

5

Datasource、もといCellForRowAtの部分はUICollectionViewDiffableDataSource を使用して作成する事になります。
最初に作成したenum Section事にセルを作成しましょう。

そしたら最後にsnapshotという全体のlayoutを作成するメソッドを呼び出し、dataSourceに当てはめてあげます。

let snapshot = snapshotForCurrentState()
dataSource.apply(snapshot, animatingDifferences: false) 

6

 var snapshot = NSDiffableDataSourceSnapshot<Section, Applince>()

でsnapshotというインスタンスを作成し、ここに作成したいsectionの数だけ値を追加していく形になります。

 // Appliance section
  snapshot.appendSections([Section.applianceHorizontal])
  snapshot.appendItems(appliancesDummy)

  // Article section
  snapshot.appendSections([Section.article])
  snapshot.appendItems(appliancesDummy2)

  return snapshot

.appendItemsには配列で作成したい分だけのデータを入れましょう。
nuberOfとか指定しなくていいから楽ですね!自動でやってくれる!

ではサンプルの構造体、配列を適当に作っていきます。

### データの作成

データの作成で気をつけなければいけないところがあります。
データはHashableに紐づくように作成してください。

 struct Applince: Hashable {
    let id: Int
    let image: UIImage = R.image.im_article_sample()!

    static func == (lhs: Applince, rhs: Applince) -> Bool {
      return lhs.id == rhs.id
    }
}

     private let appliancesDummy: [Applince] = [
        Applince(id: 0),
        Applince(id: 1),
        Applince(id: 2),
        Applince(id: 3),
        Applince(id: 4),
        Applince(id: 5)
    ]
    private let appliancesDummy2: [Applince] = [
        Applince(id: 10),
        Applince(id: 11),
        Applince(id: 12),
        Applince(id: 13),
        Applince(id: 14),
        Applince(id: 15)
    ]

CompositinalLayoutsのデータの取り扱いはhashで行っています。
APIでfetchしてくるデータは大体IDがついていたりすると思いますが、ダミーを使用してサンプルを作成する場合、hashに適合しているid等が一緒の場合、snapshotでセクションを作成するときに前のセクションが上書きされてしまいうまく表示されません。
なので要素はそれぞれ、hashをユニークにするようにしてください。
(私がここでハマりました。ペアプロ してくださったMさんありがとうございます!)

## ビルド

これで以下のように表示されるかと思います。

## 終わりに

どうでしたでしょうか。思ったより記述量が少なくなった上に、size指定もきれいになると思いませんか?
TableViewがオワコンらしいのでサボっていたCollectionViewの勉強を再度始めたんですが大きな収穫があってよかったです。
ただiOS 13以上でしか採用できないので、iOS12を担保している場合はavailableなどで切り分ける必要がありますね。

iOS12以下でCompositionalLayouts-likeに記述ができるライブラリがあるのでついでに紹介します。

https://github.com/kishikawakatsumi/IBPCollectionViewCompositionalLayout

これで記述すれば、少々冗長してしまいますが、同様に記述できますし、バージョンを切る際にバッサリ削れるので楽かなーと思います。

こんな感じですねー!
もし良ければ参考にして実装してみてください!

ではお疲れっす!
じゃーな!