横スクロールするCollectionViewに縦横比率を維持した画像を表示する

# 環境
- Xcode 12.5
- Swift 5.4

# 動作イメージ
表題通り、縦横比の異なる画像を横スクロールするCollectionViewで均等な間隔で表示します。

||
|:---:|
|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/429630/94781d1d-ebf1-8740-464c-05a522c3c147.gif" width= 400>|

※表示している画像は [PLACEHOLDER.COM](https://placeholder.com) から取得しています


# 大まかなView階層
> `UITableView` 
>> `UITableViewCell` 
>>> `UICollectionView` 
>>>> `UICollectionViewCell` 
>>>>> `UIImageView`

# 前提
- `UITableViewCell` の高さは任意の値で固定
- `UICollectionViewCell` の横幅は可変（self-sizing）
- iOS14のみで挙動を確認しているのでiOS12などでバグが起こるかもしれません
  - [UICollectionViewFlowLayoutのestimatedItemSizeを指定するとiOS12で表示がおかしい](https://qiita.com/sre20313/items/ad1beae64dac5852e883)
  

# 実装

## ViewController
```swift
import UIKit

final class PlaceholderListViewController: UIViewController {
    private var tableView: UITableView!
    private var images: [[UIImage?]] = [] {
        didSet {
            tableView.reloadData()
        }
    }
    private let reuseId = String(describing: PlaceholderListTableViewCell.self)

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

        tableView = {
            let tableView = UITableView()
            tableView.dataSource = self
            tableView.separatorInset = .zero
            tableView.register(PlaceholderListTableViewCell.self, forCellReuseIdentifier: reuseId)
            tableView.rowHeight = PlaceholderListTableViewCell.height
            view.addSubview(tableView)
            tableView.translatesAutoresizingMaskIntoConstraints = false
            NSLayoutConstraint.activate([
                tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
                tableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
                tableView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
                tableView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor)
            ])
            return tableView
        }()
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        //...(*1)
        images = // 画像を設定
    }
}
// MARK: - UITableViewDataSource
extension PlaceholderListViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        images.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: reuseId, for: indexPath) as? PlaceholderListTableViewCell
        else { fatalError() }
        cell.setup(images: images[indexPath.row])
        return cell
    }
}
```
(*1)GIFの表示では確認のため以下の様な処理を用いています。

```swift
images = repeatElement((), count: 5)
    .map { _ in
        repeatElement((), count: 5)
            .map { _ in (Int.random(in: (100...300)), Int.random(in: (100...300))) }
            .map { "https://via.placeholder.com/\($0)x\($1)" }
            .map { UIImage(urlString: $0) }
    }

private extension UIImage {
    convenience init?(urlString: String) {
        guard let url = URL(string: urlString) else { return nil }
        do {
            let data = try Data(contentsOf: url)
            self.init(data: data)
        }
        catch {
            return nil
        }
    }
}
```


## UITableViewCell & UICollectionView
```swift
import UIKit

final class PlaceholderListTableViewCell: UITableViewCell {
    static let height: CGFloat = PlaceholderListCollectionViewCell.height
    private var collectionView: UICollectionView!
    private var images: [UIImage?] = [] {
        didSet {
            collectionView.reloadData()
        }
    }
    private let reuseId = String(describing: PlaceholderListCollectionViewCell.self)

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

        let layout: UICollectionViewFlowLayout = {
            let layout = UICollectionViewFlowLayout()
            layout.scrollDirection = .horizontal
            layout.minimumLineSpacing = 10
            layout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize //...(※2)
            return layout
        }()

        collectionView = {
            let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
            collectionView.backgroundColor = .secondarySystemBackground
            collectionView.dataSource = self
            collectionView.register(PlaceholderListCollectionViewCell.self, forCellWithReuseIdentifier: reuseId)
            contentView.addSubview(collectionView)
            collectionView.translatesAutoresizingMaskIntoConstraints = false
            NSLayoutConstraint.activate([
                collectionView.topAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.topAnchor),
                collectionView.bottomAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.bottomAnchor),
                collectionView.leadingAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.leadingAnchor),
                collectionView.trailingAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.trailingAnchor)
            ])
            return collectionView
        }()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func prepareForReuse() {
        super.prepareForReuse()
        images = []
    }

    func setup(images: [UIImage?]) {
        self.images = images
    }
}
// MARK: - UICollectionViewDataSource
extension PlaceholderListTableViewCell: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView,
                        numberOfItemsInSection section: Int) -> Int {
        images.count
    }

    func collectionView(_ collectionView: UICollectionView,
                        cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseId, for: indexPath) as? PlaceholderListCollectionViewCell
        else { fatalError() }
        cell.setImage(image: images[indexPath.row])
        return cell
    }
}

```

(※2)`UICollectionViewCell` を自動リサイズさせるために、`itemSize` ではなく `estimatedItemSize` に値を設定します。

## UICollectionViewCell & UIImageView
```swift
import UIKit

final class PlaceholderListCollectionViewCell: UICollectionViewCell {
    static let height: CGFloat = 200 // 前提より高さは200で固定
    private var imageView: UIImageView!

    override init(frame: CGRect) {
        super.init(frame: frame)
        contentView.backgroundColor = .tertiarySystemBackground

        imageView = {
            let imageView = UIImageView()
            imageView.contentMode = .scaleAspectFit
            contentView.addSubview(imageView)
            imageView.translatesAutoresizingMaskIntoConstraints = false
            //...(*3)
            NSLayoutConstraint.activate([
                imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
                imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
                imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
                imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor)
            ])
            return imageView
        }()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func prepareForReuse() {
        super.prepareForReuse()
        imageView.image = nil
    }

    override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
        super.preferredLayoutAttributesFitting(layoutAttributes)
        //...(*4)
        guard let imageSize = imageView.image?.size else { return layoutAttributes }
        let imageAspectRatio: CGFloat = imageSize.width / imageSize.height
        layoutAttributes.size = .init(width: Self.height * imageAspectRatio,
                                      height: Self.height)
        return layoutAttributes
    }

    func setImage(image: UIImage?) {
        imageView.image = image
    }
}

```
(*3)サイズ変更されるCellに追従するよう`UIImageView`はContentViewにぴったりレイアウトされるようにしておきます。

(*4)表示する画像の縦横比を取得して、Cellの縦幅（今回だと200px）を基準に表示枠のサイズ修正をします。
layoutAttributesにはCellに適用されるレイアウトの情報が含まれています。


# 参考にした記事
- Apple Developer Document - [automaticSize](https://developer.apple.com/documentation/uikit/uicollectionviewflowlayout/1779556-automaticsize)
- Apple Developer Document - [preferredLayoutAttributesFitting(_:)](https://developer.apple.com/documentation/uikit/uicollectionreusableview/1620132-preferredlayoutattributesfitting)
- [iOS 10時代の Self-sizing UICollectionViewCell](https://qiita.com/usagimaru/items/e0a4c449d4cdf341e152)
- [Swift UICollectionViewのcellサイズを自動調整する](https://www.letitride.jp/entry/2019/09/09/054940)