Configuring SwiftData in a SwiftUI app
Helm Pro yearly subscribers now get a 30% discount on RocketSim thanks to contingent pricing on the App Store.
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:
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:
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:
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:
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:
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:
- The SwiftData model types your application uses.
- The type of the migration plan you created earlier.
- 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:
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:
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:
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") })
}
}
}
}