WWDC Quick Look 💓 By SwiftGGTeam
Craft search experiences in SwiftUI

Craft search experiences in SwiftUI

元の動画を見る

ハイライト

SwiftUI は 2021 年に .searchableisSearchingsearchCompletion.onSubmit(of: .search) を追加しました。開発者は宣言的なコードで、クロスプラットフォーム App に検索バー、検索結果、検索候補を追加できます。

主要内容

リストを扱う App が大きくなると、検索は必須になります。天気 App には都市のリストがあり、音楽 App には曲のリストがあり、デザインツールにはカラーパレットのリストがあります。ユーザーは探したいものを分かっているのに、先頭から末尾までスクロールさせられます。

以前の検索実装では、まず検索フィールドを置き、次に入力を状態へ同期し、さらに入力に応じて結果リストを切り替える必要がありました。iPadOS、macOS、watchOS、tvOS では、検索フィールドの位置やインタラクションの作法も個別に扱う必要があります。

SwiftUI 2021 は、これらを 1 つの modifier である .searchable にまとめました。これはバインドされた文字列を受け取り、検索フィールドを environment 経由でビュー ツリーへ渡します。NavigationView はその検索フィールドを認識し、プラットフォームに適した位置へ配置します。

Weather の例は基本モデルを示します。インターフェイスは NavigationView とリストのままです。開発者は .searchable(text:) を navigation view に付けます。検索中は、isSearching environment 値と検索テキストで結果を表示するかどうかを決めます。

Colors の例はクロスプラットフォームの挙動を説明します。同じ double-column navigation view のコードでも、iOS と iPadOS では検索バーが sidebar に関連付けられ、macOS では検索フィールドが toolbar の trailing 側に置かれ、watchOS ではビュー上部に置かれます。tvOS は構造が異なり、検索は通常 tab として表現されるため、コードでは .searchable を検索 tab に移すだけで済みます。

検索フィールドは入力の問題を解決し、検索候補は「何を検索できるのか分からない」という問題を解決します。.searchable は suggestions クロージャを受け取れます。候補はデータベース内の人気語でも、サーバー由来のデータでも構いません。searchCompletion は通常のビューをクリック可能な候補に変え、選択後に検索テキストへ自動入力して候補パネルを閉じます。

詳細

.searchable: 検索フィールドを SwiftUI に渡す

01:17

SwiftUI は searchable view modifier を追加しました。中心となる入力は Binding<String> です。この文字列は現在の検索クエリを保持し、SwiftUI が検索フィールドをプラットフォームに適した位置へ描画します。

import SwiftUI

struct ContentView: View {
    @State private var text = ""

    var body: some View {
        NavigationView {
            List(filteredCities, id: \.self) { city in
                Text(city)
            }
            .navigationTitle("Weather")
        }
        .searchable(text: $text)
    }

    private var filteredCities: [String] {
        let cities = ["Cupertino", "San Francisco", "London", "Tokyo"]

        if text.isEmpty {
            return cities
        }

        return cities.filter { city in
            city.localizedCaseInsensitiveContains(text)
        }
    }
}

キーポイント:

  • @State private var text = "" は検索クエリを保持し、入力が変わるとビューの更新を発火します。
  • NavigationView はナビゲーション構造を提供し、session の Weather と Colors の例はどちらもここから始まります。
  • List(filteredCities, id: \.self) はフィルタ済み配列に基づいて都市を表示します。
  • .searchable(text: $text) は検索フィールドを text にバインドします。
  • filteredCitiestext で結果を計算します。空のクエリでは完全なリストを表示し、空でなければ一致項目を表示します。

02:15

searchable は検索フィールドを environment に入れます。NavigationView がこのフィールドを利用できる場合は、検索バーとして表示します。利用するビューがない場合、SwiftUI はデフォルト実装を提供し、検索フィールドを toolbar に置きます。

ContentView()
    .searchable(text: $text)

キーポイント:

  • ContentView() は検索可能としてマークする対象のコンテンツです。
  • .searchable(text: $text) は、このコンテンツが検索をサポートすることを宣言します。
  • $text は binding であり、検索フィールドと App の状態が同じクエリ テキストを共有します。

isSearching: 検索中に結果ビューを重ねる

03:11

検索フィールドは入口にすぎません。実際の App では、結果をどこに表示するかも決める必要があります。session では、isSearching environment 値でユーザーが検索中かどうかを判断し、overlay で結果を表示する方法が勧められています。これにより、検索インタラクションでメイン画面の状態がリセットされません。

import SwiftUI

struct WeatherList: View {
    @Binding var text: String

    @Environment(\.isSearching)
    private var isSearching: Bool

    var body: some View {
        WeatherCitiesList()
            .overlay {
                if isSearching && !text.isEmpty {
                    WeatherSearchResults(text: text)
                }
            }
    }
}

struct WeatherCitiesList: View {
    var body: some View {
        List(["Cupertino", "San Francisco", "London"], id: \.self) { city in
            Text(city)
        }
    }
}

struct WeatherSearchResults: View {
    var text: String

