LINEヤフー Tech Blog

LINEヤフー株式会社のサービスを支える、技術・開発文化を発信しています。

【Code Review Challenge】解説4問目:UICollectionView実装のベストプラクティス #try! Swift Tokyo 2024

こんにちは。iOSアプリエンジニアのOokaです。

先日開催されたtry! Swift Tokyo 2024のLINEヤフー企業ブースでは、Code Review Challengeを行いました。Code Review Challengeとは、Bad CodeをGood Codeにするための公開コードレビューです。参加者に技術への関心を持ってもらうこと、外部の方々からのレビューにより社員の学びにつなげることを目的に、過去のイベントで行っていた取り組みを今回のイベントでも実施しました。

本記事では、今回出題したCode Review Challengeの4問目の解説をします。

出題コード

import UIKit

class CustomCell: UICollectionViewCell {
    var checkmarkView: UIImageView!
    var isMarked: Bool = false {
        didSet {
            if isMarked {
                checkmarkView.frame = CGRectMake(-7,0,35,25)
                self.contentView.addSubview(self.checkmarkView!)
            } else {
                self.checkmarkView?.removeFromSuperview()
            }
        }
    }
    func clearCheckmark() -> Void {
        self.isMarked = false
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.checkmarkView = UIImageView(image: .checkmark)
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

class MyViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource {
    var myCollectionView : UICollectionView!
    override func viewDidLoad() {
        super.viewDidLoad()
        let layout = UICollectionViewFlowLayout()
        layout.itemSize = CGSize(width:50, height:50)
        layout.sectionInset = UIEdgeInsets(top: 16, left: 16, bottom: 32, right: 16)
        layout.headerReferenceSize = CGSize(width:100,height:30)
        myCollectionView = UICollectionView(frame: self.view.frame, collectionViewLayout: layout)
        myCollectionView.register(CustomCell.self, forCellWithReuseIdentifier: "CustomCell")
        myCollectionView.delegate = self
        myCollectionView.dataSource = self
        self.view.addSubview(myCollectionView)
    }

    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        let cell = collectionView.cellForItem(at: indexPath) as! CustomCell
        cell.isMarked = !cell.isMarked
    }

    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return 100
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell : UICollectionViewCell = collectionView.dequeueReusableCell(withReuseIdentifier: "CustomCell",
                                                                             for: indexPath as IndexPath) as! CustomCell
        cell.backgroundColor = UIColor.orange
        return cell
    }
}

これは、ネット上でUICollectionViewのサンプルとしてよく使われているコードです。オレンジ色のセルがグリッド上に配置されており、各セルをタップするとチェックマークが表示されるUIです。

たしかにiOS6時代のAPIしか使っておらず、最初に習う方法なので初心者向きな書き方と言えますが、iOS14以上対応のアプリが大半の今となってはコードの安全性や保守性が低い書き方となっています。

このコードには多くの改善点がありますが、その中でもしてしまいがちな実装ミスと、UICollectionView実装のベストプラクティスという観点で分けて解説します。

  • 実装ミス
    • セルのReuseが考慮されていない
  • UICollectionView実装のベストプラクティス
    • UIContentConfiguration & UIContentViewの利用
    • UICollectionView.CellRegistrationの利用
    • UICollectionViewDiffableDataSourceの利用
    • UICollectionViewCompositionalLayoutの利用

1. セルのReuseが考慮されていない

このコードでは、isMarkedがdidSelectItemAtでしか更新されていません。このような場合、スクロールなどでセルがReuseされたとき、そのセルのisMarkedの値が別のセルに入ってしまうことがあります。実際に挙動をシミュレータで確認すると、以下のようにチェックマークのついている場所がスクロールによって変わってしまっていることがわかります。

これを回避するためには、各セルのisMarkedの値を格納した配列(Set)を用意するか、後述するUICollectionViewDiffableDataSourceを用いる必要があります。ここでは前者の方法を紹介します。

private var markedIndices = Set<Int>()

まず、ViewController内にisMarked = trueとなったセルのindexを格納するSetを定義します。この状況ではindexに一意性がある(被らない)ため、より高速に要素の参照・追加・削除ができるSetの方がArrayよりも適しています。

