LoginSignup
17
12

More than 3 years have passed since last update.

まだ、CollectionViewで疲弊してるの?iOS12から始めるCompositionalLayout~実装から設計まで~

Last updated at Posted at 2021-01-31

どうもこんにちは、忘年会をなかなか開催できず今年は悲しいTOSHです。
本日はZOZOテクノロジーズアドベントカレンダー17日目を担当させてもらいます!

はじめに

さて、iOSエンジニアなら誰でも、CollectionViewを使っていますよね。
ただ、皆さん薄々感づいているように、アプリのUIは依然と比べて、ますます複雑なものになってきています。

以下例

AbemaTV RakutenNBA UberEats AppleStore
IMG_9967.PNG IMG_9966.PNG IMG_9965.jpg IMG_9964.PNG

エンジニアからしたら、これらを実装するのに、まず、全体をTableViewでおいて、そのCellのなかにCollectionViewをおいて、その中でHorizontalになるように全体をLayoutって...難しいですよね。
そして何より、どんどん中身をネストしていくっていうのはやっぱり大変。
Apple様も薄々そんなことには気づいており、WWDC19では、CompositionalLayoutを新しく発表しました。
しかし、、、対応しているのはiOS13以降。業務だとiOS12を切るという選択肢はなかなか難しく、結局、力技で実装することになる。
そんな皆様に朗報です!弊社技術顧問の岸川さんが、iOS12でもCompositionalLayoutを使用できる、ライブラリを作成してくれていました!
https://github.com/kishikawakatsumi/IBPCollectionViewCompositionalLayout
ということで、この記事では、実際に運用する上で、どのような設計で作成するとうまく使用しやすいのかを紹介していきたいと思います〜

ちなみに、AppStoreでも使用されているバナーを中央で止める方法はCookPadさんが紹介をしていますが、まあ大変。。。

前提

- CollectionViewを普段から使用している人
- 最適な設計を探している人
- CompositionalLayoutに初めて挑戦する人

CompositionaLayoutの基本概念

詳しい内容については、こちらを参考にしてください。
https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/layouts

*Appleのホームページより引用

特にカスタムItemにUIcollectionViewCellを乗せていくイメージでいいと思います。

実装方法

では、実際に実装をしていきましょう。

各セクションの設定

まずは、セクションのProtocolを作成します。

Section.swift
protocol Section {
    // 各セクションにおくアイテムの数
    var numberOfItems: Int { get }
    // 各セクションのアイテムがタップされた際の処理
    // クロージャーで設定をしておくと、VC側から処理を追加できる
    var didSelectItem: ((Int) -> Void)? { get }
    // ここで、実際にレイアウトを組んでいきます
    func layoutSection() -> NSCollectionLayoutSection
    // Itemとして使用するCellの設定はここで行います。
    func configureCell(collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionViewCell
}

VC側での初期設定

VC側では、先ほど作成した、Sectionの配列を使用するイメージです。

ViewController.swift
import UIKit

final class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        collectionView.delegate = self
        collectionView.dataSource = self
    }

    @IBOutlet weak var collectionView: UICollectionView!

    // Sectionのレイアウトをここでセットできる形にする
    private var collectionViewLayout: UICollectionViewLayout {
        let sections = self.sections
        let layout = UICollectionViewCompositionalLayout { (sectionIndex, environment) -> NSCollectionLayoutSection? in
            return sections[sectionIndex].layoutSection()
        }
        return layout
    }

    private var sections: [Section] = [] {
        didSet {
            // sectionsが更新されたらレイアウトも更新する
            collectionView.collectionViewLayout = collectionViewLayout
            collectionView.reloadData()
        }
    }
}

// dataSourceの設定
extension ViewController: UICollectionViewDataSource {
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return sections.count
    }

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

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

// delegateの設定
extension ViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        guard let didSelectItem = sections[indexPath.section].didSelectItem else {
            return
        }
        didSelectItem(indexPath.row)
    }
}

実際にSectionを作成する

今回、Item用のcellは予め作成しておいた前提で進めます。

ItemsSection.swift
// Sectionを継承します
struct ItemsSection: Section {
    var didSelectItem: ((Int) -> Void)?

    private var items: [Items] = []
    var numberOfItems: Int {
        self.items.count
    }

    func layoutSection() -> NSCollectionLayoutSection {

        // Itemについてのレイアウト設定
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)

        // groupについてのレイアウト設定
        let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(UIScreen.main.bounds.width - 40), heightDimension: .absolute(184))
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

        // Sectionについてのレイアウト設定
        let section = NSCollectionLayoutSection(group: group)
        section.interGroupSpacing = 10
        section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)
        // ここでスクロールストップするのか、しないのかの設定を行う
        section.orthogonalScrollingBehavior = .groupPaging

        return section
    }

    func configureCell(collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ItemCollectionViewCell.self), for: indexPath) as! ItemCollectionViewCell
       // ここでCellの設定を行う

        return cell
    }
}

