Combine.framework+DiffableDataSourceでリストUIを実装してみる

# 前置き

こんにちは。@zrn-nsです。

皆さんiPhoneは使っていますか？
iOS搭載のデバイスは価格が高い代わりに、ロングサポートしてくれるのが嬉しいですよね。
僕自身、スマホはiPhone派です。Androidは良い性能のデバイスを買っても、早々にベンダーによるサポートが打ち切られてしまうため、あまり長く使えず勿体ないんですよね。

**デバイスのサポート期間が長いというのは、エンジニアとしても大きなメリットになります。**
多くの端末が最新OSにアップデートできるため、殆どの場合古いOSをサポートする必要がありません。一般的には、最新の2メジャーバージョンのみをサポートすればよいと言われているので、最新の開発機能を利用することができます。

昨年9月にiOS14が登場したことで、そろそろiOS12のサポートを終了する事ができます。そうなると**iOS13に追加された幾つかのイケてる機能（SwiftUIやCombine.framework、NSDiffableDataSource等）**を使用する事ができるようになります。

これを見据え、それらの機能の予習をしておこうと思います。

# 今回やること

今回は、iOS13から使えるようになる下記の2機能を使って、簡単なリスト表示機能（引っ張ってリロード、無限ページング付き）を作ってみます。

- Combine.framework
- NSDiffableDataSource

また今後SwiftUIへの以降も見据え、ViewサイドのアーキテクチャとしてMVVMを採用します。

↓動作イメージ↓
![combinesample.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95499/c3705574-d1ea-fb58-e9db-83fc5fdeacb9.gif)

### Combine.frameworkとは

iOS13から使える、Apple純正の非同期処理用のフレームワークです。
RxSwiftやReactiveSwiftに代表されるイベント通知の仕組みや、Promise/Futureなどの非同期処理の仕組みが含まれています。
SwiftUIで使用することも想定されているようです。

### NSDiffableDataSourceとは

iOS13から使える、UITableViewやUICollectionViewのDataSourceをより簡単に利用できる仕組みです。
これまでは、表示しているデータが変化した場合には、リストに表示されたセルの追加や削除、移動などを手動で管理する（もしくはreloadDataを呼んでまるごと更新するか）する必要がありましたが、NSDiffableDataSourceを利用すれば、最終的なデータの状態を宣言するだけで、セルの追加や削除、移動などの手続きは自動で行ってくれます。

# 実装

今回作成したサンプルプロジェクトをGithubに上げてありますので、必要に応じてご覧ください。
https://github.com/zrn-ns/CombineSample


## ViewModelの実装

<details><summary>ViewModelの全体ソース(クリックで開きます)</summary><div>

```swift:ViewModel.swift
final class ViewModel {

    @Published private(set) var newsList: [News] = []
    @Published private(set) var paging: Paging? = nil {
        didSet {
            needsToShowPagingCell = paging?.hasNext ?? false
        }
    }
    @Published private(set) var isLoading: Bool = false
    @Published private(set) var needsToShowPagingCell: Bool = false

    func viewDidLoad(vc: UIViewController) {
        router = Router()
        router?.viewController = vc

        fetchNewsFromServer()
    }

    func willDisplayNews(_ news: News) {
        if newsList.count - newsList.lastIndex(of: news)! < 5 {
            fetchNewsFromServer()
        }
    }

    func willDisplayPagingCell() {
        fetchNewsFromServer()
    }

    @objc func pulledDownRefreshControl() {
        paging = nil
        newsList = []
        fetchNewsFromServer()
    }

    // MARK: - private

    private var router: Router?
    private var cancellables: Set<AnyCancellable> = []

    private func fetchNewsFromServer() {
        guard !isLoading else { return }
        isLoading = true
        NewsRepository.fetchDataFromServer(paging: paging).sink { [weak self] completion in
            guard let _self = self else { return }

            _self.isLoading = false

            switch completion {
            case .failure(let error):
                _self.router?.showError(error)
            case .finished:
                break
            }

        } receiveValue: { [weak self] (newsList: [News], paging: Paging) in
            self?.newsList.append(contentsOf: newsList)
            self?.paging = paging

        }.store(in: &cancellables)
    }
}
```
</div></details>

部分部分に分けて説明します。

### ViewModel側のイベントをViewに伝えるための監視可能プロパティ群

