iOS
MVVM
Swift
RxSwift
swift4

RxSwiftを使ったMVVM構成でUICollectionViewの上下無限スクロールを実装する

現在関わっているiOSプロジェクトではRxSwiftを使ったMVVMアーキテクチャを採用しています。

開発しているiOSアプリにはUICollectionViewを使った商品一覧画面があり、画面下にスクロールしていくと商品を次々ロードしていく、いわゆる無限スクロールを実装しています。
画面下方向への無限スクロールについて紹介している記事はよく見かけるのですが、ロードした商品データをメモリに無限に追加していくわけにも行かないので、ある程度のところで最初にロードしたデータをメモリから削除していく必要があり、そうすると画面上下の無限スクロールを実装する必要があります。

ここではRxSwiftを使って上下無限スクロールを実装する例を紹介します。

環境

  • XCode 9.2
  • Swift 4.0.3
  • RxSwift 4.1.2

データソース

ViewModelにBehaviorRelay<[Product]>型のproductsプロパティを持たせ、ViewControllerでは以下のようにデータソースの設定をしています。

ProductListViewModel.swift
var products = BehaviorRelay<[Product]>(value: [])
ProductListViewController.swift
vm.products
    .asDriver(onErrorJustReturn: [])
    .drive(
        productCollectionView.rx.items(
            cellIdentifier: "ProductListCell",
            cellType: ProductListCell.self
        )
    ) { (_, element, cell) in
        cell.setupCell(product: element)
    }
    .disposed(by: disposeBag)

次のページの商品データをリクエストすべきかどうかの判定

ユーザが商品一覧画面を下方向にスクロールし、現在ロードされている全ての商品を表示し切る直前に次のページの商品をロードするという実装を行います。

スクロール位置を知るにはUIScrollViewのcontentOffsetを使用します。
UICollectionViewはUIScrollViewを継承しているのでこのプロパティが使用できます。

RxSwiftの拡張によってオフセット値の変更イベントをサブスクライブすることができるので、オフセット値が変更されるたび、つまり画面がスクロールされるたびにオフセット値を確認し、次のページをロードすべきかどうかを判定します。

次のページをロードすべきだと判定されたら、ViewModelのloadNextProducts()メソッドで追加の商品データをロードします。
loadNextProducts()メソッドはViewModelのproductsプロパティを更新します。

以下はその実装例です。
rx.contentOffsetは大量に変更イベントが流れてくるので、distinctUntilChangedfilterオペレータを使って、無駄なリクエストが発生するのを防止しています。

ProductListViewController.swift
productCollectionView.rx.contentOffset.asDriver()
    .map { _ in self.shouldRequestNextPage() }
    .distinctUntilChanged()
    .filter { $0 }
    .withLatestFrom(vm.isLoading)
    .filter { !$0 }
    .drive(onNext: { _ in self.vm.loadNextProducts() })
    .disposed(by: disposeBag)

private func shouldRequestNextPage() -> Bool {
    return productCollectionView.contentSize.height > 0 &&
        productCollectionView.isNearBottomEdge()
}

「現在ロードされている全ての商品を表示し切る直前」という判定は、UIScrollView.isNearBottomEdge()メソッドで行っています。
このメソッドはUIScrollViewのエクステンションとして実装しています。

UIScrollView+Extension.swift
extension UIScrollView {
    func isNearBottomEdge(edgeOffset: CGFloat = 20.0) -> Bool {
        return contentOffset.y + frame.size.height + edgeOffset > contentSize.height
    }
}

商品データの最大保持サイズを超えたらViewControllerに通知する

loadNextProducts()メソッドが呼ばれると、ViewModelは次の商品ページをロードし、商品データリスト(productsプロパティ)の末尾に追加します。
このとき、UICollectionViewのcontentOffset値はそのまま、つまりスクロール位置はそのままで、contentSizeが増えるので、商品一覧画面をさらに下にスクロールできるようになります。

ViewModelはメモリに保持している商品データのページ数を管理していて、ページの最大保持数を超えたら商品データリストの最初のページを削除し、追加で取得したページを商品データリストの末尾に追加します。
ここで一つ問題なのは、UICollectionViewのスクロール位置はそのままで、商品データリストの中身は変わってもデータ数は変わらないためcontentSizeも変わらないので、商品一覧画面は一番下にスクロールしたままの状態になってしまいます。
次のページをロードしたのに、下にスクロールできないというのは問題なので、追加でロードしたセルの数だけスクロール位置を上に移動してあげる必要があります。

ViewModelの具体的な実装は以下の通りです。
api.listProducts()で次のページをロードし、ロード済みのページ数がしきい値を超えたら最初のページを削除して、ロードしたページをproductsの末尾に追加します。
ロード済みのページ数がしきい値を超えたことをViewControllerに通知するために、productsAppendedというPublishRelay<Int>型のプロパティを持たせています。
このストリームには追加された商品データ数を流すので、Int型にしています。

