UISearchControllerのインクリメンタルサーチをよりインクリメンタルにする

iOS では [UISearchController](https://developer.apple.com/documentation/uikit/uisearchcontroller) を使用することで、ナビゲーションバーへの検索フィールドの統合や、入力イベントに応じた検索処理の実施と結果の表示といったインクリメンタルサーチの流れの実装が非常に楽になります。ただ、私はそのインクリメンタルサーチの動作に **違和感** がありました。

### インクリメンタルサーチのサンプル

インクリメンタルサーチの違和感について言及するためのサンプルコードについて説明します。

#### メイン画面

次のコードは果物をリスト表示するメイン画面 `MainViewController` の実装です。ここに `UISearchController` による検索バーが組み込まれています。

- `UISearchController` の検索結果画面として後述の `ResultViewController` を登録してあります
- `UISearchResultsUpdating` をトリガーに入力されたキーワードを含む くだもの を検索して、結果画面に表示させます

```swift:MainViewController.swift
import UIKit

class MainViewController: UITableViewController, UISearchResultsUpdating {
    
    private let items: [String] = [
        "🍎りんご", "🍊みかん", "🍇ぶどう", "🍒さくらんぼ",
        "🍓いちご", "🍉スイカ", "🍌バナナ", "🍏あおりんご",
        "🍐なし", "🍋レモン", "🍈メロン", "🍑もも",
        "🥭マンゴー", "🍍パイナップル", "🥥ココナッツ", "🥝キウイ",
    ]
    
    private var searchController: UISearchController!
    private var resultsController: ResultsViewController!

    override func viewDidLoad() {
        super.viewDidLoad()

        definesPresentationContext = true
        
        resultsController = ResultsViewController()

        searchController = UISearchController(searchResultsController: resultsController)
        searchController.dimsBackgroundDuringPresentation = true
        searchController.hidesNavigationBarDuringPresentation = true
        searchController.searchResultsUpdater = self

        navigationItem.hidesSearchBarWhenScrolling = false
        navigationItem.searchController = searchController
        
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
    }
    
    func updateSearchResults(for searchController: UISearchController) {
        if let keyword = searchController.searchBar.text, !keyword.isEmpty {
            resultsController.items = items.filter { $0.contains(keyword) }
        } else {
            resultsController.items = []
        }
    }
    
    override func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }
    
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return items.count
    }
    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let item = items[indexPath.row]
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = item
        return cell
    }

}
```

#### 結果画面

次のコードは果物を検索した結果を表示する結果画面 `ResultViewController ` の実装です。メイン画面から受け取った結果を表示するだけです。

```swift:ResultViewController
class ResultsViewController: UITableViewController {
    
    var items: [String] = [] {
        didSet {
            if isViewLoaded {
                tableView.reloadData()
            }
        }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
    }
    
    override func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }
    
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return items.count
    }
    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let item = items[indexPath.row]
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = item
        return cell
    }

}
```

#### 実行結果

このサンプルコードの実行した結果が次の動画です。

![before.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/16508/54b382c4-d219-acab-61e2-1b3661943b3c.gif)

### インクリメンタルサーチの違和感の正体

上記の動画で私が感じた **違和感** の正体は、検索キーワードの入力が変換中の場合には、検索結果が表示されていないところです。デバッグしてみると、`UISearchResultsUpdating` の `updateSearchResults(for:)` は変換中には呼び出されていません。

**インクリメンタルじゃない！**

そこで私が検索機能をよく利用する iOS 標準アプリ App Store と iTunes を確認してみたところ、二つとも変換中の入力でも検索結果が表示されました（インクリメンタル！）。なので、私の中での標準的な動きはこれらのアプリの動作だったのです。

![appstore.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/16508/26159cdd-c94f-4a98-9ad4-7ab1199626d3.gif)

### インクリメンタルサーチをよりインクリメンタルにする

ということで、App Store や iTunes アプリのように、よりインクリメンタルなインクリメンタルサーチを `UISearchController` で実装してみます。`UISearchController` にこれを実現するようなオプション等がないため、結局のところ `UISearchBarDelegate` の `searchBar(_:, shouldChangeTextIn:, replacementText:)`  イベントを拾って実現するしかありませんでした。

つまり、下記のように修正します。

```swift:MainViewController.swift
//class MainViewController: UITableViewController, UISearchResultsUpdating {
class MainViewController: UITableViewController, UISearchBarDelegate {
```

```swift:MainViewController.swift
        //searchController.searchResultsUpdater = self
        searchController.searchBar.delegate = self
```

```swift:MainViewController.swift

//    func updateSearchResults(for searchController: UISearchController) {
//        if let keyword = searchController.searchBar.text, !keyword.isEmpty {
//            resultsController.items = items.filter { $0.contains(keyword) }
//        } else {
//            resultsController.items = []
//        }
//    }

    func searchBar(_ searchBar: UISearchBar, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
        // searchResultsController を強制的に表示する
        // (検索バーの日本語変換待ち状態の入力のみだと、searchResultsController 自体が表示されないため）
        searchController.searchResultsController?.view.isHidden = false
        
        // 変換中のテキストも正しく取得できるようにするために遅延させる
        DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in
            guard let self = self else { return }
            if let keyword = searchBar.text, !keyword.isEmpty {
                self.resultsController.items = self.items.filter { $0.contains(keyword) }
            } else {
                self.resultsController.items = []
            }
        }
        
        return true
    }
```

ポイントとしては、検索バーに変換中の入力しかない状態だと、searchResultsController 自体が表示されないため、強制的に表示するようにしています。

また、`searchBar(_:, shouldChangeTextIn:, replacementText:)` には [SearchBarに入力中の文字を使い、リストから候補を探したい場合（インクリメンタルサーチ）](http://mob-wasao.hatenablog.jp/entry/2017/03/17/160113) の記事にある通り、変換中の文字列を正しく判断できないという問題があるため回避策を入れてあります。

![after.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/16508/aab107d3-2bcb-11b1-1c2d-e16f54d13152.gif)

はい、よりインクリメンタルなインクリメンタルサーチになりました！

### さいごに

この記事を書いてから、ふと気になって他の iOS 標準アプリをいくつか（メッセージ、連絡先、Apple Store, iTunes Storeなど）確認してみたのですが、それらは `UISearchController` と同じ動作になっていました。たまたま私がよく検索を利用するアプリだけが特別対応されていたということです。なんと。