```swift:ViewModel.swift
    @Published private(set) var newsList: [News] = []
    @Published private(set) var paging: Paging? = nil {
        didSet {
            needsToShowPagingCell = paging?.hasNext ?? false
        }
    }
    @Published private(set) var isLoading: Bool = false
    @Published private(set) var needsToShowPagingCell: Bool = false
```

View側からViewModelの変化を監視するためのプロパティです。
`@Published`というPropertyWrapperを付与することで、通常のPropertyに監視可能な要素としての振る舞いを付与する事ができます。

また全て@Publishedをつけていますが、これらは簡単に監視可能プロパティを宣言するためであり、必ずしも付与する必要はなく、Publisherなどの監視可能プロパティを自力で宣言する事もできます。

### View側のイベントをViewModelに伝えるためのメソッド群

```swift:ViewModel.swift
    func viewDidLoad(vc: UIViewController) {
        router = Router()
        router?.viewController = vc

        fetchNewsFromServer()
    }

    func willDisplayNews(_ news: News) {
        if newsList.count - newsList.lastIndex(of: news)! < 5 {
            fetchNewsFromServer()
        }
    }

    func willDisplayPagingCell() {
        fetchNewsFromServer()
    }

    @objc func pulledDownRefreshControl() {
        paging = nil
        newsList = []
        fetchNewsFromServer()
    }
```

View側でイベントが発生したときに、それをViewModelに伝えるためのメメソッド群です。
イベントに応じて、データの取得などの処理を行っています。

### データ取得用メソッド

```swift:ViewModel.swift
    private func fetchNewsFromServer() {
        guard !isLoading else { return }
        isLoading = true
        NewsRepository.fetchDataFromServer(paging: paging).sink { [weak self] completion in
            guard let _self = self else { return }

            _self.isLoading = false

            switch completion {
            case .failure(let error):
                _self.router?.showError(error)
            case .finished:
                break
            }

        } receiveValue: { [weak self] (newsList: [News], paging: Paging) in
            self?.newsList.append(contentsOf: newsList)
            self?.paging = paging

        }.store(in: &cancellables)
    }
```

Repositoryにアクセスしてデータを取得し、取得できたらViewModelの状態を更新しています。
`NewsRepository.fetchDataFromServer(paging:)`の戻り値は`Promise`になっており、非同期処理を実現しています。
（Future/PromiseもCombine.frameworkの機能の一部です）

## Viewの実装

<details><summary>ViewControllerの全体ソース(クリックで開きます)</summary><div>

