Searchable lists in SwiftUI in Swift, SwiftUI & iOS Apps
Search functionality has become table stakes in modern mobile apps—users expect to find what they need without scrolling through hundreds of items. Building searchable lists in SwiftUI has evolved from a complex multi-step process into something you can add with a single modifier, though handling it well still requires understanding the underlying mechanics. We'll walk through adding search to your lists, filtering data efficiently, and handling network-backed searches without hammering your backend.
The .searchable(text:) modifier on lists
SwiftUI's .searchable(text:) modifier handles the presentation layer for you—it adds a search field above your list on iOS 15+ and manages the keyboard, focusing, and dismissal. Here's the minimal setup:
@State private var searchText = ""
var body: some View {
List {
ForEach(items, id: \.id) { item in
Text(item.name)
}
}
.searchable(text: $searchText, prompt: "Search items")
}The modifier takes a binding to a state variable and an optional prompt string. On iOS 15, the search field appears only when you scroll up or tap a search icon. On iOS 16+, you can control its visibility with the .searchPresentation modifier if you want it always visible or to respond to programmatic triggers.
The real work happens when you bind that searchText to your filtering logic. Many developers make the mistake of filtering directly in the view body, which recalculates on every keystroke. Instead, use computed properties or explicit filtering to keep the view code clean:
@State private var searchText = ""
@State private var items: [Item] = loadItems()
var filteredItems: [Item] {
if searchText.isEmpty {
return items
}
return items.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}
var body: some View {
List {
ForEach(filteredItems, id: \.id) { item in
Text(item.name)
}
}
.searchable(text: $searchText, prompt: "Search items")
}Use localizedCaseInsensitiveContains rather than basic string matching—it respects locale-specific character handling and is the expected behavior for user-facing search.
Filter in the model
As your app grows, keeping filtering logic in the view becomes unmaintainable. Move it to your data model or a dedicated service layer. This also makes filtering testable and reusable across multiple views:
struct ItemRepository {
let items: [Item]
func search(_ query: String) -> [Item] {
guard !query.isEmpty else { return items }
return items.filter { item in
item.name.localizedCaseInsensitiveContains(query) ||
item.description.localizedCaseInsensitiveContains(query) ||
item.tags.contains { $0.localizedCaseInsensitiveContains(query) }
}
}
}In the view, inject the repository and call its search method:
@State private var searchText = ""
@StateObject private var repository = ItemRepository(items: loadItems())
var filteredItems: [Item] {
repository.search(searchText)
}
var body: some View {
List {
ForEach(filteredItems, id: \.id) { item in
Text(item.name)
}
}
.searchable(text: $searchText, prompt: "Search items")
}This separation also lets you add complexity like fuzzy matching, regex support, or multi-field weighting without touching your view code.
Search suggestions and scopes
iOS 16 introduced search suggestions and scopes, which enhance the search experience by offering completions and filtering by category. Suggestions appear below the search field as the user types:
@State private var searchText = ""
@State private var searchScope: SearchScope = .all
enum SearchScope: String, CaseIterable {
case all = "All"
case products = "Products"
case articles = "Articles"
}
var suggestedItems: [Item] {
guard !searchText.isEmpty else { return [] }
let filtered = repository.search(searchText)
return Array(filtered.prefix(5))
}
var body: some View {
List {
ForEach(filteredItems, id: \.id) { item in
Text(item.name)
}
}
.searchable(
text: $searchText,
suggestions: {
ForEach(suggestedItems, id: \.id) { item in
Text(item.name)
.searchCompletion(item.name)
}
},
prompt: "Search items"
)
.searchScopes($searchScope) {
ForEach(SearchScope.allCases, id: \.self) { scope in
Text(scope.rawValue).tag(scope)
}
}
}The .searchCompletion() modifier sets what gets inserted into the search field when a suggestion is tapped. Scopes let users narrow results—filter your suggestions or main results based on the selected scope:
var filteredItems: [Item] {
var results = repository.search(searchText)
switch searchScope {
case .all:
break
case .products:
results = results.filter { $0.type == .product }
case .articles:
results = results.filter { $0.type == .article }
}
return results
}Debounce network searches
When your search hits a backend API, you can't afford to make a request on every keystroke. A user typing "swift" sends four network calls if you don't debounce. Implement a delay so requests only fire after the user stops typing for a short window:
@State private var searchText = ""
@State private var searchResults: [Item] = []
@State private var isLoading = false
@State private var debounceTask: Task?
private func performSearch(_ query: String) async {
isLoading = true
do {
let results = try await repository.search(query, limit: 20)
searchResults = results
} catch {
searchResults = []
}
isLoading = false
}
var body: some View {
List {
if isLoading {
ProgressView()
} else {
ForEach(searchResults, id: \.id) { item in
Text(item.name)
}
}
}
.searchable(text: $searchText, prompt: "Search items")
.onChange(of: searchText) { newValue in
debounceTask?.cancel()
debounceTask = Task {
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
if !Task.isCancelled {
await performSearch(newValue)
}
}
}
}The debounce window of 300ms works well for most use cases—long enough to let users finish typing without feeling sluggish. Adjust based on your API latency. Cancel pending tasks when the search text changes to avoid race conditions where older results overwrite newer ones.
For production apps with heavy traffic, consider client-side caching alongside debouncing. Store recent searches and return cached results immediately while fetching fresh data in the background.
Common mistakes
- Filtering in the body: Re-running expensive filter operations on every view update kills performance. Use computed properties or separate methods.
- Ignoring case and diacritics: User "jose" won't find "José" with naive string matching. Always use
localizedCaseInsensitiveContains. - No debounce for network searches: Without debouncing, a three-character query creates three API calls. Debounce aggressively.
- Race conditions in async search: If the user types faster than your network responds, older results can overwrite newer ones. Cancel previous tasks before starting new ones.
- Keeping search state in the model: Search text is purely UI state. Store it in
@Statein the view, not in your data layer.
Key takeaways
- Use
.searchable(text:)to add a native search field with minimal boilerplate; it handles keyboard and UI state for you. - Move filtering logic into your model or repository layer for testability and reuse across multiple views.
- Leverage
.searchScopesand suggestions (iOS 16+) to guide users and narrow results without requiring complex filtering logic. - Always debounce network-backed searches with a 300ms window and cancel pending requests to prevent race conditions.
- Use
localizedCaseInsensitiveContainsfor user-facing search to respect locale and accent handling.






Comments
Loading comments…