4
2

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-02-27

前置き

こんにちは。@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

Combine.frameworkとは

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

NSDiffableDataSourceとは

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

実装

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

ViewModelの実装

ViewModelの全体ソース(クリックで開きます)
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)
    }
}

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

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

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に伝えるためのメソッド群

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に伝えるためのメメソッド群です。
イベントに応じて、データの取得などの処理を行っています。

データ取得用メソッド

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の実装

ViewControllerの全体ソース(クリックで開きます)
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()
        }
    }
}

DiffableDataSource関連のコード

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

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

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
        }
    }

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

    /// 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を生成しています。

    /// 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に設定します。

    @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関連のコード

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付きで宣言されたnewsListneedsToShowPagingCellなどのプロパティは、先頭に$をつけてアクセスすることで、監視可能なPublisherを取得することができます。

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

まとめ

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

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

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2