[Swift][iOS]ReadableContentGuideに合わせていい感じにページングするUITableView+UICollectionViewのサンプル


# 概要
基本的なレイアウトはReadable Content Guide(以下RCG)に従いつつ、アプリ内のコンテンツをセクション別にページングを効かせながら横スクロールできるようなインタフェースのサンプルです。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/720855/6eedd02f-07e2-d0af-84bc-d2eee0a79567.gif" width=320>

アプリの全体的なサンプルコードはこちらです。 
https://github.com/idomazine/RCGCollectionView

# 動作環境
- macOS Catalina 10.15.6
- XCode 12.2 
- Swift 5.3.1

# 実装手順
実装するにあたって概ね3つの課題がありますので順に説明します。

- UITableView内にUICollectionViewを保持する
- UICollectionViewのセル配置にRCGを適用する
- UICollectionViewのスクロールにページング補正をかける

## UITableView内にUICollectionViewを保持する
```swift:PaletteTableViewCell.swift

final class PaletteTableViewCell: UITableViewCell {
    private var collectionView: UICollectionView!
    private var layout: UICollectionViewFlowLayout!

    private var colors: [UIColor] = []

    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)

        selectionStyle = .none

        // コレクションビューのレイアウト
        layout = PagingForReadableContentGuideLayout()
        layout.scrollDirection = .horizontal
        layout.itemSize = ColorCollectionViewCell.defaultSize
        layout.minimumLineSpacing = 16

        // コンテンツ表示用コレクションビュー
        collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: layout)
        collectionView.showsHorizontalScrollIndicator = false
        collectionView.backgroundColor = UIColor.clear
        collectionView.decelerationRate = UIScrollView.DecelerationRate.fast
        collectionView.dataSource = self
        collectionView.register(ColorCollectionViewCell.self,
                                forCellWithReuseIdentifier: "ColorCollectionViewCell")
        contentView.addSubview(collectionView)
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            collectionView.topAnchor.constraint(equalTo: contentView.topAnchor),
            collectionView.leftAnchor.constraint(equalTo: contentView.leftAnchor),
            collectionView.rightAnchor.constraint(equalTo: contentView.rightAnchor),
            collectionView.heightAnchor.constraint(equalToConstant: ColorCollectionViewCell.defaultSize.height)
        ])
    }

    override func prepareForReuse() {
        super.prepareForReuse()
        //スクロール位置のリセット
        collectionView.contentOffset = .init(x: -collectionView.contentInset.left,
                                             y: -collectionView.contentInset.top)
    }

    func configure(colors: [UIColor]) {
        self.colors = colors
        collectionView.reloadData()
    }
}

extension PaletteTableViewCell: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        colors.count
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ColorCollectionViewCell",
                                                      for: indexPath) as! ColorCollectionViewCell
        cell.configure(color: colors[indexPath.row])
        return cell
    }
}
```

ところどころ省略していますが、これでUITableView内にUICollectionViewを保持することができます。
そのままだとセルが再利用された際に中途半端なスクロール位置から始まってしまうので`prepareForReuse`でスクロール位置のリセットを行っているのがポイントです。(同じ行の再表示の際にスクロール位置を保存するとより自然に見えますが、今回は割愛)


## UICollectionViewのセル配置にRCGを適用する
```swift:PaletteTableViewCell.swift
final class PaletteTableViewCell: UITableViewCell {
    override func layoutSubviews() {
        let leftInset = readableContentGuide.layoutFrame.minX
        let rightInset = frame.width - leftInset - layout.itemSize.width
        collectionView.contentInset = .init(top: 0,
                                            left: leftInset,
                                            bottom: 0,
                                            right: rightInset)
        super.layoutSubviews()
    }
}
```

初期表示時や端末の回転などのタイミングで呼び出されるため、`layoutSubviews`内でRCGの実際の幅を取得してcontentInsetに指定することで自動的にRCGに追従する仕組みを作れます。
今回はUITableViewが保持していますが、UIViewControllerがUICollectionViewを管理する場合は`viewWillLayoutSubviews`のタイミングで同じことを行うと良いです。

## UICollectionViewのスクロールにページング補正をかける
```swift:PagingForReadableContentGuideLayout.swift
final class PagingForReadableContentGuideLayout: UICollectionViewFlowLayout {
    override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint,
                                      withScrollingVelocity velocity: CGPoint) -> CGPoint {
        guard let contentInsetLeft = collectionView?.contentInset.left else { return proposedContentOffset }

        // アイテム1つあたりの表示幅
        let widthPerItem: CGFloat = itemSize.width + minimumLineSpacing

        // 止まるべきページ数の算出
        let adjustedPage: CGFloat = {
            // contentOffsetはcontentInsetを引いた位置から始まるため、加算して実質的なスクロール位置を算出する
            let relativeOffsetX = proposedContentOffset.x + contentInsetLeft

            // スクロール位置をアイテムの表示幅で割ることでページ数を算出する
            let page = relativeOffsetX / widthPerItem

            if velocity.x > 0 {
                // 右向きにスワイプされたなら右側のアイテムの位置に合わせる
                return ceil(page)
            } else if velocity.x < 0 {
                // 左向きにスワイプされたなら左側のアイテムの位置に合わせる
                return floor(page)
            } else {
                // 静止した状態で指が放されていたなら近いアイテムの位置に移動するため四捨五入する
                return round(page)
            }
        }()

        // ページング補正後のスクロール位置
        let adjustedContentOffsetX = adjustedPage * widthPerItem - contentInsetLeft

        return CGPoint(x: adjustedContentOffsetX,
                       y: proposedContentOffset.y)
    }
}
```

UICollectionViewFlowLayoutの`targetContentOffset`をoverrideすると、スワイプ終了時にスクロールが止まる位置を制御できます。
contentInsetを加味したスクロール位置をアイテム幅で割り、ページ数を算出することでスクロールが止まる位置を決定しています。
これによっていい感じの位置でアイテムが表示される実装ができます。

## ランダム色の生成
本題と関係ないですが一応。

```swift:UIColor+Utility.swift
extension UIColor {
    static func random() -> UIColor {
        UIColor(red: .random(in: 0...1),
                green: .random(in: 0...1),
                blue: .random(in: 0...1),
                alpha: 1)
    }
```

UIColorは以上のように書けばランダムな色を生成できます。これを利用してViewController側では10X20の色を生成して表示しています。

今回のUIColor関連の操作はこちらを参考にさせていただきつつ少しアレンジしています。
https://qiita.com/Kyome/items/a6337bea6420ec3a8991
https://qiita.com/Kyome/items/eae6216b13c651254f64