はじめに (対象読者・この記事でわかること)
この記事は、SwiftUIで検索機能を実装したいiOSアプリ開発者を対象にしています。
UIKit時代はUISearchControllerを使ったり、独自にテキストフィールドを置いたりと実装コストが高かったのですが、SwiftUIでは.searchable修飾子一つで検索バー+検索候補まで実装できます。
この記事を読むことで
.searchableの基本と内部動作- 検索候補(サジェスト)を自前で用意する方法
- 検索結果をリアルタイムでフィルタするロジック
- 日本語変換中の一時的文字列を正しく扱うテクニック
が10分で習得できます。サンプルコードはXcode 15・iOS 17以降で動作確認済みです。
前提知識
- Swiftの基本的な文法(クロージャ、プロトコル)
- SwiftUIのViewプロトコルと
@Stateの役割 NavigationStackの使い方(検索バーを表示するため)
.searchableとは何か、なぜ便利なのか
SwiftUI 3.0(iOS 15)から追加された.searchableは、Viewに付与するだけでNavigationBarに検索フィールドを出現させる修飾子です。内部的には
- フォーカス管理
- キャンセルボタンの出現/退場
- 日本語変換中のマークアップ文字列(markedText)の取り扱い
をすべてUIKit側で吸収してくれるため、開発者は「検索文字列が変わったら何をするか」に集中できます。
また、iOS 16以降ではsearchable(text:placement:prompt:)に@MainActorが付与され、メインスレッドでの更新が保証されるため、データ競合の心配も削減されます。
検索バー+検索候補を10行で実装する
ステップ1: 最小構成で検索バーを表示
Swiftimport SwiftUI struct ContentView: View { @State private var query = "" let allFruits = ["Apple", "Apricot", "Banana", "Blueberry", "Cherry"] var body: some View { NavigationStack { List(filteredFruits, id: \.self) { fruit in Text(fruit) } .navigationTitle("Fruits") .searchable(text: $query, prompt: "フルーツを検索") } } private var filteredFruits: [String] { if query.isEmpty { return allFruits } return allFruits.filter { $0.localizedCaseInsensitiveContains(query) } } }
これだけで、NavigationBarに検索フィールドが出現し、文字を打ち始めるとfilteredFruitsが再計算されて画面が更新されます。
ステップ2: 検索候補(サジェスト)を自前で用意する
.searchableには@ViewBuilderを受け取るsuggestions引数があるため、検索候補を無理なく表示できます。
Swift.searchable(text: $query, prompt: "フルーツを検索") { ForEach(suggestions, id: \.self) { suggestion in Text(suggestion) .searchCompletion(suggestion) // タップでqueryに代入 } } private var suggestions: [String] { let candidates = allFruits.filter { $0.hasPrefix(query) } return candidates.isEmpty ? allFruits : candidates }
searchCompletionを付与したViewをタップすると、自動的にqueryが更新され、キーボードが閉じます。
ステップ3: 日本語変換中の一時的文字列を無視する
日本語入力中はmarkedTextと呼ばれる一時的文字列がqueryに流れてきます。これをフィルタに使うと「りんご」と打っている最中に「り」だけでフィルタがかかり、リストがちらつく原因になります。
回避策はUITextInputのプロパティを覗くことですが、SwiftUIでは@Bindingのsetterを直接観察できないため、簡易的に以下のようにデバウンスします。
Swift@State private var debouncedQuery = "" .onChange(of: query) { newValue in // 0.3秒間新しい入力がなければ確定とみなす DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { if query == newValue { debouncedQuery = newValue } } }
filteredFruitsの代わりにdebouncedQueryを使うことで、変換中のガタツキを抑制できます。
ステップ4: 検索履歴とCore Data連携
検索履歴を保存したい場合は、Core DataのエンティティSearchHistoryを用意し、searchCompletionがタップされたタイミングで保存します。
Swift@Environment(\.managedObjectContext) private var context Text(suggestion) .searchCompletion(suggestion) .onSubmit(of: .search) { let new = SearchHistory(context: context) new.term = suggestion new.date = .now try? context.save() }
.onSubmit(of: .search)を付与しておくと、キーボードの「検索」キーでも同じ処理が走るため、ユーザビリティが向上します。
ハマった点:検索バーが表示されない
.searchableを付けてもシミュレータで検索バーが見えない場合、以下をチェックしてください。
- NavigationStack/NavigationViewでラップしているか
- 先頭のViewに
.navigationTitleを付けているか - iOS 15以降のシミュレータを使っているか
特に.navigationBarTitleDisplayMode(.inline)にしていると、検索バーが小さく折りたたまれて見落としがちです。
解決策
.searchableのplacement引数で強制的に表示場所を指定できます。
Swift.searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always))
これで、NavigationBarスタイルに関わらず検索バーが常に展開されます。
まとめ
本記事では、SwiftUIの.searchableを使って検索バーと検索候補を最小構成で実装する方法を解説しました。
.searchable一つでフォーカス/キャンセル/markedTextを自動管理searchCompletionで検索候補を簡単に実装- デバウンスで日本語変換中のガタツキを抑制
- Core Data連携で検索履歴を永続化
この記事を通して、UIKit時代と比べて実装工数が1/10以下になったことを実感していただけたでしょう。
次回は、.searchableと@FocusStateを組み合わせて「検索画面に遷移したら即キーボード表示」するテクニックを紹介します。
参考資料
- searchable(text:placement:prompt:)-7ku5m | Apple Developer Documentation
- Providing search suggestions | WWDC21
- SwiftUI 検索バー実装ガイド - Qiita
