[Swift] Compositional Layoutsで実現する疎結合な実装


# はじめに

![TOP IMG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/e7336fe2-8658-f2e0-a471-19cefdd467cf.png)


``Compositional Layouts``がWWDC2019で発表され、ここ数ヶ月でようやく**iOS13以上をターゲット**にしたプロジェクトが増えてきたのではないでしょうか？

``SwiftUI``を取り入れている技術の記事も目立ってきましたが、iOS14にならないと不自由も多く、最初から機能が豊富な``Compositional Layouts``を選択するのも１つの判断かと思います。本記事では実際にプロジェクトに導入してみたので、どのような構成で導入してみたのかをまとめています。

追記：iOSDC2021 のスポンサーセッションでも発表しました。資料はこちら。

https://speakerdeck.com/hcrane/iosdc2021-compositional-layoutsdeshi-xian-surushu-jie-he-nashi-zhuang

# Compositional Layouts の優位性

そもそも、``Compositional Layouts``で組むことは、**何がメリット**なのかというお話をざっくりしておきます。

## 1. UICollectionViewDelegateFlowLayout のデメリット

iOS12 以下で``UICollectionView``を用いて複雑なレイアウトを組む場合、こちらを検討する人が多いでしょう。

``UICollectionViewDelegateFlowLayout``を``ViewController``に継承して、画面の設定を直接書いていきます。

Example

```.swift
final class ExampleViewController: UIViewController, UICollectionViewDelegateFlowLayout {

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        /* セルのサイズ */
    }

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
        /* セルの間隔 */
    }
    .
    .
    .
}
```

この実装にはいくつかの問題があります。その最も大きな問題は、実装する際に``ViewController``と**密結合してしまう**ことです。どうして密結合になってしまうのかというと、それは継承の関連に問題があります。

![Screen Shot 2021-03-01 at 12.01.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/2adb17f9-3643-85d7-4897-2edd317b0e10.png)


``UICollectionView``の実装では``UICollectionViewDelegate``の実装がほとんどの場合で必要になります。``UICollectionViewDelegateFlowLayout``はそれを継承しているため、``ViewController``から**実装が剥がせない**のです。※1

これは**ViewControllerの肥大化**にも繋がりよくありません。仮に``enum``や``struct``で設定を定数化して切り出したり、分岐処理を切り出すことはできても、呼び出し部分はどうしても``ViewController``に残ってしまいます。