そして、didSelectItemAtのdelegateメソッドでこのmarkedIndicesを変更し、セルを直接変更することはしません。データの変更をしたあとはreloadData()を忘れずに行いましょう。

func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
    if markedIndices.contains(indexPath.row) {
        markedIndices.remove(indexPath.row)
    }
    else {
        markedIndices.insert(indexPath.row)
    }
    collectionView.reloadData()
}

最後に、cellForItemAtのdelegateメソッドでmarkedIndicesを参照してisMarkedをセットします。

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "CustomCell", for: indexPath) as! CustomCell
    cell.isMarked = markedIndices.contains(indexPath.row)
    return cell
}

これにより、セルがReuseされても正しくチェックマークが表示されるようになりました。

2. UICollectionView実装のベストプラクティス

UIContentConfiguration & UIContentViewの利用

UIContentConfiguration, UIContentViewはstructをベースにViewのコンテンツとスタイルを更新できる方法で、iOS14で導入されました。新しいConfigurationをViewにセットすると以前のセルの状態を引き継がないという利点があります。また、ここで定義したUIContentConfiguration, UIContentViewはUITableViewでもそのまま利用できるという利点もあります。それではカスタムセルの実装をUIContentConfigurationとUIContentViewを利用して書き直してみましょう。

まずはいったんUIContentViewのことは考えずにUIViewを書きます。

final class CustomView: UIView {
    private let checkMarkView = UIImageView(image: .checkmark)

    init() {
        super.init(frame: .zero)

        setUpCheckMarkView()
        backgroundColor = .systemOrange
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func setUpCheckMarkView() {
        checkMarkView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(checkMarkView)
        NSLayoutConstraint.activate([
            checkMarkView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: -7),
            checkMarkView.topAnchor.constraint(equalTo: topAnchor),
            checkMarkView.widthAnchor.constraint(equalToConstant: 35),
            checkMarkView.heightAnchor.constraint(equalToConstant: 25)
        ])
    }
}

そして、CustomViewにUIContentViewを継承させ、必要なプロパティであるconfigurationをinitで渡すようにします。

final class CustomView: UIView, UIContentView {
    ...
    var configuration: any UIContentConfiguration

    init(configuration: some UIContentConfiguration) {
        self.configuration = configuration
        super.init(frame: .zero)
        ...
    }
    ...
}

次に、UIContentConfigurationを継承したCustomConfigurationでViewを描画する際に必要な状態変数isMarkedを定義し、必要なメソッドを実装します。

struct CustomConfiguration: UIContentConfiguration {
    var isMarked = false

    func makeContentView() -> any UIView & UIContentView {
        CustomView(configuration: self)
    }

    func updated(for state: any UIConfigurationState) -> CustomConfiguration {
        self
    }
}

そして、セルがisSelectedになったタイミングでisMarkedを更新するロジックをupdated(for:)のメソッドに追加します。UIConfigurationStateをUICellConfigurationStateにキャストする必要があります。

func updated(for state: any UIConfigurationState) -> CustomConfiguration {
    guard let state = state as? UICellConfigurationState else { return self }
    var configuration = self
    configuration.isMarked = state.isSelected
    return configuration
}

そしてCustomViewの方に戻り、configurationが更新されたときの処理を追加します。ここも同様にUIContentConfigurationをCustomConfigurationにキャストする必要があります。

var configuration: any UIContentConfiguration {
    didSet {
        guard let configuration = configuration as? CustomConfiguration else { return }
        checkMarkView.isHidden = !configuration.isMarked
    }
}

以上のコードで、UIContentConfiguration & UIContentViewの定義が完了しました。

UICollectionView.CellRegistrationの利用

UICollectionView.CellRegistrationは、型安全を保ったままセルをUICollectionViewに登録できる方法で、iOS14で導入されました。

let cellRegistration = UICollectionView.CellRegistration<UICollectionViewCell, Int> { cell, indexPath, itemIdentifier in
    cell.contentConfiguration = CustomConfiguration()
}

これにより、UICollectionViewCellを登録できます。そのcontentConfigurationに先ほど定義したCustomConfigurationを代入することで、セルの状態を正しく反映できます。

UICollectionViewDiffableDataSourceの利用