ProductListViewModel.swift
var productsAppended = PublishRelay<Int>()

private var currentPage = 1
private let maxHoldPageSize = 10

private var hasMaxSize: Bool {
    return currentPage - maxHoldPageSize >= 0
}

func loadNextProducts() {
    api.listProducts(page: currentPage + 1, limit: numPerPage)
        .subscribe(onNext: { products in
            guard !products.isEmpty else { return }

            self.appendProducts(products)
            self.currentPage += 1
        })
        .disposed(by: disposeBag)
}

private func appendProducts(_ addedItems: [Product]) {
    var current = self.products.value

    if hasMaxSize {
        current.removeFirst(addedItems.count)
        productsAppended.accept(addedItems.count)
    }

    products.accept(current + addedItems)
}

商品データの追加イベントに反応してスクロール位置を移動する

ViewControllerの実装は以下の通りです。
ViewModelのproductsAppendedをサブスクライブして、追加された商品データの数だけスクロール位置を上に移動します。

ProductListViewController.swift
vm.productsAppended.asSignal()
    .emit(onNext: { numAddedItems in
        self.productCollectionView.scrollUp(numCells: numAddedItems, animated: false)
    })
    .disposed(by: disposeBag)

scrollUp(numCells:animated:)メソッドはUICollectionViewのエクステンションとして実装しました。
セルの高さは固定でセクションは1つという前提での実装になります。
セルの高さ * 追加されたセルの数を算出し、その分だけcontentOffsetをずらします。

UICollectionView+Extension.swift
extension UICollectionView {
    enum ScrollDirection {
        case up
        case down
    }

    func scrollUp(numCells: Int, animated: Bool) {
        scroll(direction: .up, numCells: numCells, animated: animated)
    }

    func scrollDown(numCells: Int, animated: Bool) {
        scroll(direction: .down, numCells: numCells, animated: animated)
    }

    private func scroll(direction: ScrollDirection, numCells: Int, animated: Bool) {
        let cellHeight = contentSize.height / CGFloat(numberOfItems(inSection: 0))
        let offsetToMove = cellHeight * CGFloat(numCells)

        let offsetY: CGFloat
        switch direction {
        case .up:
            offsetY = contentOffset.y - offsetToMove
        case .down:
            offsetY = contentOffset.y + offsetToMove
        }

        let offset = CGPoint(x: contentOffset.x, y: offsetY)
        setContentOffset(offset, animated: animated)
    }
}

上向き無限スクロールの実装

ここまでは下向きの無限スクロールについて説明してきました。
上向きの無限スクロールは、単純に逆向きのことをやればいいだけです。
実装だけ一気に載せてしまいます。

ProductListViewModel.swift
var productsPrepended = PublishRelay<Int>()

private var previousPage: Int? {
    let page = currentPage - maxHoldPageSize
    return page > 0 ? page : nil
}

func loadPreviousProducts() {
    guard let previousPage = previousPage else { return }

    api.listProducts(page: previousPage, limit: numPerPage)
        .subscribe(onNext: { products in
            guard !products.isEmpty else { return }

            self.prependProducts(products)
            self.currentPage -= 1
        })
        .disposed(by: disposeBag)
}

private func prependProducts(_ addedItems: [Product]) {
    var current = products.value

    if hasMaxSize {
        current.removeLast(addedItems.count)
        productsPrepended.accept(addedItems.count)
    }

    products.accept(addedItems + current)
}
ProductListViewController.swift
productCollectionView.rx.contentOffset.asDriver()
    .map { _ in self.shouldRequestPreviousPage() }
    .distinctUntilChanged()
    .filter { $0 }
    .withLatestFrom(vm.isLoading)
    .filter { !$0 }
    .drive(onNext: { _ in self.vm.loadPreviousProducts() })
    .disposed(by: disposeBag)

vm.productsPrepended.asSignal()
    .emit(onNext: { numAddedItems in
        self.productCollectionView.scrollDown(numCells: numAddedItems, animated: false)
    })
    .disposed(by: disposeBag)

private func shouldRequestPreviousPage() -> Bool {
    return productCollectionView.contentSize.height > 0 &&
        productCollectionView.isNearTopEdge()
}
UIScrollView+Extension.swift
extension UIScrollView {
    func isNearTopEdge(edgeOffset: CGFloat = 20.0) -> Bool {
        return contentOffset.y < edgeOffset
    }
}

まとめ

一覧画面を持ってるアプリではたいてい実装されていると思われる無限上下スクロールですが、RxSwiftで実現している例を見かけなかったのでまとめてみました。
まだまだRxSwiftやらMVVMやらを使いこなせていないので、データの追加の通知方法だったり、そもそものMVVMの構成がおかしかったりなどご指摘たくさんいただければ嬉しいです。