How to Build Search Suggestions in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: searchSuggestions Updated: May 11, 2026
TL;DR

Attach .searchSuggestions { } immediately after .searchable(text:) and give each suggestion row a .searchCompletion(_:) modifier — SwiftUI handles the drop-down presentation, filtering trigger, and keyboard fill-in automatically.

struct QuickSearchView: View {
    @State private var query = ""
    let terms = ["Swift", "SwiftUI", "Xcode", "Combine", "Swift Testing"]

    var suggestions: [String] {
        terms.filter { $0.localizedCaseInsensitiveContains(query) }
    }

    var body: some View {
        NavigationStack {
            List(suggestions, id: \.self) { Text($0) }
                .searchable(text: $query, prompt: "Search topics")
                .searchSuggestions {
                    ForEach(suggestions, id: \.self) { term in
                        Text(term).searchCompletion(term)
                    }
                }
                .navigationTitle("Topics")
        }
    }
}

Full implementation

The example below models a recipe-search screen with three suggestion categories: recent searches (persisted in @AppStorage), popular tags drawn from a @State array, and a live "search for…" catch-all row that submits the raw query. Suggestions are surfaced only while query is non-empty, preventing an overwhelming list at rest. Tapping any row calls commit() to record the term as a recent search and navigate to results.

import SwiftUI

// MARK: - Model

struct Recipe: Identifiable {
    let id = UUID()
    let title: String
    let tag: String
}

// MARK: - ViewModel

@Observable
final class RecipeSearchViewModel {
    var query: String = ""
    var recentSearches: [String] = []

    let allRecipes: [Recipe] = [
        Recipe(title: "Avocado Toast", tag: "breakfast"),
        Recipe(title: "Pasta Primavera", tag: "dinner"),
        Recipe(title: "Berry Smoothie", tag: "breakfast"),
        Recipe(title: "Chicken Tacos", tag: "dinner"),
        Recipe(title: "Caesar Salad", tag: "lunch"),
        Recipe(title: "Banana Pancakes", tag: "breakfast"),
        Recipe(title: "Lentil Soup", tag: "lunch"),
    ]

    let popularTags = ["breakfast", "lunch", "dinner", "vegan", "quick"]

    var filteredRecipes: [Recipe] {
        guard !query.isEmpty else { return allRecipes }
        return allRecipes.filter {
            $0.title.localizedCaseInsensitiveContains(query) ||
            $0.tag.localizedCaseInsensitiveContains(query)
        }
    }

    var tagSuggestions: [String] {
        popularTags.filter { $0.localizedCaseInsensitiveContains(query) }
    }

    var recentSuggestions: [String] {
        recentSearches.filter { $0.localizedCaseInsensitiveContains(query) }
    }

    func commit(_ term: String) {
        query = term
        guard !recentSearches.contains(term) else { return }
        recentSearches.insert(term, at: 0)
        if recentSearches.count > 5 { recentSearches.removeLast() }
    }

    func clearRecents() { recentSearches.removeAll() }
}

// MARK: - View

struct RecipeSearchView: View {
    @State private var vm = RecipeSearchViewModel()

    var body: some View {
        NavigationStack {
            List {
                if vm.query.isEmpty {
                    Section("All Recipes") {
                        ForEach(vm.allRecipes) { recipe in
                            RecipeRow(recipe: recipe)
                        }
                    }
                } else {
                    Section("Results") {
                        ForEach(vm.filteredRecipes) { recipe in
                            RecipeRow(recipe: recipe)
                        }
                    }
                }
            }
            .listStyle(.insetGrouped)
            .searchable(
                text: $vm.query,
                placement: .navigationBarDrawer(displayMode: .always),
                prompt: "Search recipes or tags"
            )
            .searchSuggestions {
                // 1. Recent searches
                if !vm.recentSuggestions.isEmpty {
                    Section {
                        ForEach(vm.recentSuggestions, id: \.self) { term in
                            Label(term, systemImage: "clock")
                                .searchCompletion(term)
                        }
                    } header: {
                        Text("Recent")
                    }
                }

                // 2. Popular tag matches
                if !vm.tagSuggestions.isEmpty {
                    Section {
                        ForEach(vm.tagSuggestions, id: \.self) { tag in
                            Label(tag.capitalized, systemImage: "tag")
                                .searchCompletion(tag)
                        }
                    } header: {
                        Text("Tags")
                    }
                }

                // 3. Raw query catch-all
                if !vm.query.isEmpty {
                    Label("Search for \"\(vm.query)\"", systemImage: "magnifyingglass")
                        .searchCompletion(vm.query)
                        .foregroundStyle(.blue)
                }
            }
            .onSubmit(of: .search) {
                vm.commit(vm.query)
            }
            .navigationTitle("Recipes")
            .toolbar {
                if !vm.recentSearches.isEmpty {
                    ToolbarItem(placement: .topBarTrailing) {
                        Button("Clear Recents") { vm.clearRecents() }
                            .font(.caption)
                    }
                }
            }
        }
    }
}

