UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

Dynamically sorting and filtering @Query with SwiftUI

Paul Hudson    @twostraws   

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: showingNewOnly ? .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:

  1. Three dots in a circle, and pressing that reveals the options.
  2. "Sort by Name" shown directly in the navigation bar, and tapping that lets you change to Join Date.

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!

Hacking with Swift is sponsored by Superwall

SPONSORED Superwall lets you build & test paywalls without shipping updates. Run experiments, offer sales, segment users, update locked features and more at the click of button. Best part? It's FREE for up to 250 conversions / mo and the Superwall team builds out 100% custom paywalls – free of charge.

Learn More

Sponsor Hacking with Swift and reach the world's largest Swift community!

BUY OUR BOOKS
Buy Pro Swift Buy Pro SwiftUI Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Beyond Code

Was this page useful? Let us know!

Average rating: 5.0/5

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.