UICollectionViewDiffableDataSourceは、UICollectionViewに表示するデータを管理するための方法で、iOS13で導入されました。これを利用するとUICollectionViewで利用するデータを一括管理することになるので、1.のように個別で配列を用意せずともReuseによる意図しない不具合を防げます。

DataSourceを特徴づける型としてSection / Item / Item Identifierがあります。

SectionはCollectionViewのセクションのことで、データの表示形式を変えたりデータをまとまりごとに分離したい場合にその数だけ作ります。今回はフラットなグリッドにするのでmainというSectionを1つだけ作ることにします。

ItemはCollectionViewの1つのセルに対応するデータです。今回は簡単のために0〜99のInt型の値をItemとして用います。Item IdentifierはItemを一意に特定するidのことで、 Diffable DataSource においては非常に重要な役割を果たしますが、今回は簡単のためにItemと同じように扱います。

enum Section {
    case main
}

では、DataSourceを作成するメソッドを書いていきましょう。まず、上のセクションで解説したようにセルの登録を定義すると、セルの読み込みもCellRegistrationを用いて行えます。

private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Int> {
    let cellRegistration = ...
    return UICollectionViewDiffableDataSource<Section, Int>(
        collectionView: collectionView
    ) { collectionView, indexPath, item in
        collectionView.dequeueConfiguredReusableCell(
            using: cellRegistration,
            for: indexPath,
            item: item
        )
    }
}

最後に、DataSourceにデータを入れるコードを書きます。まず空のsnapshotを作り、そこに.mainのセクションとその中に0〜99のItem Identifierを追加し、それをDataSourceに適用します。

private func setUpDataSource() async {
    var snapshot = NSDiffableDataSourceSnapshot<Section, Int>()
    snapshot.appendSections([.main])
    snapshot.appendItems(Array(0..<100), toSection: .main)
    await dataSource.apply(snapshot, animatingDifferences: true)
}

UICollectionViewCompositionalLayoutの利用

UICollectionViewCompositionalLayoutは、UICollectionViewのレイアウトを設定するための方法の一つで、iOS13で導入されました。これを利用することで、今までUICollectionViewの中にUICollectionViewを入れて実装する必要のあった複雑なレイアウト(例: Sectionごとにスクロール方向を変えるなど)を、一つのUICollectionViewで実現できるようになりました。

今回はセルを横に並べるので、NSCollectionLayoutGroup.horizontalを指定します。そして、subitemsに各セルのサイズである縦幅に.absolute(50)、横幅にも.absolute(50)を指定します。最後に、layoutSizeは横を画面いっぱいの.fractionalWidth(1), heightを.absolute(50)にした上で、interItemSpacingやinterGroupSpacing、contentInsetsを適切に設定することで元のコードと同じ表示になります。

let compositionalLayout = UICollectionViewCompositionalLayout { index, environment in
    let group = NSCollectionLayoutGroup.horizontal(
        layoutSize: .init(
            widthDimension: .fractionalWidth(1),
            heightDimension: .absolute(50)
        ),
        subitems: [.init(layoutSize: .init(
            widthDimension: .absolute(50),
            heightDimension: .absolute(50)
        ))]
    )
    group.interItemSpacing = .some(.fixed(10))
    let section = NSCollectionLayoutSection(group: group)
    section.contentInsets = .init(top: 16, leading: 16, bottom: 32, trailing: 16)
    section.interGroupSpacing = 10
    return section
}

今回はグリッドレイアウトを紹介しましたが、より複雑なレイアウト方法については公式ドキュメントをご参照ください。

3. その他改善点

コーディングスタイル

  • 継承されないclassにはfinalをつける
  • force unwrappingをしない
  • 不要ないくつかのself / return
  • コロンの前にスペースを入れない、後にはいれる
  • clearCheckmark()は使われていないので削除するべき
  • isMarked = !isMarkedはisMarked.toggle()の方がわかりやすい

パフォーマンス・UX改善

  • チェックマークをつけ外しするとき、ViewをaddSubview/removeFromSuperviewするのではなくisHiddenを変更すべき
  • UIColor.orangeではなくUIColor.systemOrangeを使うことでダークモードなどに対応すべき

想定解答

