現在関わっているiOSプロジェクトではRxSwiftを使ったMVVMアーキテクチャを採用しています。
開発しているiOSアプリにはUICollectionViewを使った商品一覧画面があり、画面下にスクロールしていくと商品を次々ロードしていく、いわゆる無限スクロールを実装しています。
画面下方向への無限スクロールについて紹介している記事はよく見かけるのですが、ロードした商品データをメモリに無限に追加していくわけにも行かないので、ある程度のところで最初にロードしたデータをメモリから削除していく必要があり、そうすると画面上下の無限スクロールを実装する必要があります。
ここではRxSwiftを使って上下無限スクロールを実装する例を紹介します。
環境
- XCode 9.2
- Swift 4.0.3
- RxSwift 4.1.2
データソース
ViewModelにBehaviorRelay<[Product]>
型のproducts
プロパティを持たせ、ViewControllerでは以下のようにデータソースの設定をしています。
var products = BehaviorRelay<[Product]>(value: [])
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
は大量に変更イベントが流れてくるので、distinctUntilChanged
やfilter
オペレータを使って、無駄なリクエストが発生するのを防止しています。
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のエクステンションとして実装しています。
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
型にしています。
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
をサブスクライブして、追加された商品データの数だけスクロール位置を上に移動します。
vm.productsAppended.asSignal()
.emit(onNext: { numAddedItems in
self.productCollectionView.scrollUp(numCells: numAddedItems, animated: false)
})
.disposed(by: disposeBag)
scrollUp(numCells:animated:)
メソッドはUICollectionViewのエクステンションとして実装しました。
セルの高さは固定でセクションは1つという前提での実装になります。
セルの高さ * 追加されたセルの数
を算出し、その分だけcontentOffset
をずらします。
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)
}
}
上向き無限スクロールの実装
ここまでは下向きの無限スクロールについて説明してきました。
上向きの無限スクロールは、単純に逆向きのことをやればいいだけです。
実装だけ一気に載せてしまいます。
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)
}
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()
}
extension UIScrollView {
func isNearTopEdge(edgeOffset: CGFloat = 20.0) -> Bool {
return contentOffset.y < edgeOffset
}
}
まとめ
一覧画面を持ってるアプリではたいてい実装されていると思われる無限上下スクロールですが、RxSwiftで実現している例を見かけなかったのでまとめてみました。
まだまだRxSwiftやらMVVMやらを使いこなせていないので、データの追加の通知方法だったり、そもそものMVVMの構成がおかしかったりなどご指摘たくさんいただければ嬉しいです。