// MARK: - Supporting Views

struct RecipeRow: View {
    let recipe: Recipe
    var body: some View {
        VStack(alignment: .leading, spacing: 2) {
            Text(recipe.title).font(.body)
            Text(recipe.tag.capitalized)
                .font(.caption)
                .foregroundStyle(.secondary)
        }
        .accessibilityElement(children: .combine)
        .accessibilityLabel("\(recipe.title), \(recipe.tag)")
    }
}

// MARK: - Preview

#Preview {
    RecipeSearchView()
}

How it works

  1. .searchable with placement: The .searchable(text:placement:prompt:) modifier embeds a search field in the navigation bar. Using .navigationBarDrawer(displayMode: .always) keeps it persistently visible rather than hidden behind a scroll gesture.
  2. .searchSuggestions ViewBuilder: The .searchSuggestions { } trailing closure is a @ViewBuilder that SwiftUI renders as a popover list below the search field whenever the field is focused. Standard Section headers work inside it to group suggestion rows visually.
  3. .searchCompletion(_:): Applied to each suggestion row, this modifier tells SwiftUI what string to write into the $query binding when the row is tapped. The value can differ from the label — useful for slugs or IDs behind human-readable text.
  4. onSubmit(of: .search): Fires when the user presses Return on the keyboard or taps a completion row. The vm.commit(_:) call deduplicates and prepends the term to recentSearches, capping history at five entries to avoid clutter.
  5. @Observable ViewModel: Using the iOS 17 @Observable macro instead of ObservableObject means only the views that read vm.query or vm.filteredRecipes re-render on change — keeping suggestion updates fast even with large datasets.

Variants

Token-based search (search chips)

iOS 17 added a tokens parameter to .searchable, letting users build up a visual chip list of active filters. Pass a Binding<[Token]> and return Text rows using .searchCompletion(token:) instead of the string-based overload.

struct TagToken: Identifiable, Hashable {
    let id = UUID()
    let name: String
}

struct TokenSearchView: View {
    @State private var query = ""
    @State private var selectedTokens: [TagToken] = []

    let availableTags = ["vegan", "gluten-free", "quick", "budget"].map(TagToken.init)

    var unusedTags: [TagToken] {
        availableTags.filter { tag in
            !selectedTokens.contains(tag) &&
            tag.name.localizedCaseInsensitiveContains(query)
        }
    }

    var body: some View {
        NavigationStack {
            List { Text("Results here") }
                .searchable(
                    text: $query,
                    tokens: $selectedTokens,
                    token: { Text($0.name) }   // renders each chip
                )
                .searchSuggestions {
                    ForEach(unusedTags) { tag in
                        Text(tag.name)
                            .searchCompletion(token: tag)  // adds chip, clears field
                    }
                }
                .navigationTitle("Filter Recipes")
        }
    }
}

#Preview { TokenSearchView() }

Async remote suggestions

For server-side typeahead, debounce the query with .onChange(of: query) and populate a @State var remoteSuggestions: [String] array inside a Task { }. Because .searchSuggestions reads the array reactively via @Observable, the drop-down updates automatically once the async fetch resolves — no extra animation code required.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement search suggestions in SwiftUI for iOS 17+.
Use searchSuggestions, searchCompletion, and @Observable.
Include three suggestion groups: recent searches, tag matches,
and a raw-query catch-all row.
Make it accessible (VoiceOver labels on each suggestion row).
Add a #Preview with realistic sample data.

In Soarias's Build phase, drop this prompt into an active screen context so Claude Code can wire the .searchSuggestions block directly into your existing NavigationStack hierarchy without scaffolding a separate file.

Related

FAQ

Does this work on iOS 16?

The core .searchSuggestions modifier and string-based .searchCompletion(_:) are available from iOS 16. However, the tokens: parameter on .searchable and the .searchCompletion(token:) overload for chip-style input require iOS 17. The @Observable macro also requires iOS 17; swap it for ObservableObject + @Published if you need iOS 16 support.

Can I show suggestions in a sheet or popover instead of the built-in drop-down?

Not with .searchSuggestions — SwiftUI controls the presentation chrome. For fully custom presentation (e.g., a floating card), manage focus with @FocusState on a plain TextField and show an .overlay or .popover driven by your own state. You lose the native keyboard integration but gain full layout control.

What is the UIKit equivalent?

In UIKit the equivalent is UISearchController with a delegate conforming to UISearchResultsUpdating, paired with a UISearchTextField and its searchTextField.tokens API for chip input. The SwiftUI modifier stack replaces all of that boilerplate with roughly five lines of code.

Last reviewed: 2026-05-11 by the Soarias team.