Skip to main content
  1. SwiftUI in 100 Days Notes/

Day 58 - SwiftUI SwiftData: Query Sorting and Filtering, Relationship and CloudKit Synchronization

Today we will work on topics such as NSPredicate, dynamically changing fetch requtes, creating relationships. The topics we will work on today;

  1. Dynamically sorting and filtering @Query with SwiftUI
  2. Relationship with SwiftData, SwiftUI and @Query
  3. Synchronize SwiftData with CloudKit

Dynamically Sort and Filter @Query with SwiftUI #

Now that you’ve seen a bit of how SwiftData’s #Predicate works, your next question is probably “how can I make it work with user input?”. The answer is … complicated. I’ll show you how it’s done, and how the same technique can be used to dynamically adjust the ordering, but it will take you a while to remember how.

Based on the SwiftData code we looked at earlier, each user object had a different joinDate property, some in the past and some in the future. We also had a List that showed the results of a query:

List(users) { user in
    Text(user.name)
}

What we are going to do is to move this list to a separate view - specifically a view to run the SwiftData query and show its results, then optionally have it show all users or only users who will join in the future.

So, create a new SwiftUI view called UsersView, add a SwiftData import to it, then move the List code there without including any of its modifiers - just the code shown above.

Now that we have the SwiftData results displayed in the UsersView, we need to add a @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 add modelContainer() modifier to preivew. Finally you should get a code like the one below.

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 are done with this view, we need a way to customize the query being executed. As it stands, just using @Query var users: [User] means that SwiftData will load all users without filter and sort order, but we really want to customize one or both of them from the ContentView.

This is best done by passing a value to the view using an initializer and then building the query using that value. As I mentioned earlier, our goal is to either show all users or only users who will join in the future. We will accomplish this by passing a minimum join date and making sure that all users have joined at least after that date.

Add this initializer to UserView now;

init(minimumJoinDate: Date) {
    _users = Query(filter: #Predicate<User> { user in
        user.joinDate >= minimumJoinDate
    }, sort: \User.name)
}

This is mostly the code you are used to, but notice that there is an underscore before users. This is intentional: we are not trying to modify the User array, we are trying to modify the SwiftData query that generates the array. The underscore is the way Swift accesses this query, i.e. we create the query from the date passed in.

At this point, we are done with UsersView, so now we need to go back to ContentView and delete the existing @Query property and replace it with code that modifies some kind of Boolean value and pass the current state of that value to UsersView.

First, add this new @State property to ContentView:

@State private var showingUpcomingOnly = false

And now replace the List code in ContentView - again, without including modifiers - with this:

UsersView(minimumJoinDate: showingUpcomingOnly ? .now : .distantPast)

This passes one of two dates to the UsersView: When our Boolean property is true, we pass .now to show only users who will join after the current time, otherwise we pass .distantPast, which is at least 2000 years ago. Unless there are some Roman emperors among our users, all users will be shown, as their join dates will be long after that.

Add this to the ContentView toolbar:

Button(showingUpcomingOnly ? "Show Everyone" : "Show Upcoming") {
    showingUpcomingOnly.toggle()
}

This changes the tag of the button so that it always reflects what happens the next time it is pressed.

This completes all the work, so if you run the application now, you will see that you can dynamically change the list of users.

Yes, it’s quite a lot of work, but as you can see it works perfectly and you can apply the same technique to other types of filtering.

This approach works equally well for sorting data: We can check an array of sort descriptors in ContentView, then pass them to the initializer of UsersView to set up the query.

First, we need to upgrade the UsersView initializer to accept some kind of sort descriptor for our User class. This again uses Swift’s generics: SortDescriptor type needs to know what it is sorting, so we need to specify User in square brackets.

Change the UsersView initializer to this:

init(minimumJoinDate: Date, sortOrder: [SortDescriptor<User>]) {
    _users = Query(filter: #Predicate<User> { user in
        user.joinDate >= minimumJoinDate
    }, sort: sortOrder)
}

You will also need to update your preview code to pass a sample sort order so that your code compiles properly:

UsersView(minimumJoinDate: .now, sortOrder: [SortDescriptor(\User.name)])
    .modelContainer(for: User.self)

Go back to ContentView and add another new property to keep the current sort order. We’ll do it in a way that uses name first then join date, this seems like a logical default:

@State private var sortOrder = [
    SortDescriptor(\User.name),
    SortDescriptor(\User.joinDate),
]

Then we can pass it to the UsersView as we did with the join date:

UsersView(minimumJoinDate: showingUpcomingOnly ? .now : .distantPast, sortOrder: sortOrder)

And finally we need a way to dynamically adjust this array. One option is to use a Picker that shows two options: Sort by Name and Sort by Join Date. This in itself is not difficult, but how do we add a SortDescriptor array to each option?

The answer lies in a handy modifier called tag(). This allows us to add specific values of our own choosing to each picker option. Here this means that we can make each option’s tag its own SortDescriptor array and SwiftUI will automatically assign this tag to the sortOrder property.

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)
        ])
}

Now when you run the app, you probably won’t see what you expect. Depending on the device you are using, instead of showing “Sort” as a menu with options, you will see one of the following:

  1. Three dots in a circle and when pressed, the options appear.
  2. “Sort by Name” is shown directly in the navigation bar, and tapping it will let you jump to the Join Date.

Neither option is great, but I would like to take this opportunity to introduce another useful SwiftUI view, Menu. This allows you to create menus in the navigation bar and you can place buttons, selectors and more inside it.

In this case, if we wrap our existing Picker code with a Menu, we will get a much better result. Try this:

Menu("Sort", systemImage: "arrow.up.arrow.down") {
    // current picker code
}

Try it again and you will see that it is much better. More importantly, both our dynamic filtering and sorting now work great!

SwiftData, SwiftUI and relationship with @Query #

SwiftData allows us to create models that reference each other. For example, we can say that a School model has an array of many Student objects, or that an Employee model stores a Manager object.

These are called relationships and they come in various forms. SwiftData does a good job of automatically creating these relationships as long as you tell it what you want, but there is still room for some surprises!

Let’s try them out now. We already have the following User model:

@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
    }
}

We can extend this to say that each User can have a job array attached to it - tasks that they need to complete as part of their job. To do this, we first need to create a new Job model like this:

@Model
class Job {
var name: String
var priority: Int
var owner: User?

init(name: String, priority: Int, owner: User? = nil) {
self.name = name
self.priority = priority
self.owner = owner
    }
}

Notice how the owner property directly references the User model - I explicitly told SwiftData that the two models are connected.

And now we can set the User model to create a job array:

var jobs = [Job]()

So, jobs have an owner and users have a job array - the relationship is bidirectional, which is usually a good idea because it makes it easier to work with your data.

This array will start working immediately: SwiftData will load all of a user’s jobs the first time they are requested, so if they are never used, it will skip that job.

Even better, the next time our application is launched, SwiftData will silently add the jobs property to all its existing users, giving them an empty array by default. This is called a migration: when we add or delete features in our models as our needs evolve over time. SwiftData can do simple migrations like this automatically, but as you progress you will learn how you can create custom migrations to handle larger model changes.

**When we use the modelContainer() modifier in our App struct, we pass User.self so that SwiftData knows that it needs to set storage for this model. We don’t need to add Job.self there because SwiftData can see that there is a relationship between the two, so it takes care of both automatically.

You don’t need to change the @Query you use to load your data, just keep using the array as normal. For example, we can show a list of users and the number of jobs as follows:

List(users) { user in
    HStack {
        Text(user.name)

        Spacer()

        Text(String(user.jobs.count))
            .fontWeight(.black)
            .padding(.horizontal, 10)
            .padding(.vertical, 5)
            .background(.blue)
            .foregroundStyle(.white)
            .clipShape(.capsule)
    }
}

If you want to see it working with some real data, you can create a SwiftUI view that will create new Job instances for the selected user, but for testing purposes we can take a small shortcut and add some sample data.

First, add a property to access the active SwiftData model context:

@Environment(\.modelContext) var modelContext

And now add a method like this to generate some sample data:

func addSample() {
let user1 = User(name: "Piper Chapman", city: "New York", joinDate: .now)
let job1 = Job(name: "Organize sock drawer", priority: 3)
let job2 = Job(name: "Make plans with Alex", priority: 4)

    modelContext.insert(user1)

    user1.jobs.append(job1)
    user1.jobs.append(job2)
}

Again, notice that almost all of this code is just regular Swift - only one line is really SwiftData related.

To see this immediately, add the following modifier to List;

.onAppear(perform: addSample)

Your starting point should always be to assume that working with your data is just like working with a normal @Observable class - let SwiftData do its thing until you have a reason to do otherwise!

There is one small problem though, and it’s worth addressing it before moving on: We’ve linked User and Job in such a way that a user can have many jobs to do, so what happens if we delete a user?

