How to Build Search Suggestions in SwiftUI
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
-
.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. -
.searchSuggestions ViewBuilder: The
.searchSuggestions { }trailing closure is a@ViewBuilderthat SwiftUI renders as a popover list below the search field whenever the field is focused. StandardSectionheaders work inside it to group suggestion rows visually. -
.searchCompletion(_:): Applied to each suggestion row, this modifier tells SwiftUI what
string to write into the
$querybinding when the row is tapped. The value can differ from the label — useful for slugs or IDs behind human-readable text. -
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 torecentSearches, capping history at five entries to avoid clutter. -
@Observable ViewModel: Using the iOS 17
@Observablemacro instead ofObservableObjectmeans only the views that readvm.queryorvm.filteredRecipesre-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
-
iOS version floor:
.searchSuggestionsshipped in iOS 16, but thetokens:and.searchCompletion(token:)overloads require iOS 17. If you target iOS 16, omit token support and use only theString-based.searchCompletion(_:). -
Suggestions won't appear outside NavigationStack:
.searchablemust be placed on a view insideNavigationStackorNavigationSplitView; attaching it to a plainVStacksilently produces no search bar. -
Showing suggestions when query is empty: SwiftUI hides the suggestion list when
queryis empty by default. If you want to show popular searches at rest, guard against the empty state yourself and return rows unconditionally — but be aware this increases perceived UI weight and should be paired with a max-count cap. -
Accessibility:
Label("…", systemImage:)rows read both the title and the SF Symbol name via VoiceOver. Prefer.accessibilityLabel(_:)to provide a cleaner spoken string such as "Recent search: pasta" rather than "pasta, clock image".
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.