Configuring SwiftData in a SwiftUI app

Sponsored

Codemagic logo
Codemagic CI/CD for mobile teams

What do you get when you put love for iOS and DevOps together? Answer: Codemagic CI/CD

During WWDC 2023 Apple announced a new long-awaited refresh to persistence in Apple platforms in the form of a new framework: SwiftData. SwiftData, which is available from iOS 17, allows you to model your app’s persistent data using Swift types and perform CRUD operations in a type-safe and declarative way.

In this article, I will show you how I configured SwiftData for one of my SwiftUI apps and some of the things I have learnt along the way.

Creating a model

Let’s say you want to make an app that allows users to save restaurants they visit and you want to persist this information on device using SwiftData.

The first thing you would need to do is create a model that represents a restaurant visit by decorating a Swift class with SwiftData’s Model macro:

DiaryEntry.Swift
import SwiftData

@Model
final class DiaryEntry {
    let restaurant: String
    let createdAt: Date

    init(restaurant: String, createdAt: Date = .now) {
        self.restaurant = restaurant
        self.createdAt = createdAt
    }
}

The model defines two properties: restaurant, which is the name of the restaurant the user has visited, and createdAt, which is the date the restaurant was visited at.

As you can see, it is very easy to convert your existing models into SwiftData models, all you usually need to do is decorate them with the @Model macro 🎉.

Schemas and versioning

As shown during the Model your schema with SwiftData WWDC session, it is good practice to encapsulate each version of your app’s model in its schema type. This is the first step to allow you to seamlessly switch between different versions of your SwiftData models.

To create a schema version, define a new type that conforms to the VersionedSchema protocol and conform to it by implementing the versionIdentifier and models properties:

DiaryEntrySchemaV1.swift
import SwiftData

enum DiaryEntryV1Schema: VersionedSchema {
    static var versionIdentifier: String? = "v1"
    static var models: [any PersistentModel.Type] { [DiaryEntry.self] }

    @Model
    final class DiaryEntry {
        let restaurant: String
        let createdAt: Date

        init(restaurant: String, createdAt: Date = .now) {
            self.restaurant = restaurant
            self.createdAt = createdAt
        }
    }
}

You can set the versionIdentifier property to any string you want that uniquely identifies the version of your schema. You also need to set the models property to an array of all the SwiftData models that are part of this version of your schema, including all relationship types.

Going the extra mile

Now that your models are namespaced to the schema type, you need to use the fully qualified name of the model type (DiaryEntryV1Schema.DiaryEntry) across your app.

The problem with this is that if you ever need to create a new version of your schema, you will need to go through your entire codebase and update all references to the model type to use the new version of the schema.

To guard against this, you can create a typealias called DiaryEntry that references the current version of your model and use this new typealias throughout your app instead:

DiaryEntry.swift
typealias DiaryEntry = DiaryEntryV1Schema.DiaryEntry

🎉 This way, whenever you need to change the version of your schema you only need to update the typealias rather than going through your entire codebase and updating all references to the model type.

Creating a migration plan

The next step is to create a migration plan that will tell SwiftData how it needs to convert data from one version of your schema to another.

SwiftData migration plans are defined by creating a new type that conforms to the SchemaMigrationPlan protocol:

DiaryEntryMigrationPlan.swift
import SwiftData

enum MigrationPlan: SchemaMigrationPlan {
    static var schemas: [VersionedSchema.Type] {
        [
            DiaryEntryV1Schema.self
        ]
    }

    static var stages: [MigrationStage] {
        []
    }
}

As you can see, the migration plan above defines a single schema DiaryEntryV1Schema and no migration stages. As the app only has a single version of the schema, there is no need to define any migration stages yet.

Even if you don’t have to migrate any data yet, I think it’s a good idea to do all of this set-up ahead of time so you have all the configuration in place for when you need it.

I am planning on writing a detailed article on SwiftData migrations soon so stay tuned for that 👀.

Creating the model container

Once you have defined your model and schema, you need to create a model container and make it available to your app’s view hierarchy through SwiftData’s modelContainer view modifier:

FoodieDiariesApp.swift
import SwiftUI
import SwiftData

@main
struct FoodieDiariesApp: App {
    let container: ModelContainer

    init() {
        do {
            container = try ModelContainer(
                for: [DiaryEntry.self],
                migrationPlan: MigrationPlan.self,
                ModelConfiguration(for: [DiaryEntry.self])
            )
        } catch {
            fatalError("Could not initialise the container...")
        }
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(container)
    }
}

As opposed to the set-up code shown in the Model your schema with SwiftData WWDC session, if you want to use migration plans you need to initialise the model container with three arguments:

  1. The SwiftData model types your application uses.
  2. The type of the migration plan you created earlier.
  3. A ModelConfiguration instance for the @Model decorated type you want to use. This last argument feels redundant but not passing it results in a runtime crash.

To make your life easier, I would suggest creating a second typealias to wrap your current schema type (which you can then use to initialise the model container) and modify the DiaryEntry typealias you created earlier to use the schema one:

DiaryEntry.swift
typealias DiarySchema = DiaryEntryV1Schema
typealias DiaryEntry = DiarySchema.DiaryEntry

Querying models from views

The modelContainer view modifier makes the model container available to all child views through SwiftUI’s environment. SwiftData ships with a @Query property wrapper that uses the model container in the environment to query models directly from your views:

DiaryEntryListView.swift
import SwiftUI
import SwiftData

struct DiaryEntryListView: View {
    @Query(sort: \.createdAt, order: .reverse) private var entries: [DiaryEntry]

    var body: some View {
        List {
            ForEach(entries) { entry in
                Text(entry.restaurant)
            }
        }
    }
}

The @Query property wrapper above also allows you to sort and filter the results in a Swifty and type-safe way through key paths 🤩.

Accessing the model context from a view

Having the model container available in the environment also allows you to access the model context directly from your views to perform operations such as deleting entries from the database:

DiaryEntryListView.swift
import SwiftUI
import SwiftData

struct DiaryEntryListView: View {
    @Environment(\.modelContext) private var modelContext
    @Query(sort: \.createdAt, order: .reverse) private var entries: [DiaryEntry]

    var body: some View {
        List {
            ForEach(entries) { entry in
                Text(entry.restaurant)
                    .swipeActions {
                        Button(action: {
                            modelContext.delete(entry)
                            try? modelContext.save()
                        }, label: { Label("Delete", systemImage: "xmark") })
            }
        }
    }
}