
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!