iOS
Swift

Decoration Viewを用いてCollection Viewに区切り線と背景を追加する

Collection Viewは通常、アイテム間に区切り線が表示されません。
アイテム間に区切り線を表示するには、

  • 各アイテムCellに、区切り線を表示するビューを持たせる
  • Decoration Viewを使う

前者は比較的実現しやすい方法ですが、Cellに本来のコンテンツ以外の要素を含めなければならず、端の要素に対して区切り線の表示制御が必要になるでしょう。

後者の方法は原則としてUICollectionViewLayoutのサブクラスを実装する必要があります。
理解が難しく解説もあまり多くされていなかった(特に日本語で)ので、この実装手順について解説してみたいと思います。

IMG_6236.PNG

この記事のサンプルコードは、以下のプロジェクトに含まれていますので参考にしてください。
(アプリ起動後、View タブの Grid を選択すると該当画面が表示されます)

https://github.com/imk2o/UICatalog

UICollectionViewLayout に登場する「要素」の種類

まずUICollectionViewLayoutには、以下の要素が登場することを理解しておくとよいと思います。

  • UICollectionElementCategory.cell: セル
  • UICollectionElementCategory.supplementaryView: ヘッダやフッタなどの補助的なビュー
  • UICollectionElementCategory.decorationView: 区切り線や背景などの装飾ビュー

表示する情報を変えたり、UIを提供する場合は.supplementaryViewを、
決まったデザインを表示するだけであれば.decorationViewを用いるとよいと思います。
(.supplementaryViewUICollectionViewDataSourceでコントロールできるが、.decorationViewはほとんどコントロールできない)

今回は区切り線を表示したいだけなので、.decorationViewを利用します。

UICollectionViewFlowLayout を継承して実装する

UICollectionViewLayout のサブクラスを実装するのはなかなか容易ではありません。
そこで UICollectionViewFlowLayout を継承したクラスを定義し、必要な実装だけを追加していくアプローチを取ります。

装飾ビューとして用いるNibを登録する

CollectionViewFlowLayoutWithDecorations.swift
class CollectionViewFlowLayoutWithDecorations: UICollectionViewFlowLayout {
    enum DecorationViewElementKind: String {
        case horizontalSeparator
        case verticalSeparator
        case sectionBackground
        // ...省略...
    }

    // ...省略...

    override func awakeFromNib() {
        let separatorNib = UINib(nibName: "SeparatorDecorationView", bundle: nil)
        self.register(
            separatorNib,
            forDecorationViewOfKind: DecorationViewElementKind.horizontalSeparator.rawValue
        )
        self.register(
            separatorNib,
            forDecorationViewOfKind: DecorationViewElementKind.verticalSeparator.rawValue
        )
        // ...省略...
    }

    // ...省略...
}

要素のレイアウト情報に、区切り線のレイアウト情報を追加するには

まず、以下のメソッドをオーバライドします。
このメソッドは通常、セルおよびセクションヘッダ&フッタのレイアウト情報を返します。
これを参考にして、区切り線のレイアウト情報を追加し、返すようにします。

// rect内にある要素のレイアウト情報を求める
func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]?

もうひとつ、以下のメソッドをオーバライドします。
このメソッドでは、タイプがelementKindの.decorationViewについて、特定のindexPathのレイアウト情報を求める必要があります。サンプルコードでは、セル間にその高さと同じ区切り線を、行間に横断的な区切り線が引かれるよう、レイアウトを求めています。

// 位置indexPathにおける、elementKindタイプの装飾ビューに対するレイアウト情報を求める
func layoutAttributesForDecorationView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?
CollectionViewFlowLayoutWithDecorations.swift
class CollectionViewFlowLayoutWithDecorations: UICollectionViewFlowLayout {
    enum DecorationViewElementKind: String {
        case horizontalSeparator
        case verticalSeparator
        case sectionBackground
        // ...省略...
    }

    var numberOfColumns: Int = 3    // 列数
    var separatorSize: CGSize = CGSize(width: 0.375, height: 0.375)    // 区切り線の太さ(0の場合は表示しない)
    var showsSectionBackground: Bool = true    // セクションごとに背景をつけるか?

