ReadableContentGuideを利用してUICollectionViewのコンテンツを見やすくする

# 環境
- Swift 5.4
- Xcode 12.5

# 概要
`ReadableContentGuide`(以下`RCG`) は、端末によって
コンテンツの読みやすい幅を実現するために役立つ `UILayoutGuide` です。

**Apple Developer ドキュメント** - [readableContentGuide](https://developer.apple.com/documentation/uikit/uiview/1622644-readablecontentguide)

デザインによるコンテンツのマージン指定が特になければ、
`UIDevice.current.userInterfaceIdiom` を判定して
iPhoneとiPadそれぞれに制約を設けることをせず、
RCGを利用して良い感じにマージンを設定することができます。

UITableViewやUIScrollViewで利用している例はちょこちょこ見かける気がするので、
今回はUICollectionViewの画面でRCGを利用した例を紹介します。

#### ※注意
Storyboradは利用せず、コードでの実装です。

# 実装
## UICollectionViewに対してRCGを適用する
UICollectionViewの制約をかける際に、
親Viewの `SafeAreaLayoutGuide` ではなくRCGを基準とします。
そのためitemSizeを算出する時は、RCGの `layoutFrame.width` を利用します。
コンテンツ上下左右のマージンはRCGに任せた実装になります。

```swift
import UIKit

final class ReadableCollectionViewController: UIViewController {
    private let collectionView = UICollectionView(frame: .zero,
                                                  collectionViewLayout: UICollectionViewFlowLayout())

    private let itemSpacing: CGFloat = 10

    override func loadView() {
        super.loadView()
        view.backgroundColor = .systemBackground
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        collectionView.backgroundColor = .secondarySystemBackground
        collectionView.delegate = self
        collectionView.dataSource = self
        collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "Cell")
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(collectionView)
        NSLayoutConstraint.activate([
            collectionView.topAnchor.constraint(equalTo: view.readableContentGuide.topAnchor),
            collectionView.bottomAnchor.constraint(equalTo: view.readableContentGuide.bottomAnchor),
            collectionView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor),
            collectionView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor)
        ])
    }
}

extension ReadableCollectionViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        30 // 任意の数
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath)
        cell.backgroundColor = .gray
        return cell
    }
}

extension ReadableCollectionViewController: UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        sizeForItemAt indexPath: IndexPath) -> CGSize {
        let cellSideLength: CGFloat = (view.readableContentGuide.layoutFrame.width - itemSpacing * 2) / 3
        return .init(width: cellSideLength, height: cellSideLength)
    }

    func collectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        insetForSectionAt section: Int) -> UIEdgeInsets {
        .zero
    }

    func collectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        minimumLineSpacingForSectionAt section: Int) -> CGFloat {
        itemSpacing
    }

    func collectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
        itemSpacing
    }
}
```

### 画面キャプチャ
インジケータがコンテンツと被ってしまっているのでinsetの設定はあった方が良さそうです。
また、CollectionViewと親Viewの背景色が異なる場合は注意が必要です。

|iPhone8|iPadPro(12.9-inch)|
|---|---|
|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/476381/f0007a14-de09-8f7f-31e4-8b0e5e8450f0.png" width=300>|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/476381/5057ea1f-d42a-330e-6c46-9f322f80b42b.png" width=400>|

## UICollectionViewのセクションに対してRCGを適用する
先の実装とは異なり、UICollectionViewの制約は、
親Viewの `SafeAreaLayoutGuide` を基準とします。
その代わりにSectionのInsetを設定する箇所で
計算処理を追加しています。

※ 先の実装と変更がない箇所は処理を省略しています

```swift
import UIKit

final class ReadableCollectionViewController: UIViewController {
    // 先の実装と同様

    override func viewDidLoad() {
        super.viewDidLoad()
        // 先の実装と同様
        NSLayoutConstraint.activate([
            collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            collectionView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
            collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
            collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor)
        ])
    }
}

extension ReadableCollectionViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        // 先の実装と同様
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        // 先の実装と同様
    }
}

extension ReadableCollectionViewController: UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        sizeForItemAt indexPath: IndexPath) -> CGSize {
        // 先の実装と同様
    }

    func collectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        insetForSectionAt section: Int) -> UIEdgeInsets {
        let topOrBottomInset: CGFloat = (view.frame.height - view.readableContentGuide.layoutFrame.height) / 2
        let leftOrRightInset: CGFloat = (view.frame.width - view.readableContentGuide.layoutFrame.width) / 2
        return .init(top: topOrBottomInset, left: leftOrRightInset,
                     bottom: topOrBottomInset, right: leftOrRightInset)
    }

    func collectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        minimumLineSpacingForSectionAt section: Int) -> CGFloat {
        // 先の実装と同様
    }

    func collectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
        // 先の実装と同様
    }
}
```





### 画面キャプチャ
CollectionViewが画面全体に広がっているのでインジケータは見やすくなりました。
Sectionが複数になった時にtopとbottomのマージン設定を改善しないといけなさそうです。


|iPhone8|iPadPro(12.9-inch)|
|---|---|
|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/476381/3e0245d7-b264-f80c-1135-12808e2f121b.png" width=300>|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/476381/8c33c98d-e983-9785-63e8-492ee6e1a8a7.png" width=400>|


# 後記
今回実装した画面は動的にitemのサイズが切り替わらないので
`UICollectionViewFlowLayout`のインスタンスをCollectionViewに渡して実現しようとしましたが、
`viewWillAppear(_:)` まではRCGのlayoutFrameの正確な値を
取得できなかったため、Delegateを利用しています。

```swift
override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    print("viewWillAppear:\(view.readableContentGuide.layoutFrame)")
}

override func viewWillLayoutSubviews() {
    super.viewWillLayoutSubviews()
    print("viewWillLayoutSubviews:\(view.readableContentGuide.layoutFrame)")
}

// iPhone8の場合
// viewWillAppear:(0.0, 0.0, 375.0, 667.0)
// viewWillLayoutSubviews:(16.0, 20.0, 343.0, 647.0)
```