RxSwiftで検索＋ページネーションをいい感じにする

iOSアプリの以下のような条件の検索画面を RxSwift でいい感じに実装する方法をメモします。

- キーワードを入力して検索ボタンをタップすると検索開始する
- 検索APIは結果が多い場合を想定して、ページ番号指定によるページネーションができる
- 検索結果一覧の最後のアイテムを表示するタイミングで自動で次のページを読み込む
- 最後のページまで読み込んだら終了する

### 実装方法

以下、検索画面 `SearchView` を想定したコードです。細かい説明はコメントとして書いてあります。また、ところどころに動作確認用の print 文が入っています。

- 検索ボタンを押した時は `search(by keyword: String)` を実行する
- 結果一覧の最後のアイテムを表示するタイミングで `requestNextPage()` を実行する

```swift:SearchView.swift
/// 検索画面
class SearchView {
    /// キーワードを入力して検索ボタンを押した時に実行する処理
    func search(by keyword: String) {
        print("[A] Start search - ", keyword)

        // 次のページを要求するイベントを発生させるためだけの Subject を用意
        let requestSubject = PublishSubject<Void>()
        self.nextPageRequestSubject = requestSubject

        // 実行中の検索をキャンセルしてから開始する
        searchDisposable?.dispose()
        searchDisposable = Observable
            // 次のページの要求イベントに検索キーワードを組み合わせる
            .combineLatest(
                // イベント発生のたびにページ番号をインクリメントする
                requestSubject.scan(Int(0), accumulator: { (page, _) -> Int in return page + 1 }),
                Observable.just(keyword)
            )
            // 検索APIでキーワードとページ番号を指定して実行する
            .flatMap { (arg) -> Observable<[Int]> in
                let (page, keyword) = arg
                print("[B] Send API request - ", keyword, page)
                return API.search(by: keyword, page: page)
                    .catchErrorJustReturn([])
                    .asObservable()
            }
            // 検索結果が空になったら止める
            .takeWhile { (ids) in
                !ids.isEmpty
            }
            // 前回までの検索結果に、新しい結果を足したものを
            .scan([]) { (ids, newIds) -> [Int] in
                ids + newIds
            }
            .subscribe(onNext: { (ids) in
                print("[C] Results - ", ids)
            })
        
        // 最初のページを要求する
        requestSubject.onNext(())
    }
    
    /// 検索結果一覧の最後のアイテムを表示する時に実行する処理
    func requestNextPage() {
        print("[D] Request next page")
        // 次のページを要求するイベントを発生させるだけ
        nextPageRequestSubject?.onNext(())
    }
    
    private var searchDisposable: Disposable?
    private var nextPageRequestSubject: PublishSubject<Void>?
    
}
```

説明用に検索APIの動きを想定した処理を次のように用意しました。

- １ページあたり５件のIDを取得できる
- 検索結果は３ページまで存在する

```swift:API.swift
import RxSwift

/// API
enum API {
    /// キーワードで検索する（検索結果は１ページ５件まで返す）
    static func search(by keyword: String, page: Int) -> Single<[Int]> {
        // 検索APIで3ページまで取得できるようなケース
        return Single.create { (observer) -> Disposable in
            switch page {
            case 1: observer(.success([1,2,3,4,5]))
            case 2: observer(.success([6,7,8,9,10]))
            case 3: observer(.success([11,12,13]))
            default: observer(.success([]))
            }
            return Disposables.create()
        }
    }
}
```

### 実行結果

実際の操作を想定して次のようなコードを実行してみます。

```swift
let searchView = SearchView()

// ページ１のリクエスト 
searchView.search(by: "hoge")

// ページ２のリクエスト 
searchView.requestNextPage()

// ページ３のリクエスト 
searchView.requestNextPage()

// ページ４のリクエスト（存在しない）
searchView.requestNextPage()

// ページ５のリクエスト（存在しない）
searchView.requestNextPage()
```

結果はこちらです。

```
[A] Start search -  hoge
[B] Send API request -  hoge 1
[C] Results -  [1, 2, 3, 4, 5]
[D] Request next page
[B] Send API request -  hoge 2
[C] Results -  [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
[D] Request next page
[B] Send API request -  hoge 3
[C] Results -  [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]
[D] Request next page
[B] Send API request -  hoge 4
[D] Request next page
[D] Request next page
```

うまく動いてますね。

- 検索中のキーワードや現在のページ番号といった状態の管理をしなくてよくなっている
- 取得済みの全ページの結果が出力されるので、それでテーブルビュー等を更新するだけでよい