LoginSignup
2
3

More than 3 years have passed since last update.

RxSwiftでインクリメンタルサーチ&ページネーションのサンプル

Last updated at Posted at 2021-02-18

はじめに

この記事は、いわゆる、よくありがちな「Wikipediaのインクリメンタルサーチを作ってみた」の記事ですが、RxSwiftでページネーションまで実装しているのは、あまりなく、実装してみかったので、自分のメモがてら、書いてみました。

作成したサンプルソースは、以下のリポジトリから取得できます。

Githubのリポジトリ
yoko-yan/RxSwiftIncrementalSearch

作成したサンプルは、機能として、以下の実装をしています。

  • WikipediaのAPIを使って記事をインクリメンタルサーチ
  • paginationで追加読み込み
  • Pull-to-Refreshでリストを引っ張って更新

サンプルソースコードに関して

キャプチャ

 リスト画面 詳細画面
Simulator Screen Shot - iPhone 11 - 2020-06-08 at 03.05.12.png Simulator Screen Shot - iPhone 11 - 2020-06-08 at 03.05.31.png

アーキテクチャは、MVVMを取り入れています。

環境やバージョンについて
Xcode 12.4
Swift 5.3.2
macOS Catalina 10.15.7

使用ライブラリ
CocoaPod
Carthage
APIKit
RxSwift 6.0.0
RxCocoa
RxSwiftExt
RxTest
RxBloking

インクリメンタルサーチ

インクリメントサーチの実装には、日本語などの変換が必要な文字の場合

  • 確定前(入力中)に検索させるか
  • 確定後に検索させるか

の2通りあります。
今回は、変換する必要がない半角文字を対象にしており
日本語などの変換が必要な文字の場合、確定後にリクエストが走ります。

ページネーション

機能としては、ごく普通のページネーションです。
20件取得後、リストの最後までスクロールすると、APIを叩いて、20件取得するような機能です。

プルトゥリフレッシュ

機能としては、こちらもごく普通のプルトゥリフレッシュです。
リストを下にドラッグすると、現在のキーワードで、リロードします。

WikipediaのAPI

このサンプルソースは、WikipediaのAPI(MediaWiki API)を使います。
MediaWiki APIについては、MediaWiki API ヘルプを参照ください。

一覧画面のデータ取得

ja.wikipedia.org/w/api.php?continue=-||&srlimit=10&action=query&srsearch=Swift&sroffset=0&format=json&list=search

{
  "batchcomplete": "",
  "continue": {
    "sroffset": 10,
    "continue": "-||"
  },
  "query": {
    "searchinfo": {
      "totalhits": 1222,
      "suggestion": "sweet",
      "suggestionsnippet": "<em>sweet</em>"
    },
    "search": [
      {
        "ns": 0,
        "title": "スイフト(テスト)",
        "pageid": 499755,
        "size": 4190,
        "wordcount": 383,
        "snippet": "スイフト、スウィフト(英語 <span class=\"searchmatch\">swift</span>, <span class=\"searchmatch\">SWIFT</span>)の原義は、「迅速」。転じて英語圏の姓など。 グレアム・スウィフト - イギリスの作家。 ジョナサン・スウィフト - アイルランドの作家。『ガリヴァー旅行記』の作者として著名。 ジョン・トランブル・スウィフト - アメリカの教育者。明治時代の日本で活動。",
        "timestamp": "2020-01-18T06:47:30Z"
      },
      {
        "ns": 0,
        "title": "通貨",
        "pageid": 4126,
        "size": 33334,
        "wordcount": 3469,
        "snippet": "『デフレと円高の何が「悪」か』 光文社〈光文社新書〉、2010年、188頁。 ^ http://www.<span class=\"searchmatch\">swift</span>.com/about_<span class=\"searchmatch\">swift</span>/shownews?param_dcr=news.data/en/<span class=\"searchmatch\">swift</span>_com/2014/PR_RMB_Nov_Dec.xml RMB breaks into the",
        "timestamp": "2020-02-27T12:30:37Z"
      }
    ]
  }
}

ページングを行うには、取得したJsonに、ページングをするためのパラメータがあらかじめ付与されているので、それを参照し、値もそのままで、クエリにしてリクエストします。

例えば

srlimit=10&sroffset=0

