You've already seen how @Query
can be used to sort SwiftData objects in a particular order, but it can also be used to filter that data using a predicate – a series of tests that get applied to your data, to decide what to return.
The syntax for this is a little odd at first, mostly because this is actually another macro behind the scenes - Swift converts our predicate code into a series of rules it can apply to the underlying database that stores all of SwiftData's objects.
Let's start with something simple, using the same User
model we used previously:
@Model
class User {
var name: String
var city: String
var joinDate: Date
init(name: String, city: String, joinDate: Date) {
self.name = name
self.city = city
self.joinDate = joinDate
}
}
Now we can add a couple of properties to ContentView
that can show all the users we have:
@Environment(\.modelContext) var modelContext
@Query(sort: \User.name) var users: [User]
And finally, we can show all those users in a list, and we'll also add a button to add some sample data easily:
NavigationStack {
List(users) { user in
Text(user.name)
}
.navigationTitle("Users")
.toolbar {
Button("Add Samples", systemImage: "plus") {
let first = User(name: "Ed Sheeran", city: "London", joinDate: .now.addingTimeInterval(86400 * -10))
let second = User(name: "Rosa Diaz", city: "New York", joinDate: .now.addingTimeInterval(86400 * -5))
let third = User(name: "Roy Kent", city: "London", joinDate: .now.addingTimeInterval(86400 * 5))
let fourth = User(name: "Johnny English", city: "London", joinDate: .now.addingTimeInterval(86400 * 10))
modelContext.insert(first)
modelContext.insert(second)
modelContext.insert(third)
modelContext.insert(fourth)
}
}
}
Tip: Those join dates represent some number of days in the past or future, which gives us some interesting data to work with.
When working with sample data like this, it's helpful to be able to delete existing data before adding the sample data. To do that, add the following code before the let first =
line:
try? modelContext.delete(model: User.self)
That tells SwiftData to tell all existing model objects of the the type User
, which means the database is clear before we add the sample users.
To finish our little sample app, we just need to make sure the App
struct uses the modelContainer()
modifier to set up SwiftData correctly:
WindowGroup {
ContentView()
}
.modelContainer(for: User.self)
Now go ahead and run the app, then press the + button to insert four users.
You can see they appear in alphabetical order, because that's what we asked for in our @Query
property.
Now let's try filtering that data, so that we only show users whose name contains a capital R. To do this we pass a filter
parameter into @Query
, like this:
@Query(filter: #Predicate<User> { user in
user.name.contains("R")
}, sort: \User.name) var users: [User]
Let's break that down:
#Predicate<User>
, which means we're writing a predicate (a fancy word for a test we're going to apply).So, when you run the code now you'll see that both Rosa and Roy appear in our list, but Ed and Johnny are left off because their names don't contain a capital R. The contains()
method is case-sensitive: it considers capital R and lowercase R to be difference, which is why it didn't find the "r" in "Ed Sheeran".
That works great for a simple test of predicates, but it's very rare users actually care about capital letters – they usually just want to write a few letters, and look for that match anywhere in the results, ignoring case.
For this purpose, iOS gives us a separate method localizedStandardContains()
. This also takes a string to search for, except it automatically ignores letter case, so it's a much better option when you're trying to filter by user text.
Here's how it looks:
@Query(filter: #Predicate<User> { user in
user.name.localizedStandardContains("R")
}, sort: \User.name) var users: [User]
In our little test data that means we'll see three out of the four users, because those three have a letter "r" somewhere in their name.
Now let's go a step further: let's upgrade our filter so that it matches people who have an "R" in their name and who live in London:
@Query(filter: #Predicate<User> { user in
user.name.localizedStandardContains("R") &&
user.city == "London"
}, sort: \User.name) var users: [User]
That uses Swift's "logical and" operator, which means both sides of the condition must be true in order for the whole condition to be true – the user's name must contain an "R" and they must live in London.
If we only had the first check for the letter R, then Ed, Rosa, and Roy would match. If we only had the second check for living in London, then Ed, Roy, and Johnny would match. Putting both together means that only Ed and Roy match, because they are the only two with an R somewhere in their name who also live in London.
You can add more and more checks like this, but using &&
gets a bit confusing. Fortunately, these predicates support a limited subset of Swift expressions that make reading a little easier.
For example, we could rewrite our current predicate to this:
@Query(filter: #Predicate<User> { user in
if user.name.localizedStandardContains("R") {
if user.city == "London" {
return true
} else {
return false
}
} else {
return false
}
}, sort: \User.name) var users: [User]
Now, you might be thinking that's a little verbose – that could remove both else
blocks and just end with return true
, because if the user actually matched the predicate the return true
would already have been hit.
Here's how that would look:
@Query(filter: #Predicate<User> { user in
if user.name.localizedStandardContains("R") {
if user.city == "London" {
return true
}
}
return false
}, sort: \User.name) var users: [User]
Sadly that code isn't actually valid, because even though it looks like we're executing pure Swift code it's important you remember that doesn't actually happen – the #Predicate
macro actually rewrites our code to be a series of tests it can apply on the database, which doesn't use Swift internally.
To see what's happening internally, press undo a few times to get the original version with two else
blocks. Now right-click on #Predicate
and select Expand Macro, and you'll see a huge amount of code appears. Remember, this is the actual code that gets built and run – it's what our #Predicate
gets converted into.
So, that's just a little of how #Predicate
works, and why some predicates you might try just don't quite work how you expect – this stuff looks easy, but it's really complex behind the scenes!
SAVE 50% All our books and bundles are half price for Black Friday, so you can take your Swift knowledge further without spending big! Get the Swift Power Pack to build your iOS career faster, get the Swift Platform Pack to builds apps for macOS, watchOS, and beyond, or get the Swift Plus Pack to learn advanced design patterns, testing skills, and more.
Link copied to your pasteboard.