    var body: some View {
        List(["Search result for \(text)"], id: \.self) { result in
            Text(result)
        }
    }
}

キーポイント:

  • @Binding var text: String は外側の .searchable が使う同じクエリ テキストを受け取ります。
  • @Environment(\.isSearching) は SwiftUI が設定する検索状態を読み取ります。
  • WeatherCitiesList() により、メイン リストは常に存在したままになります。
  • .overlay { ... } は検索結果をメイン リストの上に重ねます。
  • if isSearching && !text.isEmpty により、ユーザーが検索フィールドに入っただけで空の結果が即表示されるのを避けます。
  • WeatherSearchResults(text: text) は現在のクエリで結果ビューを構築します。

05:07

Colors App は double-column NavigationView を使います。.searchable を navigation view に付けると、SwiftUI は列数とプラットフォームに応じて、デフォルトで関連付ける列を選びます。2 列構造では、iOS と iPadOS で検索バーが sidebar に関連付けられます。macOS では検索フィールドが toolbar の trailing 側に置かれます。

import SwiftUI

struct ColorsContentView: View {
    @State var text = ""

    var body: some View {
        NavigationView {
            Sidebar(text: $text)
            DetailView()
        }
        .searchable(text: $text)
    }
}

struct Sidebar: View {
    @Binding var text: String

    var body: some View {
        List(filteredPalettes, id: \.self) { palette in
            Text(palette)
        }
    }

    private var filteredPalettes: [String] {
        let palettes = ["Ocean", "Sunset", "Forest", "iMac"]

        if text.isEmpty {
            return palettes
        }

        return palettes.filter { palette in
            palette.localizedCaseInsensitiveContains(text)
        }
    }
}

struct DetailView: View {
    var body: some View {
        Text("Select a palette")
    }
}

キーポイント:

  • NavigationView { Sidebar(text: $text); DetailView() } は 2 列インターフェイスを構築します。
  • .searchable(text: $text) を navigation view に付けることで、SwiftUI にデフォルト列を選ばせます。
  • Sidebartext を受け取り、クエリで palette リストをフィルタします。
  • DetailView は detail pane の内容を保持し、macOS では結果を detail pane に置くこともできます。
  • 検索フィールドを別の列へ関連付けたい場合は、.searchable を対象列のビューに付けます。

07:15

tvOS の一般的な検索体験は tab です。session の実装では、tvOS では内容を TabView に置き換え、.searchable を検索 tab に付けます。他のプラットフォームでは引き続き .searchable を navigation view に付けます。

import SwiftUI

struct ColorsContentView: View {
    @State var text = ""

    var body: some View {
        NavigationView {
            #if os(tvOS)
            TabView {
                Sidebar(text: $text)
                    .tabItem { Text("Library") }

                ColorsSearch(text: $text)
                    .searchable(text: $text)
                    .tabItem { Text("Search") }
            }
            #else
            Sidebar(text: $text)
            DetailView()
            #endif
        }
        #if !os(tvOS)
        .searchable(text: $text)
        #endif
    }
}

struct ColorsSearch: View {
    @Binding var text: String

    var body: some View {
        if text.isEmpty {
            Text("Search palettes")
        } else {
            List(["Result for \(text)"], id: \.self) { result in
                Text(result)
            }
        }
    }
}

キーポイント:

  • #if os(tvOS) は tvOS 用に独立したナビゲーション構造を提供します。
  • TabView は tvOS で一般的な tab 型構造を作ります。
  • ColorsSearch(text: $text).searchable(text: $text) は検索 tab だけを検索可能としてマークします。
  • #if !os(tvOS) は他のプラットフォームのデフォルト navigation view 検索挙動を残します。
  • ColorsSearch は空のクエリではプレースホルダーを表示し、空でなければ結果を表示します。

suggestions: 検索候補で検索できる内容を伝える

09:09

ユーザーが何を入力できるか分からないとき、検索候補は試行錯誤のコストを下げます。.searchable の suggestions クロージャはビューの集合を返せます。session では、ボタンで手動入力する方法と、searchCompletion で自動補完する方法の 2 パターンが示されました。

import SwiftUI

struct ColorsContentView: View {
    @State var text = ""

    private let suggestions = [
        ColorSuggestion(text: "Ocean"),
        ColorSuggestion(text: "Sunset"),
        ColorSuggestion(text: "Forest")
    ]

    var body: some View {
        NavigationView {
            Sidebar(text: $text)
            DetailView()
        }
        .searchable(text: $text) {
            ForEach(suggestions) { suggestion in
                ColorsSuggestionLabel(suggestion)
                    .searchCompletion(suggestion.text)
            }
        }
    }
}

struct ColorSuggestion: Identifiable {
    let id = UUID()
    let text: String
}

struct ColorsSuggestionLabel: View {
    let suggestion: ColorSuggestion

    init(_ suggestion: ColorSuggestion) {
        self.suggestion = suggestion
    }

    var body: some View {
        Text(suggestion.text)
    }
}

