はじめに (対象読者・この記事でわかること)

この記事は、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: 最小構成で検索バーを表示

Swift
import 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)にしていると、検索バーが小さく折りたたまれて見落としがちです。

解決策

.searchableplacement引数で強制的に表示場所を指定できます。

Swift
.searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always))

これで、NavigationBarスタイルに関わらず検索バーが常に展開されます。

まとめ

本記事では、SwiftUIの.searchableを使って検索バーと検索候補を最小構成で実装する方法を解説しました。

  • .searchable一つでフォーカス/キャンセル/markedTextを自動管理
  • searchCompletionで検索候補を簡単に実装
  • デバウンスで日本語変換中のガタツキを抑制
  • Core Data連携で検索履歴を永続化

この記事を通して、UIKit時代と比べて実装工数が1/10以下になったことを実感していただけたでしょう。
次回は、.searchable@FocusStateを組み合わせて「検索画面に遷移したら即キーボード表示」するテクニックを紹介します。

参考資料