The answer is that all their jobs remain intact - they are not deleted. This is a smart move from SwiftData, because there is no surprise data loss.

If you specifically want all of a user’s job objects to be deleted at the same time, we need to tell SwiftData that. This is done using a @Relationship macro and providing a deletion rule that describes how Job objects should be handled when the owner User is deleted.

The default deletion rule is called .nullify, which means that the owner property of each Job object is set to nil, indicating that it has no owner. We will change this to .cascade, meaning that deleting one User should automatically delete all Job objects. This is called a cascade because the deletion continues for all related objects - for example, if our Job object had a locations relation, these would also be deleted, and so on.

So, change the jobs property in User like this:

@Relationship(deleteRule: .cascade) var jobs = [Job]()

And we are now open, meaning we don’t leave any hidden Job objects when deleting a user - much better!

Synchronizing SwiftData with CloudKit #

SwiftData can synchronize all your user data with iCloud, and the best part is that this usually requires writing no code at all.

Before we start, there’s an important caveat: Synchronizing data to iCloud requires an active Apple developer account. If you don’t have an account, the following will not work.

You’re still here, right? Ok, to sync data from local SwiftData storage to iCloud, you need to enable iCloud in your app. We haven’t customized app capabilities before, so this step is new.

First, click on the “SwiftDataTest” app icon at the top of your project explorer. This should be just above the SwiftDataTest group.

Second, select “SwiftDataTest” under the “TARGETS” list. You should see a bunch of tabs: General, Signing & Capabilities, Resource Tags, Info and more. We want Signing & Capabilities, so now please select it.

Third, press the “+ CAPABILITY” button and select iCloud, this will make iCloud appear in the list of active capabilities - you will see three services possible, a “CloudKit Console” button and more.

Fourth, check the box with CloudKit checked, which will allow our app to store SwiftData information in iCloud. You will also need to press the + button to add a new CloudKit container that configures where the data is actually stored in iCloud. Here you should use your app’s package ID prefix with “iCloud.”, for example iCloud.com.hackingwithswift.swiftdatatest.

Fifth, press the “+ CAPABILITY” button again and add the Background Modes capability. This has a bunch of configuration options, but you just need to check the “Remote Notifications” box - this will let the app know when data changes in iCloud so it can be synchronized locally.

And that’s it - your app is ready to use iCloud to synchronize SwiftData.

Maybe.

You see, SwiftData with iCloud has a requirement that native SwiftData does not require: all properties must be optional or have default values and all relations must be optional. The first requirement is a minor annoyance, but the second one is a much bigger annoyance - it can be quite disruptive for your code.

But these are requirements, not suggestions. Therefore, in the Job example we will need to set the properties in this way:

var name: String = "None"
var priority: Int = 1
var owner: User?

Ve User için, bunu kullanmamız gerekecek:

var name: String = "Anonymous"
var city: String = "Unknown"
var joinDate: Date = Date.now
@Relationship(deleteRule: .cascade) var jobs: [Job]? = [Job]()

Important: If you don’t make these changes, iCloud simply won’t work. If you look at Xcode’s logs - and CloudKit loves to write to Xcode’s logs - when you scroll near the top, SwiftData should try to warn you when any feature is preventing iCloud synchronization from working correctly.

Once you’ve set up your models, your code needs to be modified to handle optional’s correctly. For example, when adding a job to a user, optional chaning can be used like this:

user1.jobs?.append(job1)
user1.jobs?.append(job2)

And reading the number of jobs of a user can be done like this, using optional chaining and nil coalescing:

Text(String(user.jobs?.count ?? 0))

I’m not a big fan of spreading this kind of code all over the project, so if I use jobs regularly, I prefer to create a read-only computed property called unwrappedJobs or something similar - this property returns jobs if it has a value, otherwise it returns an empty array, like this:

var unwrappedJobs: [Job] {
    jobs ?? []
}

It’s a small detail, but it helps to make the rest of the code smoother, and making it read-only prevents you from accidentally trying to replace a missing array.

Important: The simulator is built to test native SwiftData apps, but it’s quite inadequate at testing iCloud - you may find that your data doesn’t sync correctly, quickly or at all. Please use a real device to avoid problems!


You can also read this article in Turkish.
Bu yazıyı Türkçe olarak da okuyabilirsiniz.

This article contains the notes I took for myself from the articles found at SwiftUI Day 58. Please use the link to follow the original lesson.