キーポイント:

  • suggestions は動的な候補データで、session では App データベースやサーバーから取得できると説明されています。
  • .searchable(text: $text) { ... } の trailing クロージャは候補ビューを提供します。
  • ForEach(suggestions) は各候補に label を生成します。
  • ColorsSuggestionLabel(suggestion) は非インタラクティブなビューです。
  • .searchCompletion(suggestion.text) は label を選択可能な候補へ変換し、選択後に検索テキストを更新して候補を閉じます。

09:43

選択時の挙動を自分で制御したい場合は、suggestions にボタンを置くこともできます。ボタンがクリックされると、同じ text binding を直接変更します。

import SwiftUI

struct ManualSuggestionsView: View {
    @State private var text = ""

    private let suggestions = [
        ColorSuggestion(text: "Blue"),
        ColorSuggestion(text: "Green"),
        ColorSuggestion(text: "Purple")
    ]

    var body: some View {
        NavigationView {
            List(suggestions) { suggestion in
                Text(suggestion.text)
            }
        }
        .searchable(text: $text) {
            ForEach(suggestions) { suggestion in
                Button {
                    text = suggestion.text
                } label: {
                    ColorsSuggestionLabel(suggestion)
                }
            }
        }
    }
}

キーポイント:

  • @State private var text = "" は検索フィールドと候補ボタンが共有する状態です。
  • Button { text = suggestion.text } はクリック時に候補テキストをクエリへ書き込みます。
  • label は同じ ColorsSuggestionLabel を使うため、表示ロジックを再利用できます。
  • この書き方は、追加のカスタム挙動が必要な候補項目に適しています。

.onSubmit(of: .search): ユーザーの送信後にクエリを実行する

10:21

検索の中には、1 文字入力されるたびに実行するのが適さないものがあります。たとえばサーバー側クエリ、全文検索、高コストなデータベース リクエストです。SwiftUI は onSubmit modifier を追加しました。.search を渡すと、ユーザーが検索クエリを送信したときにクロージャを実行します。session では、通常のトリガーは検索候補の選択、またはハードウェア キーボードで Enter を押すことだと説明されています。

import SwiftUI

struct SearchSubmitView: View {
    @State private var text = ""
    @State private var results: [String] = []

    var body: some View {
        NavigationView {
            List(results, id: \.self) { result in
                Text(result)
            }
        }
        .searchable(text: $text) {
            MySearchSuggestions()
        }
        .onSubmit(of: .search) {
            fetchResults()
        }
    }

    private func fetchResults() {
        results = ["Submitted query: \(text)"]
    }
}

struct MySearchSuggestions: View {
    var body: some View {
        Text("Ocean")
            .searchCompletion("Ocean")
    }
}

キーポイント:

  • .searchable(text: $text) はクエリ入力を担当します。
  • MySearchSuggestions() は補完可能な候補を提供します。
  • .onSubmit(of: .search) は検索送信だけを監視します。
  • fetchResults() は送信後に結果配列を更新します。
  • session では、onSubmitTextFieldSecureField の検索以外の送信シナリオにも使えると説明されています。

重要ポイント

  1. 何をするか: ローカルのお気に入りリストに検索バーを追加します。 取り組む価値: .searchable(text:) は iOS、iPadOS、macOS、watchOS の作法に合わせて検索フィールドを配置します。 始め方: お気に入りページを NavigationView で包み、@State private var text = "" を追加し、.searchable(text: $text) を navigation view または対象列に付けます。

  2. 何をするか: 「検索中だけ一時的に結果を重ねる」リストを作ります。 取り組む価値: isSearching は通常閲覧と検索インタラクションを区別でき、overlay は元のリストのスクロール位置と選択状態を保てます。 始め方: リスト ビューで @Environment(\.isSearching) を読み取り、isSearching && !text.isEmpty のときに .overlay で結果ビューを表示します。

  3. 何をするか: 検索フィールドに人気検索と検索履歴を追加します。 取り組む価値: suggestions クロージャはデータベースやサーバー データに基づいて候補を生成でき、searchCompletion はクエリ テキストを自動入力して候補を閉じます。 始め方: suggestions 配列を保持し、.searchable(text:) { ForEach(suggestions) { ... } } 内で各 label に .searchCompletion(suggestion.text) を付けます。

  4. 何をするか: サーバー側検索を「送信後に問い合わせる」方式へ変えます。 取り組む価値: .onSubmit(of: .search) はユーザーが明示的に送信したときだけ実行されるため、ネットワーク リクエストや全文検索に適しています。 始め方: リクエスト関数を .onSubmit(of: .search) { fetchResults() } に入れ、関数内で現在の text を読み取ります。

  5. 何をするか: tvOS App に独立した検索 tab を作ります。 取り組む価値: session では tvOS の検索は通常 tab として表現されると説明されており、独立した検索ページは元の構造に検索フィールドを押し込むよりもプラットフォームの作法に合います。 始め方: #if os(tvOS) 分岐で TabView を使い、.searchable(text:)ColorsSearch ページに付けます。

関連セッション

コメント

GitHub Issues · utterances