インクリメンタルサーチとは
検索したい単語をすべて入力した上で検索するのではなく、入力のたびごとに即座に候補を表示させる検索方法
こちらの記事がわかりやすそうです(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)
}
}
実行結果
重要なのは以下の部分です。
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
の特性を維持したままテキスト情報の取得を行うことができます。
例としてextension
でControlProperty
化していますが、 UIViewControllerの中に直接入れてもなんら問題ありません (直接入れると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処理も内包したい方は是非こちらの方法も試してみてください
参考
searchBar(_:shouldChangeTextIn:replacementText:)
Incremental Search with multibyte text in RxSwift/RxCocoa
RxSwift
RxCocoa