iOSアプリの検索ビューにおけるタグ(UISearchToken)の追加

iOSの検索ビュー内でタグを追加し、検索対象のカテゴリーを指定することができます。

<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/d78f9202-171e-d824-0fe0-33dec0757704.png">

このタグは検索トークンと呼ばれるものです。

ここでは、検索トークンを作成し、それを検索バーに追加する方法を説明します。 さらに、SwiftUIアプリに検索トークンを使った検索バーを加えることについても説明します。

## サーチトークンの作成

`UISearchToken`プロパティを使用し、サーチトークンを作成することができます。このオブジェクトは、`UIImage`アイコンやタイトルで初期化することができます。

[Apple Developer Documentation](https://developer.apple.com/documentation/uikit/uisearchtoken)

また、トークンの識別子を指定する必要があります（`representedObject`プロパティを使用）。この変数はどのような型でもよく、コードで自分が参照するために使います。一般的に、この変数には文字列の識別子を設定します。

例えば、ユーザーが`SwiftUI`についての記事を検索している場合、次のように`UISearchToken`オブジェクトを初期化することができます:

```swift
var token = UISearchToken(icon: UIImage(systemName: "swift"), text: "SwiftUI")
token.representedObject = "#swiftui"
```

識別子は検索トークンから後で読み取ることができます。

```swift
if let identifier = token.representedObject as? String {
    print(identifier)
}
```

## 検索トークンを検索バーに追加する

`UISearchController`では、検索トークンを設定できます。

```swift
searchController.searchBar.searchTextField.tokens = searchTokens
```

また、検索トークンを追加または削除したり、テキスト内にあるトークンの位置を取得したり、指定されたテキスト位置の間にある全トークンを取得したりするなどの追加機能もあります。

```swift
open var tokens: [UISearchToken]

open func insertToken(_ token: UISearchToken, at tokenIndex: Int)

open func removeToken(at tokenIndex: Int)

open func positionOfToken(at tokenIndex: Int) -> UITextPosition

open func tokens(in textRange: UITextRange) -> [UISearchToken]
```

## UIKitアプリに検索トークンを追加する

UIKitアプリに検索トークンを追加するのは簡単です。`UISearchController`を作成し、`searchController.searchBar.searchTextField.tokens`変数を使用して検索トークンをそのビューコントローラに割り当てるだけです。

## SwiftUIアプリに検索トークンを追加する

SwiftUIには、新たな`.searchable`ビューモディファイアがあります。しかし、このモディファイアは検索トークンの追加をサポートしていません。そのため、`UIViewControllerRepresentable`構造を実装する必要があります。

### UIKitコンポーネントをSwiftUIのナビゲーションバーに追加する

検索バー（UIKitコンポーネント）を単に表示するだけではなく、SwiftUIのナビゲーションバーに直接追加したいと思います。

```swift
class NavBarEmbeddedSearch: UIViewController {
    
    let searchController = UISearchController()
    
    override func viewDidLoad() {
        searchController.hidesNavigationBarDuringPresentation = true
        searchController.obscuresBackgroundDuringPresentation = false
    }
    
    override func viewWillAppear(_ animated: Bool) {
        self.parent?.navigationItem.searchController = searchController
    }
    
}
```

`viewDidLoad`関数内では、検索バーのUIに関する基本的な設定を行います。

このビューコントローラが画面に表示されるとき（`viewWillAppear`が呼び出されるとき）、検索コントローラが親ナビゲーションバー（SwiftUIが管理するもの）に割り当てられます。

### SearchBar（サーチバー）構造体の作成

次に、`SearchBar`の基本構造を作成します。これはタイプ`UIViewControllerRepresentable`に準拠するもので、UIKitをSwiftUIに移植する（利用できるようにする）ことを意味します。

```swift
fileprivate struct SearchBar: UIViewControllerRepresentable {
    
    @Binding var searchText: String
    @Binding var searchTokens: [UISearchToken]
    
    init(searchText: Binding<String>, searchTokens: Binding<[UISearchToken]>) {
        self._searchText = searchText
        self._searchTokens = searchTokens
    }
    
    func makeUIViewController(context: Context) -> NavBarEmbeddedSearch {
        // TODO 1
    }
    
    func updateUIViewController(_ controller: NavBarEmbeddedSearch, context: Context) {
        
    }
    
    func makeCoordinator() -> Coordinator {
        return Coordinator(searchText: $searchText, searchTokens: $searchTokens)
    }
    
    class Coordinator: NSObject, UISearchResultsUpdating {
        
        @Binding var searchText: String
        @Binding var searchTokens: [UISearchToken]
        
        init(searchText: Binding<String>, searchTokens: Binding<[UISearchToken]>) {
            self._searchText = searchText
            self._searchTokens = searchTokens
        }
        
        func updateSearchResults(for searchController: UISearchController) {
            // TODO 2
        }
    }
    
}
```

上記のコードでご覧のように、`SearchBar`構造体には検索テキストのバインディング変数と検索トークンが格納されることになります。

また、デリゲートとして動作する`Coordinator`クラスもあり、検索バーから更新情報を受け取ります（ユーザーが検索テキストを入力したときやユーザーが検索トークンを削除したときなど）。

### 検索バーの初期化

上記のコードの`// TODO 1`で、検索コントローラーの初期化を行います。

```swift
func makeUIViewController(context: Context) -> NavBarEmbeddedSearch {
        let controller = NavBarEmbeddedSearch()
        controller.searchController.searchBar.searchTextField.tokens = searchTokens
        controller.searchController.searchResultsUpdater = context.coordinator
        return controller
    }
```

ここでは、サーチトークンに提供された値を設定します。

また、検索結果アップデータ`searchResultsUpdater`変数には、作成した`Coordinator`を設定します。これにより、検索文字が変更されたことがわかるようになります。 

### 検索文字の変更でSwiftUIのバインディング変数を更新する

検索文字に変更があった場合、`Coordinator`オブジェクト内の`func updateSearchResults(for searchController: UISearchController)`関数が呼び出されます。

上記のコードの`// TODO 2`コードブロックの中で、更新された検索文字でSwiftUIのバインディング変数を更新します。

```swift
guard let text = searchController.searchBar.text else { return }
self.searchText = text
self.searchTokens = searchController.searchBar.searchTextField.tokens
```

### 完成したコード

こちらが、完成した`SearchBar`のコードです

```swift
class NavBarEmbeddedSearch: UIViewController {
    
    let searchController = UISearchController()
    
    override func viewDidLoad() {
        searchController.hidesNavigationBarDuringPresentation = true
        searchController.obscuresBackgroundDuringPresentation = false
    }
    
    override func viewWillAppear(_ animated: Bool) {
        self.parent?.navigationItem.searchController = searchController
    }
    
}

fileprivate struct SearchBar: UIViewControllerRepresentable {
    
    @Binding var searchText: String
    @Binding var searchTokens: [UISearchToken]
    
    init(searchText: Binding<String>, searchTokens: Binding<[UISearchToken]>) {
        self._searchText = searchText
        self._searchTokens = searchTokens
    }
    
    func makeUIViewController(context: Context) -> NavBarEmbeddedSearch {
        
        let controller = NavBarEmbeddedSearch()
        
        controller.searchController.searchResultsUpdater = context.coordinator
        
        controller.searchController.searchBar.searchTextField.tokens = searchTokens
        
        return controller
    }
    
    func updateUIViewController(_ controller: NavBarEmbeddedSearch, context: Context) {
        
    }
    
    func makeCoordinator() -> Coordinator {
        return Coordinator(searchText: $searchText, searchTokens: $searchTokens)
    }
    
    class Coordinator: NSObject, UISearchResultsUpdating {
        
        @Binding var searchText: String
        @Binding var searchTokens: [UISearchToken]
        
        init(searchText: Binding<String>, searchTokens: Binding<[UISearchToken]>) {
            self._searchText = searchText
            self._searchTokens = searchTokens
        }
        
        func updateSearchResults(for searchController: UISearchController) {
            guard let text = searchController.searchBar.text else { return }
            self.searchText = text
            self.searchTokens = searchController.searchBar.searchTextField.tokens
        }
    }
    
}
```

## SwiftUIビュー内に検索バーを設置する

ここでは、SwiftUIビューのナビゲーションバーに検索バーを追加します。

まずは、SwiftUIビューがナビゲーションビュー内に埋め込まれていることを確認します。

検索コントローラーは、ナビゲーションバーにプログラムで直接割り当てるため、検索バーをビューコンポーネントにする必要はありません。従って、`overlay`ビューモディファイアを利用することで、そのフレームを0に設定します。

```swift
struct ContentView: View {
    
    /// Variables used by `SearchView`
    @State private var searchText: String = ""
    @State private var searchTokens: [UISearchToken]
    
    init(searchTokens: [UISearchToken]) {
        self._searchTokens = .init(initialValue: searchTokens)
    }
    
    var body: some View {
        Form {
            Section {
                Text("Search term: \(searchText)")
                Text("Search tokens: \(searchTokens.getTokenNames().description)")
            }
        }.overlay(
            SearchBar(searchText: $searchText, searchTokens: $searchTokens).frame(width: 0, height: 0)
        )
    }
    
}
```

上の例では、入力された検索テキストとトークンを表示しているだけです。ご自分のアプリケーションでは、検索ワードが変わるたびに検索を実行する必要があります。

## 上記のSwiftUIビューを使う

これで、上記のSwiftUIビューを使って、タグと共に検索バーを表示できるようになりました。

```swift
struct MenuView: View {
    var body: some View {
        List {
            NavigationLink("Movies") {
                ContentView(searchTokens: [.init(icon: UIImage(systemName: "play.rectangle.on.rectangle.fill"), text: "Movies").getTokenWithIdentifier("movies")])
            }
            NavigationLink("Music") {
                ContentView(searchTokens: [.init(icon: UIImage(systemName: "music.note"), text: "Music").getTokenWithIdentifier("music")])
            }
            NavigationLink("Documents") {
                ContentView(searchTokens: [.init(icon: UIImage(systemName: "doc"), text: "Documents").getTokenWithIdentifier("documents")])
            }
        }
        .navigationTitle("Search demo")
        .navigationBarTitleDisplayMode(.inline)
    }
}
```

上の例では、ユーザーは、映画、歌、文書のいずれかの検索を選択できます。ユーザーが検索しているカテゴリーは、タグ（検索トークン）として表示されます。

## 完成したプロジェクト

完成したプロジェクトのコードをこちらからご覧いただけます。

https://github.com/mszmagic/SearchTokens_SwiftUI