post Image
インクリメンタルサーチ【RxSwift/RxCocoa編】

インクリメンタルサーチとは

検索したい単語をすべて入力した上で検索するのではなく、入力のたびごとに即座に候補を表示させる検索方法

こちらの記事がわかりやすそうです(iOSで検索ワードの入力中に検索結果を表示するインクリメンタルサーチの導入方法)

UISearchBar

検索にはUISearchBarを使いますが、1つインクリメンタルサーチをするにおいて辛い部分があります。それは入力中の文字を即時取得できないことです。(変換する必要がない半角文字を除く)
しかしこれはデリゲートメソッドと遅延処理を組み合わせることで取得することができます、また、Rxを使うことでより効率的に表現できます。

ソースコード

import UIKit
import RxSwift
import RxCocoa

final class ViewController: UIViewController {

    @IBOutlet private weak var searchBar: UISearchBar! {
        didSet {
            searchBar.delegate = self
        }
    }

    @IBOutlet private weak var tableView: UITableView! {
        didSet {
            tableView.register(UITableViewCell.self, forCellReuseIdentifier: "CELL")
        }
    }

    // 検索用サジェスト(テストデータ)
    private let item = ["あいうえお","いうえおあ","うえおあい","おあいいうえ"]
    private let disposeBag = DisposeBag()
    private let dataSource = DataSource()

    // インクリメンタルサーチするための検索ワード
    private var incrementalText: Driver<String> {
        return rx
            .methodInvoked(#selector(UISearchBarDelegate.searchBar(_:shouldChangeTextIn:replacementText:)))
            .debounce(0.2, scheduler: MainScheduler.instance)
            .flatMap { [weak self] _ -> Observable<String> in .just(self?.searchBar.text ?? "") }
            .distinctUntilChanged()
            .asDriver(onErrorJustReturn: "")
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        incrementalText
            .flatMap { [weak self] text -> Driver<[String]> in
                guard let me = self else { return .just([]) }
                return .just(me.item.filter { $0.contains(text.lowercased()) })
            }
            .drive(tableView.rx.items(dataSource: dataSource))
            .disposed(by: disposeBag)
    }
}

extension ViewController: UISearchBarDelegate {
    func searchBar(_ searchBar: UISearchBar, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
        return true
    }
}

final class DataSource: NSObject, UITableViewDataSource, RxTableViewDataSourceType {

    typealias  Element = [String]

    private var items: Element = []

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "CELL", for: indexPath)
        cell.textLabel?.text = "\(items[indexPath.row])"
        return cell
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return items.count
    }

    func tableView(_ tableView: UITableView, observedEvent: Event<Element>) {
        UIBindingObserver(UIElement: self) { (dataSource, items) in
            if dataSource.items == items { return }
            dataSource.items = items
            tableView.reloadData()
        }
        .on(observedEvent)
    }
}

実行結果

inc-search.gif

重要なのは以下の部分です。

private var incrementalText: Driver<String> {
   return rx
      .methodInvoked(#selector(UISearchBarDelegate.searchBar(_:shouldChangeTextIn:replacementText:)))
      .debounce(0.2, scheduler: MainScheduler.instance)
      .flatMap { [weak self] _ -> Observable<String> in .just(self?.searchBar.text ?? "") }
      .distinctUntilChanged()
      .asDriver(onErrorJustReturn: "")
}

methodInvokedは指定した関数が呼び出し終わった時に通知されるため、UISearchBarのデリゲートメソッドの処理が終わった後をフックすることができます。

その次にあるdebounceには2つの意図があります、1つは0.2秒間のユーザー入力を無視して連続入力はサジェスト取得を行わないようにフィルタリングすることと、もう一つは入力が未確定の状態のテキストが現在のテキストとして更新されるのを待つためです。上記のUISearchBarのデリゲートでtrueを返すとその後入力中の文字が現在のテキストとして更新されますが、即時ではないため待つ必要があります。そのためdebounceを使うことでdelay等を使わずに一石二鳥に処理することができます。

また、destinctUntilChangedを入れることで0.2秒以内になにかを入力して、それを削除してその結果が0.2秒前の文字列と同じだった時には無視をするため無駄なリクエストが呼ばれることを防いでくれます。

func tableView(_ tableView: UITableView, observedEvent: Event<Element>) {
        UIBindingObserver(UIElement: self) { (dataSource, items) in
            if dataSource.items == items { return } //ここ
            dataSource.items = items
            tableView.reloadData()
        }
        .on(observedEvent)
    }

入力が変わったとしてもサジェストの結果が変わらない場合にはreloadDataをかけないことでアプリケーションの負荷も軽減することができます。

[追記1]
本記事の例ではUISearchBarのクリアボタンや音声入力などのUISearchBar(_:shouldChangeTextIn:replacementText:)を介さないテキスト情報はincrementalTextから取得されません。そのため以下のように既存のrx.textの通知タイミングを合わせることでincrementalTextの特性を維持したままテキスト情報の取得を行うことができます。
例としてextensionControlProperty化していますが、 UIViewControllerの中に直接入れてもなんら問題ありません :ok_woman: (直接入れるとmethodInvokedの部分を変える必要はあります)

extension Reactive where Base: UISearchBar {

    var incrementalText: ControlProperty<String?> {
        let delegates: Observable<Void> = Observable.deferred { [weak searchBar = self.base as UISearchBar] () -> Observable<Void> in
            guard let searchBar = searchBar,
               let owner = searchBar.delegate as? UIViewController else { return .empty() }

            let shouldChange = owner.rx
                .methodInvoked(#selector(UISearchBarDelegate.searchBar(_:shouldChangeTextIn:replacementText:)))
                .map { _ in ()}

            return Observable
                .of(shouldChange, searchBar.rx.text.map { _ in () }.asObservable())
                .merge()
        }

        let source = delegates
            .debounce(0.2, scheduler: MainScheduler.instance)
            .flatMap { [weak self = self.base as UISearchBar] _ -> Observable<String?> in .just(self?.text) }
            .distinctUntilChanged { $0 == $1 }

        let bindingObserver = UIBindingObserver(UIElement: self.base) { (searchBar, text: String?) in
            searchBar.text = text
        }

        return ControlProperty(values: source, valueSink: bindingObserver)
    }
}

e.g.

searchBar.rx
            .incrementalText
            .do(onNext: { text in
                print("input=\(text)")
            })
            .flatMap { [weak self] text -> Observable<[String]> in
                guard let me = self else { return .just([]) }
                return .just(me.item.filter { $0.contains(text?.lowercased() ?? "") })
            }
            .bind(to: tableView.rx.items(dataSource: dataSource))
            .disposed(by: disposeBag)

[追記2]
http://qiita.com/rinov/items/1f6e57e376af185d0561#comment-df35d618c8fcb05e11ed

@toshi0383 さんのコメントにてUIViewControllerでのdelegateを使わないパターンも教えていただいたので、UISearchBar自体にdelegate処理も内包したい方は是非こちらの方法も試してみてください :coffee:

参考

searchBar(_:shouldChangeTextIn:replacementText:)
Incremental Search with multibyte text in RxSwift/RxCocoa
RxSwift
RxCocoa


『 Swift 』Article List