※srlimitは取得件数。sroffsetはオフセット
オフセット0で、取得件数が10でリクエストしたので、
以下のように付与されます。

"continue": {
  "sroffset": 10,
  "continue": "-||"
}

次のデータを取得するには、sroffsetが10なので、上記のjsonの値をそのままクエリにして、リクエストします

srlimit=10&sroffset=0&continue=-||

※continue=-||は、ページングのために連続したデータを取得するのに必須なパラメータになります。

詳細画面のデータ取得

一覧画面で取得したjsonの中の該当するデータのpageidでリクエストをします。

ja.wikipedia.org/w/api.php?prop=extracts&pageids=3000778&action=query&exintro=explaintext&format=json
{
    "batchcomplete": "",
    "warnings": {
        "extracts": {
            "*": "HTML may be malformed and/or unbalanced and may omit inline images. Use at your own risk. Known problems are listed at https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:TextExtracts#Caveats."
        }
    },
    "query": {
        "pages": {
            "3000778": {
                "pageid": 3000778,
                "ns": 0,
                "title": "Swift (プログラミング言語)",
                "extract": "<p><b>Swift</b>(スウィフト)は、アップルのiOSおよびmacOS、Linuxで利用出来るプログラミング言語である。Worldwide Developers Conference (WWDC) 2014で発表された。アップル製OS上で動作するアプリケーションの開発に従来から用いられていたObjective-CやObjective-C++、C言語と共存できるように、共通のObjective-Cランタイムライブラリが使用されている。 </p><p>Swiftは、<span title=\"リンク先の項目はまだありません。新規の執筆や他言語版からの翻訳が望まれます。\">動的ディスパッチ</span>や動的バインディング等のObjective-Cの特長を受け継いでいる一方で、Objective-Cより「安全」にバグを捕捉できることも意図している。また、タイプや構造体、クラスに適用可能なプロトコルによるシステムの拡張性の概念は「プロトコル指向プログラミング」と呼ばれる 。 </p><p>Swiftは、マルチパラダイムのコンパイラプログラミング言語であるが、XcodeのPlaygroundsの上やターミナルでインタラクティブにデバッグする事が可能である。 </p><p>LLVMコンパイラが使われており、ライブコーディングに対応していることが特徴。 </p>"
            }
        }
    }
}

コードの解説

インクリメンタルリサーチのロジックは、ほぼ、ViewModelで書いているので、ViewModelのコードを解説します。

FirstViewModel.swift
// MARK: - Private
private extension FirstViewModel {
    struct RequestParameter {
        let word: String
        let offset: Int
    }
}

extension FirstViewModel: ViewModelType {
    struct Input {
        let viewDidReachBottom: Observable<Void>
        let pullToRefreshTrigger: Observable<Void>
        let incrementalSearchTrigger: Observable<String>
    }

    struct Output {
        let loading: Observable<Bool>
        let refreshing: Observable<Bool>
        let searchStream: Observable<[WikipediaSearch]>
        let error: Observable<Error>
    }

    func transform(input: Input) -> Output {
        let session = dependencies.session

        let requestParameterRelay = BehaviorRelay<RequestParameter>(value: RequestParameter(word: "", offset: 0))

        let continueRelay = BehaviorRelay<WikipediaSearchResponse.Continue?>(value: nil)

        let incrementalSearchTrigger = input.incrementalSearchTrigger
            .debounce(.milliseconds(300), scheduler: scheduler)
            .filter { $0.count >= 3 }
            .distinctUntilChanged()
            .map { RequestParameter(word: $0, offset: 0) }
            .share()

        let reloadRequestTrigger = input.pullToRefreshTrigger
            .withLatestFrom(requestParameterRelay.compactMap { $0 })
            .map { RequestParameter(word: $0.word, offset: 0) }
            .share()

        let paginationRequestTrigger = input.viewDidReachBottom
            .withLatestFrom(Observable.combineLatest(requestParameterRelay.compactMap { $0 }, continueRelay.compactMap { $0 }) )
            .map { RequestParameter(word: $0.word, offset: $1?.sroffset ?? 0) }
            .share()

        let load = PublishRelay.merge(
                incrementalSearchTrigger,
                reloadRequestTrigger,
                paginationRequestTrigger
            )
            .filter { !$0.word.isEmpty }
            .throttle(.milliseconds(300), latest: false, scheduler: scheduler)
            .share()

        load
            .bind(to: requestParameterRelay)
            .disposed(by: disposeBag)

        let sequence = load
            .flatMapLatest { arg -> Observable<Event<WikipediaSearchResponse>> in
                return session.rx.send(WikipediaSearchRequest(word: arg.word, offset: arg.offset))
                    .asObservable()
                    .materialize()
            }
            .share()

        let apiResponseStream = sequence.compactMap { $0.event.element }.share()
        apiResponseStream
            .map { $0.continue }
            .bind(to: continueRelay)
            .disposed(by: disposeBag)

        let searchStream = apiResponseStream
            .scan([]) { $1.continue?.sroffset == $1.query.search.count || $1.continue == nil ? $1.query.search : $0 + $1.query.search }
            .share(replay: 1)

        let loading = PublishRelay.merge(load.map { _ in true }, sequence.map { _ in false }).startWith(false)
        let refreshing = PublishRelay.merge(reloadRequestTrigger.map { _ in true }, sequence.map { _ in false }).startWith(false)
        let error = sequence.errors().share()

        return Output(
            loading: loading,
            refreshing: refreshing,
            searchStream: searchStream,
            error: error
        )
    }
}

