How to implement a search bar in SwiftUI
Attach .searchable(text: $searchText) to a
List inside a
NavigationStack, then filter your data with a
computed property — iOS handles the rest, including keyboard dismissal and placement.
struct ContentView: View {
@State private var searchText = ""
let fruits = ["Apple", "Banana", "Cherry", "Date", "Elderberry"]
var filtered: [String] {
searchText.isEmpty ? fruits : fruits.filter {
$0.localizedCaseInsensitiveContains(searchText)
}
}
var body: some View {
NavigationStack {
List(filtered, id: \.self) { Text($0) }
.navigationTitle("Fruits")
.searchable(text: $searchText, prompt: "Search fruits")
}
}
}
Full implementation
The example below models a searchable contact list with a struct data source, a computed
filteredContacts property, and an
empty-state view so the UI stays polished when nothing matches. The
.searchable modifier is placed on the
List — not the
NavigationStack — so iOS automatically
positions the bar below the navigation title and collapses it on scroll.
import SwiftUI
struct Contact: Identifiable {
let id = UUID()
let name: String
let role: String
}
struct ContactListView: View {
@State private var searchText = ""
private let contacts: [Contact] = [
Contact(name: "Ada Lovelace", role: "Engineer"),
Contact(name: "Alan Turing", role: "Mathematician"),
Contact(name: "Grace Hopper", role: "Admiral"),
Contact(name: "Tim Berners-Lee", role: "Inventor"),
Contact(name: "Linus Torvalds", role: "Engineer"),
Contact(name: "Margaret Hamilton",role: "Scientist"),
]
private var filteredContacts: [Contact] {
guard !searchText.isEmpty else { return contacts }
return contacts.filter {
$0.name.localizedCaseInsensitiveContains(searchText) ||
$0.role.localizedCaseInsensitiveContains(searchText)
}
}
var body: some View {
NavigationStack {
Group {
if filteredContacts.isEmpty {
ContentUnavailableView.search(text: searchText)
} else {
List(filteredContacts) { contact in
VStack(alignment: .leading, spacing: 2) {
Text(contact.name)
.font(.headline)
Text(contact.role)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(contact.name), \(contact.role)")
}
}
}
.navigationTitle("Contacts")
.searchable(
text: $searchText,
placement: .navigationBarDrawer(displayMode: .always),
prompt: "Name or role"
)
}
}
}
#Preview {
ContactListView()
}
How it works
-
.searchable(text:placement:prompt:) — Attaching this modifier to the
Listtells SwiftUI to inject a nativeUISearchControllerinto the hosting navigation bar. Theplacement: .navigationBarDrawer(displayMode: .always)parameter keeps the bar permanently visible rather than hiding it when the list scrolls up. -
filteredContacts computed property — Because it depends on
@State var searchText, SwiftUI automatically re-evaluates and re-renders the list every time the user types a character. The short-circuitguard !searchText.isEmptyavoids iterating the full array when the query is blank. -
ContentUnavailableView.search(text:) — Introduced in iOS 17, this
ready-made empty-state view renders the system magnifying-glass illustration with your
query highlighted, replacing the blank list when
filteredContacts.isEmpty. - localizedCaseInsensitiveContains — This Foundation method respects the device locale and diacritic rules (e.g. "e" matches "é") without any extra configuration, making search feel natural across languages.
- accessibilityElement(children: .combine) — Combining the name and role labels into a single VoiceOver element reads them as one phrase instead of two separate taps, giving a cleaner screen-reader experience.
Variants
Search scopes (filter by category)
Add .searchScopes to render a segmented
control beneath the search bar, letting users restrict results to a category without extra UI.
enum RoleScope: String, CaseIterable {
case all = "All"
case engineer = "Engineer"
case other = "Other"
}
struct ScopedSearchView: View {
@State private var searchText = ""
@State private var selectedScope = RoleScope.all
private let contacts: [Contact] = [
Contact(name: "Ada Lovelace", role: "Engineer"),
Contact(name: "Grace Hopper", role: "Admiral"),
Contact(name: "Linus Torvalds", role: "Engineer"),
]
private var filteredContacts: [Contact] {
contacts.filter { contact in
let matchesScope: Bool = switch selectedScope {
case .all: true
case .engineer: contact.role == "Engineer"
case .other: contact.role != "Engineer"
}
let matchesQuery = searchText.isEmpty ||
contact.name.localizedCaseInsensitiveContains(searchText)
return matchesScope && matchesQuery
}
}
var body: some View {
NavigationStack {
List(filteredContacts) { contact in
Text(contact.name)
}
.navigationTitle("Contacts")
.searchable(text: $searchText, prompt: "Search")
.searchScopes($selectedScope) {
ForEach(RoleScope.allCases, id: \.self) { scope in
Text(scope.rawValue).tag(scope)
}
}
}
}
}
Dismissing search programmatically
Inject @Environment(\.dismissSearch) private var dismissSearch
into any child view, then call dismissSearch()
on a button tap or after a navigation push. This closes the keyboard and resets the
search bar without requiring you to manually clear searchText.
Common pitfalls
-
iOS version floor:
ContentUnavailableView.search(text:)is iOS 17+ only. If you support iOS 16, guard withif #available(iOS 17, *)and provide a manual fallback empty state. -
Modifier placement: Attaching
.searchableto theNavigationStackitself instead of theListcan cause the search bar to appear in unexpected positions or not appear at all on certain iOS versions — always attach it to the scrollable content view. -
Performance with large datasets: A computed property that iterates
thousands of items on every keystroke can drop frames. For large collections, debounce
the query with
.onChange(of: searchText)+ a smallTask { try? await Task.sleep(for: .milliseconds(150)) }before filtering on a background actor.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a search bar in SwiftUI for iOS 17+. Use .searchable with placement .navigationBarDrawer(displayMode: .always). Filter a list of model objects using a computed property. Show ContentUnavailableView.search(text:) when results are empty. Make it accessible (VoiceOver labels on each row). Add a #Preview with realistic sample data.
Drop this prompt into Soarias during the Build phase to scaffold a fully wired search feature with empty-state handling and accessibility in a single pass — no boilerplate hunting required.
Related
FAQ
Does this work on iOS 16?
The .searchable modifier itself is
available from iOS 15+. However, ContentUnavailableView
and the .search(text:) static factory
require iOS 17+. Wrap those with
if #available(iOS 17, *) { ... } else { /* custom empty view */ }
to maintain iOS 16 compatibility.
How do I search across multiple fields or nested objects?
Extend your filter predicate with ||
chains or implement a
var searchTokens: String computed
property on your model that concatenates all searchable text into one string, then run a
single localizedCaseInsensitiveContains
against it. For full-text search across thousands of records, consider using
NSPredicate with a SwiftData
#Predicate macro so the filtering
runs in the persistent store rather than in memory.
What's the UIKit equivalent?
In UIKit you create a UISearchController,
assign it to
navigationItem.searchController, and
implement UISearchResultsUpdating to
receive query changes. SwiftUI's .searchable
modifier wraps exactly this machinery, so the two are functionally equivalent but the
SwiftUI version requires roughly 80% less code.
Last reviewed: 2026-05-11 by the Soarias team.