```swift:ViewController.swift
private let NewsCellClassName = String(describing: NewsCollectionViewCell.self)
private let NewsCellIdentifier: String = NewsCellClassName

private let PagingCellClassName = String(describing: PagingCollectionViewCell.self)
private let PagingCellIdentifier = PagingCellClassName

class ViewController: UIViewController {

    enum Section: Int {
        case news
        case paging
    }

    enum Item: Hashable {
        case news(news: News)
        case paging
    }

    let viewModel: ViewModel = .init()

    // MARK: - lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()

        viewModel.viewDidLoad(vc: self)
        setupSubscription()
    }

    // MARK: - outlet

    @IBOutlet private weak var collectionView: UICollectionView! {
        didSet {
            collectionView.delegate = self
            collectionView.dataSource = collectionViewDataSource
            collectionView.collectionViewLayout = Self.createLayout()
            collectionView.refreshControl = refreshControl
            collectionView.register(UINib(nibName: NewsCellClassName, bundle: nil), forCellWithReuseIdentifier: NewsCellIdentifier)
            collectionView.register(PagingCollectionViewCell.self, forCellWithReuseIdentifier: PagingCellIdentifier)
        }
    }

    // MARK: - private

    private var cancellables: Set<AnyCancellable> = []

    private lazy var refreshControl: UIRefreshControl = {
        let control = UIRefreshControl()
        control.addTarget(viewModel, action: #selector(ViewModel.pulledDownRefreshControl), for: .valueChanged)
        return control
    }()

    private lazy var collectionViewDataSource: UICollectionViewDiffableDataSource<Section, Item> = .init(collectionView: collectionView) {
        (collectionView: UICollectionView, indexPath: IndexPath, item: Item) -> UICollectionViewCell? in
        // 要素に対して、どのようにセルを生成するかを定義する
        switch item {
        case .news(let news):
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: NewsCellIdentifier, for: indexPath) as! NewsCollectionViewCell
            cell.set(.init(news: news))
            return cell

        case .paging:
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PagingCellIdentifier, for: indexPath) as! PagingCollectionViewCell
            cell.startAnimating()
            return cell
        }
    }

    /// イベントの購読の登録を行う
    private func setupSubscription() {
        // ニュース一覧
        viewModel.$newsList.receive(on: DispatchQueue.main).removeDuplicates().sink { [weak self] _ in
            self?.updateDataSource()
        }.store(in: &cancellables)

        // ページングセル
        viewModel.$needsToShowPagingCell.receive(on: DispatchQueue.main).removeDuplicates().sink { [weak self] _ in
            self?.updateDataSource()
        }.store(in: &cancellables)

        // ロード中表示
        viewModel.$isLoading.receive(on: DispatchQueue.main).removeDuplicates().sink { [weak self] isLoading in
            UIApplication.shared.isNetworkActivityIndicatorVisible = isLoading

            if !isLoading {
                self?.refreshControl.endRefreshing()
            }
        }.store(in: &cancellables)
    }

    /// CollectionViewのLayoutを作成する
    private static func createLayout() -> UICollectionViewLayout {
        UICollectionViewCompositionalLayout { (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
            let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                  heightDimension: .estimated(50))
            let item = NSCollectionLayoutItem(layoutSize: itemSize)
            let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item])
            let section = NSCollectionLayoutSection(group: group)
            return section
        }
    }

    /// CollectionViewのデータソースを更新する
    private func updateDataSource() {
        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapshot.appendSections([.news])
        snapshot.appendItems(viewModel.newsList.map({ .news(news: $0) }), toSection: .news)

        if viewModel.needsToShowPagingCell {
            snapshot.appendSections([.paging])
            snapshot.appendItems([.paging], toSection: .paging)
        }

        collectionViewDataSource.apply(snapshot, animatingDifferences: true)
    }
}

extension ViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
        guard let item = (collectionView.dataSource as? UICollectionViewDiffableDataSource<Section, Item>)?.itemIdentifier(for: indexPath) else {
            fatalError("不正な状態")
        }

        switch item {
        case .news(let news):
            viewModel.willDisplayNews(news)

        case .paging:
            viewModel.willDisplayPagingCell()
        }
    }
}

```
</div></details>

### DiffableDataSource関連のコード

まず、`UICollectionViewDiffableDataSource`の生成を行います。
`UICollectionViewDiffableDataSource`の宣言時に、ジェネリック型としてセクションの種類と、そこに表示されるデータ（≒セル）の種類を設定します（今回はSection, Itemというenumで表現しました）。

また`UICollectionViewDiffableDataSource`のイニシャライザには、データに対してどのようなセルを生成するかをClosureとして定義して渡す必要があります。
- ニュースの要素（`Item.news(news:)`）に対しては `NewsCollectionViewCell`を
- ページングの要素（`Item.paging`）に対しては`PagingCollectionViewCell`を
返すように実装しました。

```swift:ViewController.swift
    enum Section: Int {
        case news
        case paging
    }

    enum Item: Hashable {
        case news(news: News)
        case paging
    }

    private lazy var collectionViewDataSource: UICollectionViewDiffableDataSource<Section, Item> = .init(collectionView: collectionView) {
        (collectionView: UICollectionView, indexPath: IndexPath, item: Item) -> UICollectionViewCell? in
        switch item {
        case .news(let news):
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: NewsCellIdentifier, for: indexPath) as! NewsCollectionViewCell
            cell.set(.init(news: news))
            return cell

        case .paging:
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PagingCellIdentifier, for: indexPath) as! PagingCollectionViewCell
            cell.startAnimating()
            return cell
        }
    }
```

次に、レイアウトを組み立てます。

```swift
    /// CollectionViewのLayoutを作成する
    private static func createLayout(collectionViewWidth: CGFloat) -> UICollectionViewLayout {
        UICollectionViewCompositionalLayout { (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
            let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                  heightDimension: .estimated(50))
            let item = NSCollectionLayoutItem(layoutSize: itemSize)
            let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item])
            let section = NSCollectionLayoutSection(group: group)
            return section
        }
    }
```