上記の改善点をすべて適用した想定解答は以下です。

import UIKit

struct CustomConfiguration: UIContentConfiguration {
    var isMarked = false

    func makeContentView() -> any UIView & UIContentView {
        CustomView(configuration: self)
    }

    func updated(for state: any UIConfigurationState) -> CustomConfiguration {
        guard let state = state as? UICellConfigurationState else { return self }
        var configuration = self
        configuration.isMarked = state.isSelected
        return configuration
    }
}

final class CustomView: UIView, UIContentView {
    private let checkMarkView = UIImageView(image: .checkmark)

    var configuration: any UIContentConfiguration {
        didSet {
            guard let configuration = configuration as? CustomConfiguration else { return }
            checkMarkView.isHidden = !configuration.isMarked
        }
    }

    init(configuration: some UIContentConfiguration) {
        self.configuration = configuration
        super.init(frame: .zero)

        setUpCheckMarkView()
        backgroundColor = .systemOrange
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func setUpCheckMarkView() {
        checkMarkView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(checkMarkView)
        NSLayoutConstraint.activate([
            checkMarkView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: -7),
            checkMarkView.topAnchor.constraint(equalTo: topAnchor),
            checkMarkView.widthAnchor.constraint(equalToConstant: 35),
            checkMarkView.heightAnchor.constraint(equalToConstant: 25)
        ])
    }
}

final class ViewController: UIViewController {
    enum Section {
        case main
    }

    private lazy var collectionView = createCollectionView()
    private lazy var dataSource = createDataSource()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground
        setUpCollectionView()
        Task { await setUpDataSource() }
    }

    private func setUpCollectionView() {
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(collectionView)
        NSLayoutConstraint.activate([
            collectionView.topAnchor.constraint(equalTo: view.topAnchor),
            collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
        ])
    }

    private func setUpDataSource() async {
        var snapshot = NSDiffableDataSourceSnapshot<Section, Int>()
        snapshot.appendSections([.main])
        snapshot.appendItems(Array(0..<100), toSection: .main)
        await dataSource.apply(snapshot, animatingDifferences: true)
    }

    private func createCollectionView() -> UICollectionView {
        let collectionView = UICollectionView(
            frame: view.bounds,
            collectionViewLayout: UICollectionViewCompositionalLayout { index, environment in
                let group = NSCollectionLayoutGroup.horizontal(
                    layoutSize: .init(
                        widthDimension: .fractionalWidth(1),
                        heightDimension: .absolute(50)
                    ),
                    subitems: [.init(layoutSize: .init(
                        widthDimension: .absolute(50),
                        heightDimension: .absolute(50)
                    ))]
                )
                group.interItemSpacing = .some(.fixed(10))
                let section = NSCollectionLayoutSection(group: group)
                section.contentInsets = .init(top: 16, leading: 16, bottom: 32, trailing: 16)
                section.interGroupSpacing = 10
                return section
            }
        )
        collectionView.allowsMultipleSelection = true
        return collectionView
    }

    private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Int> {
        let cellRegistration = UICollectionView.CellRegistration<
            UICollectionViewCell, Int
        > { cell, indexPath, itemIdentifier in
            cell.contentConfiguration = CustomConfiguration()
        }
        return UICollectionViewDiffableDataSource<Section, Int>(
            collectionView: collectionView
        ) { collectionView, indexPath, item in
            collectionView.dequeueConfiguredReusableCell(
                using: cellRegistration,
                for: indexPath,
                item: item
            )
        }
    }
}

まとめ

この記事では、Code Review Challengeの4問目の解説として、主に以下の点について説明しました。

  • セルのReuseに気をつけて実装をする必要がある
  • コードの可読性・安全性・保守性の観点でも、UICollectionViewに関する新しいAPIを利用して実装することが推奨される

私のプロジェクトでもUICollectionViewを利用していることもあり、このようなCode Review Challengeの問題を作成しました。UICollectionViewに関するAPIはここ数年で大きく変わり、すべてをまとめて解説している記事はあまり多くないので、この記事が誰かの助けになれば幸いです。

また、これらの技術は年々変わってゆくものでもあるので、エンジニアとしてその時点でのベストプラクティスを常に追い続けることが重要だと日々感じています。