全体的な流れで言うと、リクエストパラメータを各トリガー(インクリメンタルサーチ、リロード、ページネーション)のストリームに流し、APIリクエストを実行後、レスポンスに入っている次のデータを取得する為のオフセットを保持。
その後、ページネーションの場合だけ、APIから前回までに取得したレスポンスをストリームから取得して、今回取得したレスポンスとマージするという流れです。

トリガーとリクエストパラメータ

各トリガーをどのようにうまく纏めるかを工夫してみましたので、その部分を掘り下げ。

        let requestParameterRelay = BehaviorRelay<RequestParameter>(value: RequestParameter(word: "", offset: 0))

        let continueRelay = BehaviorRelay<WikipediaSearchResponse.Continue?>(value: nil)

        let incrementalSearchTrigger = input.incrementalSearchTrigger
            .debounce(.milliseconds(300), scheduler: scheduler)
            .filter { $0.count >= 3 }
            .distinctUntilChanged()
            .map { RequestParameter(word: $0, offset: 0) }
            .share()

        let reloadRequestTrigger = input.pullToRefreshTrigger
            .withLatestFrom(requestParameterRelay.compactMap { $0 })
            .map { RequestParameter(word: $0.word, offset: 0) }
            .share()

        let paginationRequestTrigger = input.viewDidReachBottom
            .withLatestFrom(Observable.combineLatest(requestParameterRelay.compactMap { $0 }, continueRelay.compactMap { $0 }) )
            .map { RequestParameter(word: $0.word, offset: $1?.sroffset ?? 0) }
            .share()

ポイントは、以下
- インクリメンタルサーチ(incrementalSearchTrigger)にdebounceをかけているのは、文字の入力が早い時に、無駄な値がストリームに流れないように
- インクリメンタルサーチの場合、キーワードを入力された文字にして、オフセットを0にする
- リロード(reloadRequestTrigger)の場合、キーワードだけ引き継いで、オフセットを0にする
- ページネーション(paginationRequestTrigger)の場合、キーワードだけ引き継いで、前回取得したAPIのレスポンスにあるオフセットの値をセットする
- リクエストパラメータを、requestParameterRelayに保持
- APIレスポンスに含まれるContinueを、ページネーションのために、continueRelayで保持
- リロードとページネーションの場合、前回のリクエストパラメータをrequestParameterRelayから取得し、トリガーが実行された時に流れるように、withLatestFromでセット

ページング

Wiki

        let searchStream = apiResponseStream
            .scan([]) { $1.continue?.sroffset == $1.query.search.count || $1.continue == nil ? $1.query.search : $0 + $1.query.search }
            .share(replay: 1)
  • scanで、前回ストリームに流れたレスポンスデータと、新しく取得したレスポンスデータを合成する
  • レスポンスにcontinueがない場合は、ページングはなし

その他

RxTestやRxBlockingを使ったユニットテスト、Page Object Patternを使ったUIテストなどもサンプルソースコードでやってみたので、もし良ければ、そちらもご参照ください。

2
3
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
2
3