Now that you've seen a little of how SwiftData's #Predicate
works, the next question you're likely to have is "how can I make it work with user input?" The answer is… it's complicated. I'll show you how it's done, and also how the same technique can be used to dynamically adjust sorting, but it's going to take you a little while to remember how it's done – hopefully Apple can improve this in the future!
If we build on the previous SwiftData code we looked at, each user object had a different joinDate
property, some in the past and some in the future. We also had a List
showing the results of a query:
List(users) { user in
Text(user.name)
}
What we're going to do is move that list out into a separate view – a view specifically for running the SwiftData query and showing its results, then make it optionally show all users or only users who are joining in the future.
So, create a new SwiftUI view call UsersView
, give it a SwiftData import, then move the List
code there without moving any of its modifier – just the code shown above.
Now that we're displaying SwiftData results in UsersView
, we need to add an @Query
property there. This should not use a sort order or predicate – at least not yet. So, add this property there:
@Query var users: [User]
And once you add a modelContainer()
modifier to the preview, your UsersView.swift code should look like this:
import SwiftData
import SwiftUI
struct UsersView: View {
@Query var users: [User]
var body: some View {
List(users) { user in
Text(user.name)
}
}
}
#Preview {
UsersView()
.modelContainer(for: User.self)
}
Before we're done with this view, we need a way to customize the query that gets run. As things stand, just using @Query var users: [User]
means SwiftData will load all the users with no filter or sort order, but really we want to customize one or both of those from ContentView
– we want to pass in some data.
This is best done by passing a value into the view using an initializer, then using that to create the query. As I said earlier, our goal is to either show all users, or just show users who are joining in the future. So, we'll accomplish that by passing in a minimum join date, and ensuring that all users join at least after that date.
Add this initializer to UsersView
now:
init(minimumJoinDate: Date) {
_users = Query(filter: #Predicate<User> { user in
user.joinDate >= minimumJoinDate
}, sort: \User.name)
}
That's mostly code you're used to, but notice that there's an underscore before users
. That's intentional: we aren't trying to change the User
array, we're trying to change the SwiftData query that produces the array. The underscore is Swift's way of getting access to that query, which means we're creating the query from whatever date gets passed in.
At this point we're done with UsersView
, so now back in ContentView
we need to delete the existing @Query
property and replace it with code to toggle some kind of Boolean, and pass its current value into UsersView
.
First, add this new @State
property to ContentView
:
@State private var showingUpcomingOnly = false
And now replace the List
code in ContentView
– again, not including its modifiers – with this:
UsersView(minimumJoinDate: showingUpcomingOnly ? .now : .distantPast)
That passes one of two dates into UsersView
: when our Boolean property is true we pass in .now
so that we only show users who will join after the current time, otherwise we pass in .distantPast
, which is at least 2000 years in the past – unless our users include some Roman emperors, they will all have join dates well after this and so all users will be shown.
All that remains now is to add a way to toggle that Boolean inside ContentView
– add this to the ContentView
toolbar:
Button(showingUpcomingOnly ? "Show Everyone" : "Show Upcoming") {
showingUpcomingOnly.toggle()
}
That changes the button's label so that it always reflect what happens when it's next pressed.
That completes all the work, so if you run the app now you'll see you can change the list of users dynamically.
Yes, it's quite a bit of work, but as you can see it works brilliantly and you can apply the same technique to other kinds of filtering too.
This same approach works equally well with sorting data: we can control an array of sort descriptors in ContentView
, then pass them into the initializer of UsersView
to have them adjust the query.
First, we need to upgrade the UsersView
initializer so that it accepts some kind of sort descriptor for our User
class. This uses Swift's generics again: the SortDescriptor
type needs to know what it's sorting, so we need to specify User
inside angle brackets.
Modify the UsersView
initializer to this:
init(minimumJoinDate: Date, sortOrder: [SortDescriptor<User>]) {
_users = Query(filter: #Predicate<User> { user in
user.joinDate >= minimumJoinDate
}, sort: sortOrder)
}
You'll also need to update your preview code to pass in a sample sort order, so that your code compiles properly:
UsersView(minimumJoinDate: .now, sortOrder: [SortDescriptor(\User.name)])
.modelContainer(for: User.self)
Back in ContentView
we another new property to store the current sort order. We'll make this use name then join date, which seems like a sensible default:
@State private var sortOrder = [
SortDescriptor(\User.name),
SortDescriptor(\User.joinDate),
]
We can then pass that into UsersView
just like we did with the join date:
UsersView(minimumJoinDate: showingUpcomingOnly ? .now : .distantPast, sortOrder: sortOrder)
And finally we need a way to adjust that array dynamically. One option is to use a Picker
showing two options: Sort by Name, and Sort by Join Date. That in itself isn't tricky, but how do we attach a SortDescriptor
array to each option?
The answer lies in a useful modifier called tag()
, which lets us attach specific values of our choosing to each picker option. Here that means we can literally make the tag of each option its own SortDescriptor
array, and SwiftUI will assign that tag to the sortOrder
property automatically.
Try adding this to the toolbar:
Picker("Sort", selection: $sortOrder) {
Text("Sort by Name")
.tag([
SortDescriptor(\User.name),
SortDescriptor(\User.joinDate),
])
Text("Sort by Join Date")
.tag([
SortDescriptor(\User.joinDate),
SortDescriptor(\User.name)
])
}
When you run the app now, chances are you won't see what you expected. Depending on which device you're using, rather than showing "Sort" as a menu with options inside, you'll either see:
Both options aren't great, but I want to use this chance to introduce another useful SwiftUI view called Menu
. This lets you create menus in the navigation bar, and you can place buttons, pickers, and more inside there.
In this case, if we wrap our current Picker
code with a Menu
, we'll get a much better result. Try this:
Menu("Sort", systemImage: "arrow.up.arrow.down") {
// current picker code
}
Try it again and you'll see it's much better, and more important both our dynamic filtering and sorting now work great!
SPONSORED Debug 10x faster with Proxyman. Your ultimate tool to capture HTTPs requests/ responses, natively built for iPhone and macOS. You’d be surprised how much you can learn about any system by watching what it does over the network.
Sponsor Hacking with Swift and reach the world's largest Swift community!
Link copied to your pasteboard.