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

## インクリメンタルサーチとは
> 検索したい単語をすべて入力した上で検索するのではなく、入力のたびごとに即座に候補を表示させる検索方法

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


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

## ソースコード

```swift
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](https://qiita-image-store.s3.amazonaws.com/0/130326/1f2b43a6-1064-e385-2eae-e7fca7a50ecd.gif)

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

```swift
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秒前の文字列と同じだった時には無視をするため無駄なリクエストが呼ばれることを防いでくれます。

```swift
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の中に直接入れてもなんら問題ありません :ok_woman: (直接入れると`methodInvoked`の部分を変える必要はあります)

```swift
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.

```swift
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:)](https://developer.apple.com/documentation/uikit/uisearchbardelegate/1624328-searchbar)
[Incremental Search with multibyte text in RxSwift/RxCocoa](https://medium.com/@ryo31330023/incremental-search-with-multibyte-text-in-rxswift-rxcocoa-8c2d1f3540b)
[RxSwift](https://github.com/ReactiveX/RxSwift)
[RxCocoa](https://github.com/ReactiveX/RxSwift/tree/master/RxCocoa)