Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 088 / 100

Searchable lists in SwiftUI in Swift, SwiftUI & iOS Apps

Add search in one modifier.

Expona Admin6 min read

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:

swift
@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:

swift
@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:

swift
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:

swift
@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:

swift
@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:

swift
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:

swift
@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 @State in 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 .searchScopes and 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 localizedCaseInsensitiveContains for user-facing search to respect locale and accent handling.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles