モダンCollectionViewとSwiftUIのハイブリッドを実現する

## はじめに

SwiftUIはこれまでのiOSアプリにおけるUI実装上の課題を解消する新技術として登場し、徐々にプロダクションでも利用されて来ていると思います。しかしまだ、UIKitでできていたことがすべて置き換えられる段階ではないというのも事実でしょう。一方で、UIKitの世界にもUICollectionViewのCompositionalLayoutやDiffableDataSourceといった便利で使いやすいAPIが登場しています。

このようなタイミングで既存のアプリに機能的にかなり独立した画面を新たに追加することになったので、ベースを UICollectionView + CompositionalLayout + DiffableDataSource （いわゆる[Modern Collection View](https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/implementing_modern_collection_views)）で実装し、セルの中身にSwiftUIを利用するという、UIKitとSwiftUIのいいところどりを目指した実装を試してみることにしました。

具体的な実装方法、はまったポイントとワークアラウンドなどをまとめて紹介します。

## 画面の仕様

[TimeTree](https://timetreeapp.com/intl/ja/)はこのたび「Today」という新機能をリリースしました。これは天気予報、予定、今日にまつわるコンテンツなどをまとめて確認できる画面です。

https://timetreeapp.com/intl/ja/newsroom/app/2021-08-02/today

各モジュールはカードのようなUIになっていて、セクションによっては複数のカードが横スクロールで見れるようになっています。少し前までこのようなUIを実現するためには `UICollectionView` をネストして使う必要がありましたが、iOS 13からはCompositionalLayoutを利用することによって1つのCollectionViewで実現できます。

<table><tr>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32084/545ab8aa-cc46-4290-cd1f-db68c24fe570.png" width="390"></td>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32084/35ecd7e4-2eea-6811-271d-44b72d1a3453.png" width="390"></td>
</tr></table>

今回はサンプルとして「Today」を単純化した以下のような画面を作ります。

ひとつめのセクションはカードが1枚ずつ縦に並びます。中身のテキストに応じてセルの高さが変わります。ふたつめのセクションは固定サイズのカードが横に並び、横スクロールします。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32084/7d2a45a2-9a88-ec3a-59d8-0fd422dd1d01.png" width="375">

以下のサンプルコードはiOS 14+を前提にしていますが、一部iOS 14+のAPIの利用を避ければiOS 13をサポートすることも可能です。

完成したサンプルプロジェクトも[こちら](https://github.com/gonsee/swiftui-hosting-cell-example)に用意したので適宜ご活用ください。

## SwiftUIで中身を実装できる`UICollectionViewCell`

`UICollectionViewCell` の `contentView` の上に `UIHostingController` を使ってSwiftUIの `View` を表示できるようにします。 `UIHostingController` は `UIViewController` なので、 `addSubview` するだけでなく ViewController の親子関係を設定するための `addChild(_:)`, `didMove(toParent:)` を呼ぶ必要があります。

```swift
/// HostingCellのContentになるViewが準拠するプロトコル
protocol HostingCellContent: View {
    associatedtype Dependency
    init(_ dependency: Dependency)
}

class HostingCell<Content: HostingCellContent>: UICollectionViewCell {

    private let hostingController = UIHostingController<Content?>(rootView: nil)

    override public init(frame: CGRect) {
        super.init(frame: frame)
        hostingController.view.backgroundColor = .clear
        hostingController.view.translatesAutoresizingMaskIntoConstraints = false
    }

    @available(*, unavailable)
    public required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    public func configure(_ dependency: Content.Dependency, parent: UIViewController) {
        hostingController.rootView = Content(dependency)
        hostingController.view.invalidateIntrinsicContentSize()

        guard hostingController.parent == nil else { return }
        // 以下は初回のみ実行
        parent.addChild(hostingController)
        contentView.addSubview(hostingController.view)
        NSLayoutConstraint.activate([
            hostingController.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            hostingController.view.topAnchor.constraint(equalTo: contentView.topAnchor),
            hostingController.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            hostingController.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
        ])
        hostingController.didMove(toParent: parent)
    }

}
```

## セルの中身をSwiftUIで実装する

`HostingCellContent` に準拠した `SwiftUI.View` を実装します。ここでは `Dependency` として文字列をひとつ持つ `ViewModel` を定義して、その文字列を `Text` に表示するシンプルなビューを作ります。

```swift
struct ItemView: HostingCellContent {

    typealias Dependency = ViewModel

    struct ViewModel {
        var text: String
    }

    var viewModel: ViewModel

    init(_ viewModel: ViewModel) {
        self.viewModel = viewModel
    }

    var body: some View {
        VStack(spacing: 0) {
            Text(viewModel.text)
                .font(.system(size: 24, weight: .bold))
                .frame(maxWidth: .infinity, alignment: .leading)
            Spacer(minLength: 0)
        }
        .padding(12)
    }

}
```

続いて上記で定義した `ItemView` をコンテンツとして持つ `HostingCell` のサブクラスを定義します。 `init` の中でやっているのは角丸と影をつけてカードの見た目を実装しているだけで、ここで大事なのはクラス宣言の部分です。

```swift
final class ItemCell: HostingCell<ItemView> {

    override init(frame: CGRect) {
        super.init(frame: frame)

        clipsToBounds = false
        backgroundColor = .clear
        layer.cornerRadius = 16
        layer.shadowColor = UIColor.black.cgColor
        layer.shadowOpacity = 0.05
        layer.shadowOffset = .init(width: 0, height: 2)
        layer.shadowRadius = 3

        contentView.clipsToBounds = true
        contentView.backgroundColor = .secondarySystemGroupedBackground
        contentView.layer.cornerRadius = 16
    }

}
```

## CompositionalLayout

セルの準備ができたので、続いてViewControllerとCollectionViewを作っていきます。ふたつのセクションを表現する `enum` を以下のように定義します。

```swift
final class ViewController: UIViewController {

    enum Section: Int {
        case first
        case second
    }

}
```

CollectionViewとlayoutを以下のように実装します。

```swift
final class ViewController: UIViewController {

    // ...

    private lazy var collectionView: UICollectionView = {
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.backgroundColor = .systemGroupedBackground
        return collectionView
    }()

    private lazy var layout = UICollectionViewCompositionalLayout { section, _ in
        guard let section = Section(rawValue: section) else { return nil }
        switch section {
        case .first:
            let layoutSize = NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1.0),
                heightDimension: .estimated(100) // 高さは可変
            )
            let item = NSCollectionLayoutItem(layoutSize: layoutSize)
            let group = NSCollectionLayoutGroup.horizontal(layoutSize: layoutSize, subitem: item, count: 1)
            let section = NSCollectionLayoutSection(group: group)
            section.contentInsets = .init(top: 8, leading: 8, bottom: 8, trailing: 8)
            section.interGroupSpacing = 8
            return section
        case .second:
            let layoutSize = NSCollectionLayoutSize(
                widthDimension: .absolute(160),
                heightDimension: .absolute(240)
            )
            let item = NSCollectionLayoutItem(layoutSize: layoutSize)
            let group = NSCollectionLayoutGroup.horizontal(layoutSize: layoutSize, subitems: [item])
            let section = NSCollectionLayoutSection(group: group)            
            section.orthogonalScrollingBehavior = .continuous // 要素を横スクロールさせる
            section.contentInsets = .init(top: 8, leading: 8, bottom: 8, trailing: 8)
            section.interGroupSpacing = 8
            return section
        }
    }

    override func loadView() {
        view = collectionView
    }

}
```

## DiffableDataSource

まず、 `UICollectionViewDiffableDataSource` の `ItemIdentifierType` としてカード1枚を表現する `Item` を以下のように定義します。

```swift
final class ViewController: UIViewController {
    // ...
    enum Item: Hashable {
        case item(text: String)
    }
    // ...
}
```

これまで用意したものを利用してデータソースは以下のように実装できます。 `CellRegistration` のhandlerでは `ItemCell` に `ViewModel` を渡してSwiftUIのビューに表示するテキストを与えています。

```swift
    private lazy var dataSource: UICollectionViewDiffableDataSource<Section, Item> = {
        let cellRegistration = UICollectionView.CellRegistration<ItemCell, Item> { [weak self] cell, indexPath, item in
            guard let self = self,
                  case let .item(text) = item else { return }
            cell.configure(.init(text: text), parent: self)
        }
        let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, item in
            switch item {
            case .item:
                return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item)
            }
        }
        return dataSource
    }()
```

最後にデータを用意してスナップショットを生成し、データソースに `apply` します。

```swift
    private let data = [
        "Lorem ipsum dolor sit amet, consectetur adipiscing elit.\nNullam condimentum ante quis purus sagittis finibus.",
        "Aliquam vel libero vel metus consectetur laoreet.",
        "Nulla vehicula nibh et est laoreet accumsan.\nVestibulum aliquet diam sed ornare iaculis.",
        "Ut fringilla massa ac arcu gravida, sit amet hendrerit est iaculis.",
        "Vestibulum euismod purus quis justo consequat imperdiet.",
        "Vestibulum sed libero ultrices, ultricies magna non, pretium mi.",
        "Etiam ut metus eu lectus cursus luctus in et ligula.",
        "Nullam a dui vitae nisi cursus rhoncus.",
    ]

    override func viewDidLoad() {
        super.viewDidLoad()

        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapshot.appendSections([.first, .second])
        snapshot.appendItems(data[0..<3].map({ .item(text: $0) }), toSection: .first)
        snapshot.appendItems(data[3..<8].map({ .item(text: $0) }), toSection: .second)
        dataSource.apply(snapshot)
    }
```

以上で冒頭に示したサンプルの画面が表示できるようになったはずです。

## 発生した問題とワークアラウンド

上記のシンプルな例では素直な実装で実現できていますが、実際の複雑なレイアウトに適用しようとしたときにいくつかの問題が発生しました。

### コンテンツがセーフエリアにかかるとレイアウトが崩れる

スクロールしてコンテンツが画面上部のセーフエリアにかかったときにSwiftUIで実装した部分のレイアウトが影響を受けてしまう問題がありました。 `UIHostingController` のバグっぽい挙動なのですが、きれいな回避策は見つけられず、 method swizzling を利用して無理やり safeAreaInsets をなくすという荒技を採用するしかありませんでした。[こちら](https://stackoverflow.com/a/62421114/1544689)を参考にしています。

```swift
/// fixSafeAreaInsets を一度だけ実行するためのフラグ
private var fixSafeAreaInsetsHostingControllerIsFixed = false

/// UIHostingController の view のレイアウトが safe area の影響を受けてしまう問題のワークアラウンドのために利用する
public final class FixSafeAreaInsetsHostingController<Content: View>: UIHostingController<Content> {

    override public init(rootView: Content) {
        super.init(rootView: rootView)
        if !fixSafeAreaInsetsHostingControllerIsFixed {
            fixSafeAreaInsets()
            fixSafeAreaInsetsHostingControllerIsFixed = true
        }
    }

    @available(*, unavailable)
    @objc dynamic required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    /// view の safe area を強制的になくす
    private func fixSafeAreaInsets() {
        let viewClass: AnyClass = view.classForCoder

        let safeAreaInsets: @convention(block) (AnyObject) -> UIEdgeInsets = { (_: AnyObject!) -> UIEdgeInsets in
            .zero
        }
        guard let safeAreaInsetsMethod = class_getInstanceMethod(viewClass.self, #selector(getter: UIView.safeAreaInsets)) else { return }
        class_replaceMethod(viewClass, #selector(getter: UIView.safeAreaInsets), imp_implementationWithBlock(safeAreaInsets), method_getTypeEncoding(safeAreaInsetsMethod))

        let safeAreaLayoutGuide: @convention(block) (AnyObject) -> UILayoutGuide? = { (_: AnyObject!) -> UILayoutGuide? in
            nil
        }
        guard let safeAreaLayoutGuideMethod = class_getInstanceMethod(viewClass.self, #selector(getter: UIView.safeAreaLayoutGuide)) else { return }
        class_replaceMethod(viewClass, #selector(getter: UIView.safeAreaLayoutGuide), imp_implementationWithBlock(safeAreaLayoutGuide), method_getTypeEncoding(safeAreaLayoutGuideMethod))
    }

}
```

`HostingCell` の内部で使う `UIHostingController` を上記のクラスに置き換えて使います。

method swizzling は `UIHostingController` の `view` のクラスに対して適用しているので、すべての `UIHostingController` に影響する点に注意が必要です。

```swift
open class HostingCell<Content: HostingViewContent>: UICollectionViewCell {

    private let hostingController = FixSafeAreaInsetsHostingController<Content?>(rootView: nil)

    // ...
}
```

### レイアウトによってセルのself-sizingがうまくいかない

基本的には `UIHostingController` の `rootView` が自身のサイズを決めるので、CompositionalLayoutでアイテムのサイズに `.estimated(100)` のように指定しておけば、セルのサイズも自動的に調整されます。ただしSwiftUIのレイアウトによっては意図通りにサイズが決まらないケースがありました。

このようなケースでも以下のように `sizeThatFits` で計算すると正しいサイズを得ることができたので、この結果を利用する方法を取りました。

```swift
let height = hostingController.view.sizeThatFits(contentView.bounds.size).height
```

まず `HostingCell` の `contentView` にオートレイアウトで高さの制約を与えます。

```swift
    /// 高さの制約
    private lazy var heightConstraint: NSLayoutConstraint = {
        let constraint = contentView.heightAnchor.constraint(equalToConstant: 0)
        // 実行時のレイアウトエラー回避のため優先度を下げる
        constraint.priority = .init(rawValue: 999)
        constraint.isActive = true
        return constraint
    }()
```

`configure` の中で `sizeThatFits` の結果を制約にセットします。

```swift
   func configure(_ dependency: Content.Dependency, parent: UIViewController) {
        hostingController.rootView = Content(dependency)
        hostingController.view.invalidateIntrinsicContentSize()
        // hostingController の view 自身にサイズを決めて欲しいがうまくいかないケースがあるので
        // sizeThatFits の計算結果を使って高さの制約を与えている。
        let height = hostingController.view.sizeThatFits(contentView.bounds.size).height
        heightConstraint.constant = height

        // ...
    }
```

### スクロール中にボタンが反応してしまう

SwiftUIのボタンは通常以下のように実装すると思います。

```swift
Button(action: {
    // ボタンアクション
}, label: {
    Text("Button")
})
```

しかし今回の実装では、SwiftUIの中で `Button` を配置したときにCollectionViewのスクロールとボタンタップのジェスチャーが競合してしまう問題が発生しました。ドラッグのジェスチャーを開始した位置にボタンがあった場合に、ドラッグを終了して指を離した瞬間にボタンのアクションが実行されてしまいます。

通常であればスクロールが発生した段階でボタンに対するタップジェスチャーはキャンセルされてアクションが実行されることはありません。実際に `UIScrollView` の中に `UIButton` を配置した場合や、SwiftUIの中で `ScrollView` と `Button` を使った場合には問題は発生しません。`UIHostingController` を介したハイブリッドな状況でのみ発生するようです。

こちらは試行錯誤した結果以下のように `onTapGesture` の中で実際の処理を行うようにすることで解消できました。

```swift
Button(action: {}, label: {
    Text("Button")
        .onTapGesture {
            // ボタンアクション
        }
})
```

## まとめ

いくつかのかっこ悪いワークアラウンドを含んではいますが、モダンなCollectionViewとSwiftUIのハイブリッド実装を実現することができました。プロダクションに段階的にSwiftUIを導入していくひとつの選択肢になるのではと思います。

「Today」画面には多数のモジュールがあり、それぞれが複雑なレイアウトを持っていました。実際に上記の方法を適用して各モジュールにSwiftUIを利用できたことはかなりメリットがあったと感じます。

最後に、TimeTreeではエンジニア募集中です！興味があったらお気軽にご連絡いただけると嬉しいです。

https://timetreeapp.com/intl/ja/corporate/jobs

## 参考リンク

既存アプリにSwiftUIを導入する事例

https://techlife.cookpad.com/entry/2021/01/18/kaimono-swift-ui

Modern Collection Viewの公式サンプルコード

https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/implementing_modern_collection_views

TableViewでセルの中身をSwiftUIで実装する方法

https://noahgilmore.com/blog/swiftui-self-sizing-cells/