UICollectionViewCompositionalLayoutで正確なItem Spacingを確保する

以前 [potatotips #67](https://potatotips.connpass.com/event/152899/) で[こんな内容](https://speakerdeck.com/imk2o/uicollectionviewcompositionallayoutdekireinagrid-layoutwotukuru)を発表しました。
その際「Itemの大きさが正確なGrid Layout」にする方法を紹介したのですが、「Item Spacingを正確にするには？」という課題の解決法を紹介したいと思います。

## Item Sizeを揃えることとItem Spacingを揃えることは両立しない

Collection ViewのGrid Layoutは

* Collection View(Content Size)の幅
* 分割数
* 要素の間隔(Item Spacing)

によって各要素の `frame` が決まりますが、

* 要素のItem Sizeを揃えること
* 要素間のItem Spacingを揃えること

は両立しない場合があります。
Item Sizeが「端数のない大きさ」でないと、スクリーン座標系にマップしたときに丸められ、要素のサイズないし要素の間隔に視覚的な「ずれ」が生じるためです。
**デザイン要件としてどちらが重要なのかを確認し、それに準じた最適化を選択する**ことになります。

## `NSCollectionLayoutGroup.custom()` による正確なItem Spacing

`NSCollectionLayoutGroup.horizontal()` では、上記のような端数が生じるケースがあるため、正確なItem Spacingとならないことがあります。
そこで `NSCollectionLayoutGroup.custom()` を利用し、要素の `frame` を独自計算する方法を採ります。

```NSCollectionLayoutSection+Extension.swift
extension NSCollectionLayoutSection {
    /// Item Spacingが正確なレイアウトを生成する
    /// - Parameters:
    ///   - count: 列数
    ///   - height: セルの高さ
    ///   - spacing: セルの間隔
    static func column(count: Int, height: CGFloat, spacing: CGFloat) -> Self {
        let group = NSCollectionLayoutGroup.custom(
            layoutSize: .init(
                widthDimension: .fractionalWidth(1.0),
                heightDimension: .estimated(height)
            )
        ) { layoutEnvironment in
            let contentSize = layoutEnvironment.container.contentSize

            let fCount = CGFloat(count)
            // Item Spacingを除く、要素が入る領域幅の和(TODO: Content Inset考慮)
            let fillWidth = count == 1 ? contentSize.width : contentSize.width - (spacing * (fCount - 1))
            // 要素の大きさは端数を切り捨て
            let baseItemWidth = (fillWidth / fCount).rounded(.down)
            // 端数切り捨てによって生じる余り
            let residue = Int(fillWidth - (baseItemWidth * fCount))
            
            return (0..<count).reduce(into: [NSCollectionLayoutGroupCustomItem]()) { items, n in
                // x, widthは常に整数
                // 正確なItem Spacingを確保するため、「余り」は要素幅に加える
                let x = n == 0 ? 0 : items[n - 1].frame.maxX + spacing
                let width = baseItemWidth + (n < residue ? 1 : 0)
                
                items.append(NSCollectionLayoutGroupCustomItem(frame: .init(
                    x: x,
                    y: 0,
                    width: width,
                    height: contentSize.height
                )))
            }
        }

        let section = self.init(group: group)
        section.interGroupSpacing = spacing

        return section
    }
}
```

> `custom()` メソッドを用いる場合 `interItemSpacing` プロパティは無視されます。

## 比較

### `.horizontal()` でレイアウトした場合

各要素の位置と大きさに端数があるため、見かけ上のItem Spacingにバラつきがあります。

![default.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25727/4a4a64cd-0187-a8a2-a0d0-d63489a728ce.jpeg)

### `.custom()` でレイアウトした場合

各要素の位置と大きさに端数はなく、Item Spacingが均一です。
但し要素の大きさは帳尻合わせのため、すべて同じにならない場合があります。

![spacing.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25727/7d7175e5-0e01-8630-7272-504917f21bd5.jpeg)

## その他気になったこと

`.custom()` でレイアウトを組む際はセクションのContent Insetも考慮すべきですが、 `NSCollectionLayoutEnvironment#contentInset` は常に `0` です...不具合なのかどうか分かりませんが、Content Insetがあるレイアウトの場合は注意が必要ですね。