UICollectionViewCompositionalLayoutを利用し、レイアウトを構成します。
sectionIndexで渡されたセクションに対して、どのようにレイアウトを組むのか宣言するのですが、今回はただ単にTableViewのように、縦一列のリストのレイアウトを生成しています。
`itemSize`は各要素のサイズを示し、今回はwidthDimensionに`.fractionalWidth(1.0)`を設定することで、CollectionViewの横いっぱいに広がるようなレイアウトを実現しています。

また`NSCollectionLayout[Section|Group|Item]`という型が登場していますが、それぞれセクション全体、セクション内のグループ、それぞれのアイテムを示しています。
今回は1グループ1アイテムになっているのであまり活用できていませんが、うまく設定すると入れ子のような構造を実現できるようです。

次にDiffableDataSourceを更新するためのメソッド定義です。
NSDiffableDataSourceSnapshotを生成し、それをDiffableDataSourceの`apply()`メソッドに渡すことで、自動的に画面が更新されます。
今回は`viewModel`に最新のデータが乗っているので、それらをかき集めてSnapshotを生成しています。

```swift
    /// CollectionViewのデータソースを更新する
    private func updateDataSource() {
        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapshot.appendSections([.news])
        snapshot.appendItems(viewModel.newsList.map({ .news(news: $0) }), toSection: .news)

        if viewModel.needsToShowPagingCell {
            snapshot.appendSections([.paging])
            snapshot.appendItems([.paging], toSection: .paging)
        }

        collectionViewDataSource.apply(snapshot, animatingDifferences: true)
    }
```

最後に、下記のように、ここまで作成したdataSourceとLayoutをCollectionViewに設定します。

```swift
    @IBOutlet private weak var collectionView: UICollectionView! {
        didSet {
            collectionView.dataSource = collectionViewDataSource
            collectionView.collectionViewLayout = Self.createLayout()
            collectionView.register(UINib(nibName: NewsCellClassName, bundle: nil), forCellWithReuseIdentifier: NewsCellIdentifier)
            collectionView.register(PagingCollectionViewCell.self, forCellWithReuseIdentifier: PagingCellIdentifier)
        }
    }
```

### MVVM関連のコード

```swift:ViewController.swift
    let viewModel: ViewModel = .init()
    private var cancellables: Set<AnyCancellable> = []

    /// イベントの購読の登録を行う
    private func setupSubscription() {
        // ニュース一覧
        viewModel.$newsList.receive(on: DispatchQueue.main).removeDuplicates().sink { [weak self] _ in
            self?.updateDataSource()
        }.store(in: &cancellables)

        // ページングセル
        viewModel.$needsToShowPagingCell.receive(on: DispatchQueue.main).removeDuplicates().sink { [weak self] _ in
            self?.updateDataSource()
        }.store(in: &cancellables)

        // ロード中表示
        viewModel.$isLoading.receive(on: DispatchQueue.main).removeDuplicates().sink { [weak self] isLoading in
            UIApplication.shared.isNetworkActivityIndicatorVisible = isLoading

            if !isLoading {
                self?.refreshControl.endRefreshing()
            }
        }.store(in: &cancellables)
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        viewModel.viewDidLoad(vc: self)
        setupSubscription()
    }
}
```

ViewModelをローカル変数として保持し、ViewModel内の監視可能プロパティ（Publisher）を監視しています。

ViewModel側で@Published付きで宣言された`newsList`や`needsToShowPagingCell`などのプロパティは、先頭に`$`をつけてアクセスすることで、監視可能なPublisherを取得することができます。

また、@Publishedを使ったPublisherのイベントの発行タイミングはwillSetのタイミングになっており、`receive(on: DispatchQueue.main)`を使わずに監視すると、プロパティの実体が変化する前に処理が行われてしまい、意図しない動作をする可能性があるため注意が必要です。

# まとめ

今回はiOS13に同梱される予定の機能を幾つか利用してみました。
本当はSwiftUIへの書き換えまでやろうかと思ったのですが、SwiftUIではUIRefreshControlがまだ使えないらしく、一旦後回しにすることにしました😇
（やりようはあるようなので、今後時間があれば試してみようと思います）

まだReactiveProgrammingに慣れていないため、こう書いたほうがいいよ！みたいなのがあれば、ぜひコメントで教えてください🙇‍♂️