Performing search with SwiftData in a SwiftUI app

Performing search with SwiftData in a SwiftUI app

Learn how to combine SwiftData queries with the seachable modifier on SwiftUI.

When adding search functionality to an app made with SwiftUI, your first option might be using the searchable(text:placement:prompt:) modifier.

While it works well out of the box for performing filtering and search within locally stored data (such as an array within a view), when combined with more robust data persistency solutions, such as SwiftData we need to work a little bit more.

Let’s see how to use the searchable(text:placement:prompt:) modifier to perform search and filtering with SwiftData.

To get SwiftData and the searchable modifier working together you’ll need dynamic queries powered by predicates. Let’s break this down to understand what they are and how to use them. To start, you need a SwiftData model, like the following:

import SwiftData

@Model
final class Item {
    var name: String
    var category: String
    var dateCreated: Date
    
    init(name: String, category: String) {
        self.name = name
        self.category = category
        self.dateCreated = Date()
    }
}

And a basic view listing the data from the model:

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @State private var showingAddItem = false
    @Query private var items: [Item]
    
    var body: some View {
        NavigationStack {
            List {
                ForEach(items) { item in
                    ItemRowView(item: item)
                }
                .onDelete(perform: deleteItems)
            }
            .navigationTitle("Stuff in the kitchen")
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button {
                        showingAddItem = true
                    } label: {
                        Image(systemName: "plus")
                    }
                }
            }
            .sheet(isPresented: $showingAddItem) {
                AddItemView()
            }
        }
    }
    
    private func deleteItems(offsets: IndexSet) {
        withAnimation {
            for index in offsets {
                modelContext.delete(items[index])
            }
        }
    }
}

Implementing search with SwiftUI

The first step is to make the List searchable by using the searchable(text:placement:prompt:) modifier.

struct ContentView: View {

    @Environment(\.modelContext) private var modelContext
    
    @State private var showingAddItem = false
    @Query private var items: [Item]

    // 1. Property for search input
    @State private var searchText = ""
    
    var body: some View {
        NavigationStack {
            List {
    		    ...
            } 
            .navigationTitle("Stuff in the kitchen")
            
            // 2. Adding search field to the list
            .searchable(text: $searchText, prompt: "Search items...")
            
            .toolbar { ... }
            .sheet(isPresented: $showingAddItem) { ... }
        }
    }
    
    private func deleteItems(offsets: IndexSet) { ... }
}

Add property to hold the user's search input, called searchText, and bind it to the searchable(text:placement:prompt:) modifier. This gives us the search interface, but we still need to connect it with SwiftData filtering capabilities to reflect the results in the user interface.

To do that, you need to use the @Query macro and the Predicate type.

A @Query is how we fetch and reactively update filtered data from our database. It watches for changes and automatically refreshes your UI when data matches or stops matching your criteria. A Predicate is a condition that determines which items should appear in your results.

In SwiftData, predicates use the #Predicate macro and filter data at the database level, making searches much faster than filtering arrays in memory. In other words, the #Predicate macro converts your Swift code into database queries that run efficiently.

However, there's a catch: since @Query requires its predicate at initialization, we can't update it dynamically within the same view. That's why we need a separate view pattern.

When we pass searchText as a parameter, SwiftUI reinitializes the view, and with it, the @Query uses the updated predicate. This ensures that only matching results are fetched from the database, keeping the app fast and efficient.

Let's update the ContentView to use this pattern:

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @State private var searchText = ""
    @State private var showingAddItem = false
    
    var body: some View {
        NavigationStack {
            // Instead of a List, we use a custom View
            ItemListView(searchText: searchText)
                .navigationTitle("Stuff in the kitchen")
                .searchable(text: $searchText, prompt: "Search items...")
                .toolbar {
                    ToolbarItem(placement: .navigationBarTrailing) {
                        Button {
                            showingAddItem = true
                        } label: {
                            Image(systemName: "plus")
                        }
                    }
                }
                .sheet(isPresented: $showingAddItem) {
                    AddItemView()
                }
        }
    }
}

Now, let's create the separate View that handles the filtered data, receiving searchText as a parameter.

struct ItemListView: View {
    @Environment(\.modelContext) private var modelContext
    
    let searchText: String
    
    @Query private var items: [Item]
  
    init(searchText: String) {
        self.searchText = searchText
        
        let predicate = #Predicate<Item> { item in
            if searchText.isEmpty {
                return true
            } else {
                return item.name.localizedStandardContains(searchText) ||
                item.category.localizedStandardContains(searchText)
            }
        }
        
        _items = Query(
            filter: predicate,
            sort: [SortDescriptor(\.name)]
        )
    }
    
    var body: some View { 
        // To be implemented soon...
    }
}

As you can see, we created the @Query with a dynamic approach that reinitializes every time the searchText changes. The initializer builds a custom predicate using the#Predicate macro: if the search text is empty, it returns all items, otherwise it filters using localizedStandardContains(_:) for case-insensitive, localized matching across both name and category fields.

We then initialize the @Query with this predicate and add sorting by name, telling SwiftData to perform all filtering at the database level.

Finally, the body handles three distinct states:

struct ItemListView: View {
    
    ...
    
    var body: some View {
        Group {
            if items.isEmpty && !searchText.isEmpty {
                ContentUnavailableView(
                    "No Results",
                    systemImage: "magnifyingglass",
                    description: Text("No items match '\(searchText)'")
                )
            } else if items.isEmpty && searchText.isEmpty {
                ContentUnavailableView(
                    "No Items",
                    systemImage: "tray",
                    description: Text("Tap the + button to add your first item")
                )
            } else {
                List {
                    ForEach(items) { item in
                        ItemRowView(item: item)
                    }
                    .onDelete(perform: deleteItems)
                }
            }
        }
    }
    
    private func deleteItems(offsets: IndexSet) {
        withAnimation {
            for index in offsets {
                modelContext.delete(items[index])
            }
        }
    }
}

This body shows a "No Results" message when search yields nothing, it displays an empty state when the database is empty, and it presents the filtered results in a list.

Since the @Query automatically contains only the matching results, we can directly iterate through the items array, and the delete functionality works seamlessly with the filtered data.

And that’s it!

By combining searchable(text:placement:prompt:), #Predicate, and @Query, you’ve built a dynamic, efficient search system that filters directly at the database level. As your dataset grows, your search stays just as responsive!