RxSwift/RxCocoaを使ってインクリメンタルサーチを実装する方法

2017.12.19

iOSでインクリメンタルサーチしたい

今回はiOSでインクリメンタルサーチをしたいケースがあり、色々と調べたところRxSwift/RxCoccoaを使うと少ないコードで実現できるとわかったので ブログに残しておこうと思います。

まずはインクリメンタルサーチとはなんぞや?という方のために簡単な説明を。

インクリメンタルサーチ(incremental search)とは、アプリケーションにおける検索方法のひとつ。検索したい単語をすべて入力した上で検索するのではなく、入力のたびごとに即座に候補を表示させる。逐語検索、逐次検索とも。

Wikipediaより引用

Safariでも検索ワードを入れると確定ボタンをタップする前に即座に候補を表示してくれますね。あれです。

これをやりたかった

先に今回やりたかったインクリメンタルサーチのGIFを載せておきます。

画面上部にUISearchBarがあり、その下にUITableViewがあるシンプルな画面です。
ひらがなを入力したら確定ボタンをタップする前にデータをフィルタリングして表示しています。

検証環境

本エントリは以下の環境で検証を行っています。

  • macOS Sierra バージョン 10.12.6
  • Xcode Version 9.2 (9C40b)
  • Swift 4
  • iPhone X シミュレーター iOS 11.2
  • CocoaPods 1.3.1

RxSwift/RxCocoaを導入

実装にあたり、RxSwift/RxCocoaが必要なのでインストールしておきましょう。 筆者はCocoaPodsでインストールしました。 以下はPodfileの例です。

target 'ターゲット名' do
  use_frameworks!
  pod 'RxSwift'
  pod 'RxCocoa'
end

pod installしたところ、筆者の環境ではRxSwift、RxCocoa共にバージョン4.0.0がインストールされました。

インクリメンタルサーチの実装

以下がViewControllerのコード全体です。全体だとそれなりのコード量ですが、 インクリメンタルサーチに関する部分はsetupSearchBar()(ハイライト部分)だけです。

import UIKit
import RxSwift
import RxCocoa

struct User {
    let name: String
}

class ViewController: UIViewController {
    @IBOutlet weak var searchBar: UISearchBar!
    @IBOutlet weak var tableView: UITableView!

    private let allUsers = [
        User(name: "かとう"),
        User(name: "たなか"),
        User(name: "ひらや"),
        User(name: "おおはし"),
        User(name: "やまもと"),
        User(name: "ふかさわ"),
        User(name: "さいとう"),
        User(name: "くどう"),
        User(name: "すわ"),
        User(name: "わたなべ")
    ]

    private var filteredUsers = [User]()

    private let disposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()

        setup()
    }
}

// MARK: - Private
private extension ViewController {
    func setup() {
        filteredUsers = allUsers
        tableView.dataSource = self
        tableView.keyboardDismissMode = .onDrag

        setupSearchBar()
    }

    func setupSearchBar() {
        searchBar.delegate = self

        let debounceInterval = 0.3

        // インクリメンタルサーチのテキストを取得するためのObservable
        let incrementalSearchTextObservable = rx
            // UISearchBarに文字列入力中に呼ばれるUISearchBarDelegateのメソッドをフック
            .methodInvoked(#selector(UISearchBarDelegate.searchBar(_:shouldChangeTextIn:replacementText:)))
            // searchBar.textの値が確定するまで0.3待つ
            .debounce(debounceInterval, scheduler: MainScheduler.instance)
            // 確定したsearchBar.textを取得
            .flatMap { [unowned self] _ in Observable.just(self.searchBar.text ?? "") }

        // UISearchBarのクリア(×)ボタンや確定ボタンタップにテキストを取得するためのObservable
        let textObservable = searchBar.rx.text.orEmpty.asObservable()

        // 2つのObservableをマージ
        let searchTextObservable = Observable.merge(incrementalSearchTextObservable, textObservable)
            // 初期化時に空文字が流れてくるので無視
            .skip(1)
            // 0.3秒経過したら入力確定とみなす
            .debounce(debounceInterval, scheduler: MainScheduler.instance)
            // 変化があるまで文字列が流れないようにする、つまり連続して同じテキストで検索しないようにする。
            .distinctUntilChanged()

        // subscribeして流れてくるテキストを使用して検索
        searchTextObservable.subscribe(onNext: { [unowned self] text in
            if text.isEmpty {
                // 空文字の場合は全件表示
                self.filteredUsers = self.allUsers
            } else {
                // 入力文字列がある場合はデータをフィルタリングして表示
                self.filteredUsers = self.allUsers.filter { $0.name.contains(text) }
            }
            self.tableView.reloadData()
        }).disposed(by: disposeBag)
    }
}

// MARK: - UITableViewDataSource
extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return filteredUsers.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
        cell.textLabel?.text = filteredUsers[indexPath.row].name
        return cell
    }
}
// MARK: - UISearchBarDelegate
extension ViewController: UISearchBarDelegate {
    func searchBar(_ searchBar: UISearchBar, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
        return true
    }
}

解説

コメントにも説明がありますが、setupSearchBar()部分の実装ポイントを解説します。

  • methodInvokedを使って、UISearchBarに文字を入力するたびに呼ばれるUISearchBarDelegateのsearchBar(_:shouldChangeTextIn:replacementText:) メソッドをフックし、少し待つことでUISearchBarに入力中のテキストがsearchBar.textとして取得できる。
    • そのためにsearchBar(_:shouldChangeTextIn:replacementText:)を実装してtrueを返す必要がある。
  • 確定ボタンやクリアボタンタップ時のUISearchBarのテキストを取得するためにはsearchBar.rx.textを使用する。
  • 上記2つのObservableをマージして1つのObservableに。
  • マージしたObservableをsubscribeしてテキストを取得して、検索。

おわりに

今回はRxSwift/RxCocoaを使ってインクリメンタルサーチを実装する方法をご紹介しました。
どなたかの参考になれば幸いです。

参考記事