    override func awakeFromNib() {
        // ...省略...
    }

    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        guard let layoutAttributesArray = super.layoutAttributesForElements(in: rect) else {
            return nil
        }

        // 各Itemに対応するセパレータのLayoutAttributesを求め、追加する
        var decorationLayoutAttributesArray: [UICollectionViewLayoutAttributes] = []
        var sectionIndexes: Set<Int> = []
        for layoutAttributes in layoutAttributesArray {
            let indexPath = layoutAttributes.indexPath
            decorationLayoutAttributesArray += DecorationViewElementKind.separators.flatMap {
                return self.layoutAttributesForDecorationView(ofKind: $0.rawValue, at: indexPath)
            }
            sectionIndexes.insert(indexPath.section)
        }

        // ...省略...

        return layoutAttributesArray + decorationLayoutAttributesArray
    }

    override func layoutAttributesForDecorationView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        switch DecorationViewElementKind(rawValue: elementKind) {
        case .horizontalSeparator?:
            // 2行目以降の最初の列に対するItemの上部に水平線を引く
            guard
                self.separatorSize.height > 0,
                indexPath.item > 0 && (indexPath.item % self.numberOfColumns == 0),
                let collectionView = self.collectionView,
                let itemLayoutAttributes = self.layoutAttributesForItem(at: indexPath)
            else {
                return nil
            }

            let layoutAttributes = UICollectionViewLayoutAttributes(forDecorationViewOfKind: elementKind, with: indexPath)
            layoutAttributes.frame = CGRect(
                x: self.sectionInset.left,
                y: itemLayoutAttributes.frame.origin.y - ((self.minimumLineSpacing + self.separatorSize.height) / 2).rounded(.down),
                width: collectionView.bounds.width - (self.sectionInset.left + self.sectionInset.right),
                height: self.separatorSize.height
            )

            return layoutAttributes
        case .verticalSeparator?:
            // 最初の行以外に対するItemの左部に垂直な線を引く
            guard
                self.separatorSize.width > 0,
                (indexPath.item % self.numberOfColumns) > 0,
                let itemLayoutAttributes = self.layoutAttributesForItem(at: indexPath)
            else {
                return nil
            }

            let layoutAttributes = UICollectionViewLayoutAttributes(forDecorationViewOfKind: elementKind, with: indexPath)
            layoutAttributes.frame = CGRect(
                x: itemLayoutAttributes.frame.origin.x - ((self.minimumInteritemSpacing + self.separatorSize.width) / 2).rounded(.down),
                y: itemLayoutAttributes.frame.origin.y,
                width: self.separatorSize.width,
                height: itemLayoutAttributes.frame.size.height
            )

            return layoutAttributes
        case .sectionBackground?:
            // ...省略...
        default:
            return nil
        }
    }

    // ...省略...
}

CollectionViewにInsert, Deleteする場合

Collection ViewでInsert, Delete操作を行う場合は以下のメソッドも適宜実装が必要です。
(サンプルコードでは実装していません)

func indexPathsToInsertForDecorationView(ofKind elementKind: String) -> [IndexPath]
func indexPathsToDeleteForDecorationView(ofKind elementKind: String) -> [IndexPath]

実装したカスタムレイアウトを利用する

StoryboardからCollection Viewの下にあるCollection View Flow Layoutを選択し、
Custom Classをカスタムレイアウトを実装したクラス名に変更します。
仕切り線の太さや列の数は適宜調整してください。

これで区切り線が表示されるはずです。

まとめ

  • UICollectionViewFlowLayoutを継承したクラスを実装することで、比較的簡単かつ確実に、区切り線を表示させることができる
  • .decorationViewはあらかじめregisterNib()したものを表示するだけなので、色やデザインを変える柔軟性を持たせたい場合、更なる工夫が必要

おまけ

サンプルコードにはUICollectionViewFlowLayoutをカスタマイズして、セクション毎に背景ビューを敷く方法も実装しています。