※1: ``UICollectionViewDelegate``(とさらに親の``UIScrollViewDelegate``）のAPIを使用しないなら剥がすこともできますが、その場合はそもそも設計段階で``UIScrollView``+``UIStackView``を検討する方が適切な可能性があります。

## 2. UICollectionViewLayout のデメリット

``UICollectionViewLayout``を継承したカスタムクラスを作成する方法もあります。

```.swift
final class ExampleCollectionViewFlowLayout: UICollectionViewFlowLayout {
    
    override func prepare(){
        super.prepare()
        // レイアウトなどの計算
    }  
    
    override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        // IndexPathに応じたCellのAttributesを返す
    }
    .
    .
    .
}
```

１の時とは違い``ViewController``と独立して実装できるため疎結合な作りにすることができます。その反面として、**APIのライフライクルがやや難しい**部分もあり、実装が容易ではないという面をもちます。

実際のところ、**ほとんどのUIは１で事足りてしまう**ため、わざわざこちらで実装するのはオーバースペックなことが多く、疎結合にしたいがためにこちらで実装する、といったことを現場ではあまりしないのが実情でしょう。



## 3. Compositional Layoutsのメリット

ざっくり言えば、上であげた **１と２の良いとこどりできるよ！** ってことになります。

- ①のように、ある程度決まった形で書ける
- ②のように、疎結合にできる

という点を兼ね備えています。

また、UIを組む上でも以前と比べてわかりやすくなったという点もあります。

具体的な説明に関しては、たくさん出回っているので説明はしません。
- [時代の変化に応じて進化するCollectionView ~Compositional LayoutsとDiffable Data Sources~](https://qiita.com/shiz/items/a6032543a237bf2e1d19)

今回は、**疎結合な実装**方法にフォーカスしていきます。

# Compositional Layouts + MVP

今回は実装を考えたプロジェクトが``MVP``をベースとした設計のため、それを基本にコードの記載を行っていきますが、``Clean Architecture``や``VIPER``など、**疎結合が実現可能**なアーキテクチャーであれば、同じような形で実装を行うことができるでしょう。

以下は、実際に運用しているアプリの構成を簡単にまとめたものです。
![arc](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/4ab27870-9bdf-f989-21d1-58b8d29d5671.png)

先に結論の概要から述べてしまうと、上の構成に``Compositional Layouts``を導入するとこのようになります。

![Screen Shot 2021-03-05 at 11.06.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/65e9a6bc-0c1c-6bd6-7a76-91c023a3d6c8.png)

抽象的なプロトコルとして書き出すことで**共通化**し、``ViewController``に依存しないように切り出しています。

## 実装

具体的なコードを見ていきます。

### 1. 通常の実装をする

想像しやすいように、通常の実装からどのように行うかを見ていきます。
以下は、``Compositional Layouts``で複数のレイアウトを組む際の簡単な例です。

```.swift
final class ViewControlle: UIViewController {

    // MARK: Property

    private lazy var collectionView: UICollectionView = {
        let collection = UICollectionView(frame: .zero, collectionViewLayout: compositionalLayout)
        /* ~ 略 ~ */
        return collection
    }()

    private lazy var compositionalLayout: UICollectionViewCompositionalLayout = {
        let layout = UICollectionViewCompositionalLayout { [weak self] (section: Int, environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
            // Section番号でレイアウトの分岐
            switch section {
            case 0: return self?.createLayoutA()
            case 1: return self?.createLayoutB()
            default: fatalError()
            }
        }
        return layout
    }()


    // MARK: Method

    private func createLayoutA() -> UICollectionViewCompositionalLayout {
        /* ~ 略 ~ */
        return UICollectionViewCompositionalLayout(section: section)
    }

    private func createLayoutB() -> UICollectionViewCompositionalLayout {
        /* ~ 略 ~ */
        return UICollectionViewCompositionalLayout(section: section)
    }
}
```

複数のレイアウトを構成したい場合は、レイアウトの数に合わせて、その設定メソッドが増えていきます。``Compositional Layouts``でも、普通に実装した場合は**ViewControllerの肥大化**を招きます。

この肥大化を防ぐために、分離していきます。

### 2. レイアウトの抽象化をする

上記のコードからも分かる通り、``UICollectionViewCompositionalLayout``ではレイアウトを**Section番号で分岐できる**ため、この部分を抽象化して取り出すことで、すっきりとした書き方にすることができます。

具体的には、以下のレイアウトがあった場合、図右側のような抽象化を行います。
![layout](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/77b4370d-a32b-31ef-dda1-1ed95a8b086d.png)



セクションに共通する処理を整理して抽象化していきます。
例として、抽象化するとこのようになります。

```.swift
protocol SectionProtocol {
    // セクションのアイテム数
    var numberOfItems: Int { get }

    // レイアウトの生成
    func layoutSection(_ view: UIView) -> NSCollectionLayoutSection

    // セルの生成
    func configureCell(_ view: UICollectionView, at indexPath: IndexPath) -> UICollectionViewCell

    // セルタップ時（引数は継承元のコントローラ名）
    func selectItem(_ controller: HogeViewController, at indexPath: IndexPath) 
}
```

各人の実装によって抽象化されるものは変わるかと思いますが、上記は大体共通して実装することになるでしょう。これはただ抽象化しただけではなく、**画面を構成するモデル**の役割もはたします。

このプロトコルを各セクションごとに継承し、セクションごとに設定を記載していきます。

```.swift
struct SectionA: SectionProtocol {

    let numberOfItems = 1

    func layoutSection(_ view: UIView) -> NSCollectionLayoutSection {
        /* 略 */
        return UICollectionViewCompositionalLayout(section: section)
    }

    func configureCell(_ view: UICollectionView, at indexPath: IndexPath) -> UICollectionViewCell {
        let cell = view.dequeueReusableCell(withReuseIdentifier: "your cell id", for: indexPath) as! SectionACell
        /* 略 */
        return cell
    }

    func selectItem(_ controller: ViewController, at indexPath: IndexPath) {
        // do some action
    }
}
```

こうすることで、``ViewController``からレイアウト部分を、**別クラスとして分離**することができます。

### 3. ViewControllerから分離する

別クラスとして分離したので、``ViewController``はこのようにすっきりとした形になります。


```.swift
final class ViewController: UIViewController {

    private var sections: [SectionProtocol]

    private lazy var collectionView: UICollectionView = {
        let collection = UICollectionView(frame: .zero, collectionViewLayout: compositionalLayout)
        /* ~ 略 ~ */
        return collection
    }()

    private lazy var compositionalLayout: UICollectionViewLayout = {
        return UICollectionViewCompositionalLayout { [weak self] section, _ in
            return self?.sections[section].layoutSection(self ?? .init()) // force cast でも問題ない
        }
    }()
}
```

また、抽象化した他のプロパティやメソッドは、以下のように呼び出すことができます。


```.swift
extension ViewController: UICollectionViewDataSource, UICollectionViewDelegate {

    func numberOfSections(in collectionView: UICollectionView) -> Int {
        sections.count
    }

    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
       sections[section].numberOfItems
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
       sections[indexPath.section].configureCell(collectionView, at: indexPath)
    }

    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
       sections[indexPath.section].selectItem(self, at: indexPath)
    }
}
```

抽象化されているため、分岐の処理がなく**とても綺麗な実装**になっています。

### 4. MVP構成にする

抽象化したセクションのモデル一覧である

```.swift
private var sections: [SectionProtocol]
```

を``ViewController``から``Presenter``に移行するだけです。

この部分はMVP構成にするだけなので、構成の仕方に関して記載は致しません。後述のリポジトリを見ていただけると幸いです。

構成の例として、階層構造を置いておきます。
![MVP](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/4de8d321-ad36-5ba2-5c4e-4aa239fd4365.png)

### その他

セクション側でアクションの処理を行いたい場合は、``ViewController``から``Presenter``をフックしてあげることで、**単一方向な処理**を実現することができます。

具体例として、セルをタップしたい際の挙動をあげておきます。


```.swift
protocol Presentable: AnyObject {
    var sections: [SectionProtocol] { get }
    func selectItem(at indexPath: IndexPath)
}

final class Presenter: Presentable {

    private var sections: [SectionProtocol]

    func selectItem(at indexPath: IndexPath) {
        // do someting
    }
}
```

```.swift
final class ViewController: UICollectionViewDelegate {

    private(set) var presenter: Presentable!

    /* 略 */

    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        presenter.sections[indexPath.section].selectItem(self, at: indexPath)
    }
}
```

```.swift
struct SectionA: SectionProtocol {

    /* 略 */

    func selectItem(_ controller: ViewController, at indexPath: IndexPath) {
        controller.presenter.selectItem(at: indexPath)
    }
}
```

先ほどの図に照らし合わせると、

![Screen Shot 2021-03-05 at 16.01.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/40eb4ae9-18ac-8c7a-6eb8-cb9c7b740fc8.png)



このような関係になっています。

このように、抽象化して分離することで、``ViewController``の肥大化を防ぎつつ、疎結合な作りを実現することができるのです。

# 終わりに
少しコードが多くなってしまい、わかりづらい部分もあるかもしれません。

動作するリポジトリを置いておくので、こちらからコードを読んでいただけると幸いです。
- [CompositionalLayouts-MVP](https://github.com/crane-hiromu/CompositionalLayouts-MVP)


そちらをみていただくと、より理解を深めることができます。
ご指摘などありましたらコメントいただけると幸いですmm

### 関連

[Compositional Layoutsを使ってラクマのiOSアプリを改修した話](https://commerce-engineer.rakuten.careers/entry/tech/0038)

### その他

https://qiita.com/hcrane/items/92c0e08f9bf7d017f53f