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

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

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

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

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

<img width="50%" alt="IMG_6236.PNG" src="https://qiita-image-store.s3.amazonaws.com/0/25727/039cd370-5972-c648-541d-5fc35d3054d3.png">

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

https://github.com/imk2o/UICatalog

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

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

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

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

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

## `UICollectionViewFlowLayout` を継承して実装する

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

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

```swift: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
        )
        // ...省略...
    }

    // ...省略...
}
```
### 要素のレイアウト情報に、区切り線のレイアウト情報を追加するには

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

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

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

```swift
// 位置indexPathにおける、elementKindタイプの装飾ビューに対するレイアウト情報を求める
func layoutAttributesForDecorationView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?
```

```swift: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操作を行う場合は以下のメソッドも適宜実装が必要です。
(サンプルコードでは実装していません)

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

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

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

<img width="80%" src="https://qiita-image-store.s3.amazonaws.com/0/25727/aa630709-9653-1c59-c80a-2e47ec7bc4b0.png">

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

## まとめ

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

## おまけ

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