extension BannerSection {
    // Initializerが必要な場合は、Extensionに切り出すと良いでしょう
    init() {
    }
}

各セクションごとに、セクションのStructを一つ作成すると良いでしょう。

実際に、VC側に追加する

それでは先ほど作成した、Sectionを追加していきましょう。

ViewController.swift
func viewDidLoad() {
    ~~省略~~

    collectionView.register(ItemCollectionViewCell.self,
                            forCellWithReuseIdentifier: String(describing: ItemCollectionViewCell.self))

    var itemsSection = ItemsSection()
    itemsSection.didSelectItem = { index in
        // ここで、cell選択時の処理を行う
    }
    sections.append(itemsSection)
}

これで、横にスクロースしながら、中央で止まるCollectionViewを作成することができました。

これらをうまく使用すると下記画像のようなレイアウトも作成することができます!(すみません、時間がなくて図でのイメージになります汗)

[]の中の数字は、[Section番号、 row番号]になります。
これの大事なメリットとしては、TableViewのなかにCollectionViewのようなネストをすることなく、すべて一つのCollectionViewの上で管理することができるというのが大きなメリットかなと思います!
複雑なレイアウトでも、管理しやすい形で、設計を行うことができます!

おまけ

各SectionにHeader,Footerを付けたい!という人も多いかと思います。
Header,Footerを作成する方法は大きく分けて二通りあります。

  1. Sectionに対して、Header,Footerを設定する
  2. Sectionの上下にHeader, FooterとなるSectionを追加する。

これらの方法にはメリットデメリットがあると思いますが、Header, Footerに対して、複雑なタッチイベントを追加したい場合は2の方法がよく、特にタッチイベントを使用しないまたは、タップによってアコーディオンのような処理のみしか行わない場合であれば、1の方法の方が楽なのかなと思います。

1の方法の実装方法

先ほどのSectionに対して、もう一つメソッドを追加します。

Section.swift
Protocol Section {
    ~~省略~~

    // HeaderやFooterを使用しない場合は、UICollectionReusableView()を返す
    func configureHeaderFooter(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView
}

次にItemsSectionにも新しく追加したメソッドを足しましょう!

ItemsSection.swift
struct ItemsSection: Section {
    func layoutSection() -> NSCollectionLayoutSection {
           ~~省略~~
            // header
            let headerSize = NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1.0),
                heightDimension: .estimated(95))
            let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(
                layoutSize: headerSize,
                elementKind: "section-header-element-kind",
                alignment: .top)

            // footer
            let footerSize = NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1.0),
                heightDimension: .estimated(45))
            let sectionFooter = NSCollectionLayoutBoundarySupplementaryItem(
                layoutSize: footerSize,
                elementKind: "section-footer-element-kind",
                alignment: .bottom)

            // header, footerを追加
            section.boundarySupplementaryItems = [sectionHeader, sectionFooter]
          ~~省略~~
    }

    func configureHeaderFooter(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
        // ここでの文字列は固定
        switch kind {
        // header
        case "section-header-element-kind":
            let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: String(describing: ItemHeaderCell.self), for: indexPath) as! ItemHeaderCell
            // Headerのセットアップ
            return header
        // footer
        case "section-footer-element-kind":
            let footer = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: String(describing: ItemFooterCell.self), for: indexPath) as! ItemFooterCell
        // Footerのセットアップ
        return footer
        default:
            // HeaderやFooterを設定しない場合には、UICollectionReusableView()を返す
            return UICollectionReusableView()
        }
    }
}

最後に、VC側

ViewController.swift
final class ViewController: UIViewController {
    override func viewDidLoad() {
    ~~省略~~
        collectionView.register(itemHeaderCell.self, forSupplementaryViewOfKind: "section-header-element-kind", withReuseIdentifier: String(describing: itemHeaderCell.self))
        collectionView.register(itemFooterCell.self, forSupplementaryViewOfKind: "section-footer-element-kind", withReuseIdentifier: String(describing: itemFooterCell.self))
    ~~省略~~
    }
}

extension ViewController: UIViewControllerDataSource {
    func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
        let view = sections[indexPath.section].configureHeaderFooter(collectionView, viewForSupplementaryElementOfKind: kind, at: indexPath)
        // viewにタップジェスチャを追加したい場合にはここで行う
        return view
    }
}

まとめ

こんな感じで、ViewControllerとSectionに分けて実装するとなかなかいい設計になるのではないでしょうか?
先ほどのべたライブラリを使用するとiOS12からCompositionalLayoutが使用できるので、ぜひ少しずつ使用し始めてください〜
ちなみに、iOS12のサポート終了後でも容易に移行できます。

今回はDataSourceは通常のものを使用しましたが、CompositionalLayoutと同時に、DiffrableDataSourceも登場しているので、こちらと組み合わせるとまた別の設計も組めるのではないでしょうか?
DiffrableDataSourceについてはまた今度記事にしようと思います!
それではっ!

17
12
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
17
12