Project 18: SwiftData

SwiftData: Introduction

This technique project is going to explore SwiftData in more detail, starting with a summary of some basic techniques then building up to tackling some more complex problems.

As you'll see, SwiftData really pushes hard on advanced features of both Swift and SwiftUI, all to help make it easy for us to store data efficiently. It's not always easy, though, and there are a few places that take quite a bit of thinking to use properly.

We have lots to explore, so please create a fresh project where we can try it out. Call it “SwiftDataProject” and not just “SwiftData” because that will cause Xcode to get confused.

Make sure you do not enable SwiftData for storage. Again, we'll be building this from scratch so you can see how it all works.

All set? Let’s go!

Editing SwiftData model objects

SwiftData's model objects are powered by the same observation system that makes @Observable classes work, which means changes to your model objects are automatically picked up by SwiftUI so that our data and our user interface stay in sync.

This support extends to the @Bindable property wrapper we looked at previously, which means we get delightfully straightforward object editing.

To demonstrate this, we could create a simple User class with a handful of properties. Create a new file called User.swift, add an import at the top for SwiftData, then give it this code:

@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 create a model container and model context for that by adding another import SwiftData in the App struct file then using modelContainer() like this:

WindowGroup {
    ContentView()
}
.modelContainer(for: User.self)

When it comes to editing User objects, we would create a new view called something like EditUserView, then use the @Bindable property wrapper to create bindings for it. This time we'll need to use a slight variant of the regular Picker view called DatePicker, which gives us much better UI for editing dates.

So, something like this:

struct EditUserView: View {
    @Bindable var user: User

    var body: some View {
        Form {
            TextField("Name", text: $user.name)
            TextField("City", text: $user.city)
            DatePicker("Join Date", selection: $user.joinDate)
        }
        .navigationTitle("Edit User")
        .padding()
    }
}

That's identical to how we used a regular @Observable class, and yet SwiftData still takes care of automatically writing out all our changes to permanent storage – it's completely transparent to us.

Important: If you want to use Xcode's previews with this, you need to pass a sample object in, which in turn means creating a custom configuration and container. First add an import for SwiftData, then change your preview to this:

#Preview {
    do {
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        let container = try ModelContainer(for: User.self, configurations: config)
        let user = User(name: "Taylor Swift", city: "Nashville", joinDate: .now)
        return EditUserView(user: user)
            .modelContainer(container)
    } catch {
        return Text("Failed to create container: \(error.localizedDescription)")
    }
}

We could make a really simple user editing app out of this by adding a new user when a button is pressed, then immediately selecting user for editing.

Let's build this step by step. First, open ContentView.swift and an import for SwiftData, then add properties to get access to the model context, load all our User objects, then store a selection we can bind to a List:

@Environment(\.modelContext) var modelContext
@Query(sort: \User.name) var users: [User]
@State private var selection: User?

Now replace the default body property with this:

NavigationSplitView {
    List(users, selection: $selection) { user in
        NavigationLink(value: user) {
            Text(user.name)
        }
    }
} detail: {
    if let selection {
        EditUserView(user: selection)
    } else {
        Text("Select a user")
    }
}

And now we just need a way to add users. If you think about it, adding and editing are very similar, so the easiest thing to do here is to create a new User object with empty properties, insert it into the model context, then immediately select it by adjusting our selection property.

Add this extra modifier below the two navigation modifiers:

.toolbar {
    Button("Add User", systemImage: "plus") {
        let user = User(name: "", city: "", joinDate: .now)
        modelContext.insert(user)
        selection = user
    }
}

And that works! In fact, it's pretty much the same approach Apple's own Notes app takes, although they add the extra step of automatically deleting the note if you navigate away from that note without actually adding any text.

Filtering @Query using #Predicate

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.

We already have code in place to create and display User instances, but I'd like you to add a button to the toolbar that will add some sample data easily:

Button("Add Samples", systemImage: "person.3") {
    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 of 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.

Now go ahead and run the app, then press the new 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:

  1. The filter starts with #Predicate<User>, which means we're writing a predicate (a fancy word for a test we're going to apply).
  2. That predicate gives us a single user instance to check. In practice that will be called once for each user loaded by SwiftData, and we need to return true if that user should be included in the results.
  3. Our test checks whether the user's name contains the capital letter R. If it does, the user will be included in the results, otherwise they won't.

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!

Dynamically sorting and filtering @Query with SwiftUI

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, selection: $selection) { user in
    NavigationLink(value: user) {
        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.

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]

We'll also need to store a binding to the selection from ContentView. This needs to reference the existing selection because it's used in the NavigationSplitView, so add this property:

@Binding var selection: 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]
    @Binding var selection: User?

    var body: some View {
        List(users, selection: $selection) { user in
            NavigationLink(value: user) {
                Text(user.name)
            }
        }
    }
}

#Preview {
    UsersView(selection: .constant(nil))
        .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.

Important: This initializer must also accept a binding to a user, which needs to be assigned to the local @Binding property.

Add this initializer to UsersView now:

init(selection: Binding<User?>, minimumJoinDate: Date = .now) {
    _selection = selection

    _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 are underscores before both selection and users.

That underscore is intentional: we aren't trying to change the selected user or the User array, but instead we're trying to change the binding and the SwiftData query that produces the array, respectively. 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 with this:

UsersView(selection: $selection, 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(selection: Binding<User?>, minimumJoinDate: Date = .now, sortOrder: [SortDescriptor<User>]) {
    _selection = selection

    _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(selection: .constant(nil), 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(selection: $selection, 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:

  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") {
    Picker("Sort", selection: $sortOrder) {
        // current picker code
    }
    .pickerStyle(.inline)
    .labelsHidden()
}

Try it again and you'll see it's much better, and more important both our dynamic filtering and sorting now work great!

Tip: The two extra Picker modifiers make the picker a direct descendent of the menu button, and also removes the "Sort" title from the submenu.

Relationships with SwiftData, SwiftUI, and @Query

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

These are called relationships, and they come in all sorts of forms. SwiftData does a good job of forming these relationships automatically as long as you tell it what you want, although there's still some room for surprises!

Let's try them out now, building on our existing User model. We could extend it to say that each User can have an array of jobs attached to them – tasks they need to complete as part of their work. To do that, 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 I've made the owner property refer directly to the User model – I've told SwiftData explicitly that the two models are linked together.

And now we can adjust the User model to create the jobs array:

var jobs = [Job]()

So, jobs have an owner, and users have an array of jobs – the relationship goes both ways, which is usually a good idea because it makes your data easier to work with.

That array will start working immediately: SwiftData will load all the jobs for a user when they are first requested, so if they are never used at all it will just skip that work.

Even better, the next time our app launches 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 properties in our models, as our needs evolve over time. SwiftData can do simple migrations like this one automatically, but as you progress further you'll learn how you can create custom migrations to handle bigger model changes.

Tip: When we used the modelContainer() modifier in our App struct, we passed in User.self so that SwiftData knew to set up storage for that model. We don't need to add Job.self there because SwiftData can see there's 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 go ahead and use the array like normal. For example, we could show a list of users and their job count like this:

List(users, selection: $selection) { user in
    NavigationLink(value: user) {
        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 work with some actual data, you can either create a SwiftUI view to create new Job instances for the selected user, but for testing purposes we can just extend the Add Samples button we added earlier so that it creates some sample jobs too – add this to the end of the method:

let job1 = Job(name: "Organize sock drawer", priority: 3)
let job2 = Job(name: "Write next album", priority: 4)
first.jobs.append(job1)
first.jobs.append(job2)

Again, notice how all that code is just regular Swift – SwiftData is completely invisible here.

I encourage you to try experimenting here a little bit. Your starting point should always be to assume that working with your data is just like working with a regular @Observable class – just let SwiftData do its thing until you have a reason to do otherwise!

There is one small catch, though, and it's worth covering before we move on: we've linked User and Job so that one user can have lots of jobs to do, but what happens if we delete a user?

The answer is that all their jobs remain intact – they don't get deleted. This is a smart move from SwiftData, because you don't get any surprise data loss.

If you specifically want all a user's job objects to be deleted at the same time, we need to tell SwiftData that. This is done using an @Relationship macro, providing it with a delete rule that describes how Job objects should be handled when their owning User is deleted.

The default delete rule is called .nullify, which means the owner property of each Job object gets set to nil, marking that they have no owner. We're going to change that to be .cascade, which means deleting a User should automatically delete all their Job objects. It's called cascade because the delete keeps going for all related objects – if our Job object had a locations relationship, for example, then those would also be deleted, and so on.

So, change the jobs property in User to this:

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

And now we're being explicit, which means we don't leave any hidden Job objects around when deleting a user – much better!

Syncing SwiftData with CloudKit

SwiftData can sync all your user's data with iCloud, and best of all it often takes absolutely no code.

Before you start, there's an important warning: syncing data to iCloud requires an active Apple developer account. If you don't have one, the following will not work.

Still here? Okay, in order to sync data from local SwiftData storage up to iCloud, you need to enable the iCloud capability for your app. We haven't customized our app capabilities before, so this step is new.

First, click the "SwiftDataTest" app icon at the top of your project navigator. This should be directly above the SwiftDataTest group.

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

Third, press "+ CAPABILITY" and select iCloud, which should make iCloud appear in the list of active capabilities – you'll see three services are possible, a "CloudKit Console" button, and more.

Fourth, check the box marked CloudKit, which is what allows our app to store SwiftData information in iCloud. You'll also need to press the + button to add a new CloudKit container, which configures where the data is actually stored in iCloud. You should use your app's bundle ID prefix with "iCloud." here, for example iCloud.com.hackingwithswift.swiftdatatest.

Fifth, press "+ CAPABILITY" again, then add the Background Modes capability. This has a whole bunch of configuration options, but you only need to check the "Remote Notifications" box – that allows the app to be notified when data changes in iCloud, so it can be synchronized locally.

And that's it – your app is all set to use iCloud for synchronizing SwiftData.

Perhaps.

You see, SwiftData with iCloud has a requirement that local SwiftData does not: all properties must be optional or have default values, and all relationship must be optional. The first of those is a small annoyance, but the second is a much bigger annoyance – it can be quite disruptive for your code.

However, they are requirements and not merely suggestions. So, in the case of Job we'd need to adjust its properties to this:

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

And for User, we'd need to use this:

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 will simply not work. If you look through Xcode's log – and CloudKit loves to write to Xcode's log – and scroll near the very top, SwiftData should try to warn you when any properties have stopped iCloud syncing from working correctly.

Once you've adjusted your models, you now need to change any code to handle the optionality correctly. For example, adding jobs to a user might use optional chaining like this:

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

And reading the count of a user's jobs might use optional chaining and nil coalescing, like this:

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

I'm not a particularly big fan of scattering that kind of code everywhere around a project, so if I'm using jobs regularly I'd much rather create a read-only computed property called unwrappedJobs or similar – something that either returns jobs if it has a value, or an empty array otherwise, like this:

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

It's a small thing, but it does help smooth over the rest of your code, and making it read-only prevents you trying to change a missing array by accident.

Important: Although the simulator is created at testing local SwiftData applications, it's pretty terrible at testing iCloud – you might find your data isn't synchronized correctly, quickly, or even at all. Please use a real device to avoid problems!

SwiftData: Wrap up

Although we've only really scratched the surface of what SwiftData can do, you’ve still seen how it can add, delete, sort, filter, and more, all with relatively simple code. Yes, a few parts are a little murky in Swift – #Predicate, for example, can take some getting used to – but as long as you're careful you should be fine.

Perhaps the most important thing about SwiftData is that it’s guaranteed to be there for all apps, on all of Apple’s platforms. This means you can use it regardless of your needs: maybe it’s for saving important data, maybe it’s just a cache of content you downloaded; it doesn’t matter, because SwiftData will do a great job of managing it for you.

Challenge

To apply the skills you've learned here, try upgrading one or more of the other apps in this book to use SwiftData – MultiMap (project 5) and TimeBuddy (project 10) would both be great places to start!

Back to Table of Contents

Copyright © 2023 Paul Hudson, hackingwithswift.com.
You should follow me on Twitter.