SwiftData, my first look
This article is part of a series.
SwiftData seems to be a very good fit for simple projects. When using defaults developers get a lot of features right out of the gate, like change history and undo. Default settings also syncs across devices easily with CloudKit, with a few caveats.
In 2024 Apple added a DataStore
protocol so you can swap in your own preferred persistence style, rather than the very fancy wrapper around SQLite that it comes with out of the box.
Resources
- https://www.hackingwithswift.com/quick-start/swiftdata
- https://developer.apple.com/videos/play/wwdc2023/10187/ (2023 - Meet SwiftData)
- https://developer.apple.com/videos/play/wwdc2024/10137/ (2024 - What’s new in SwiftData)
- https://developer.apple.com/videos/play/wwdc2024/10138 (2024 - Create a custom data store with SwiftData)
- https://developer.apple.com/videos/play/wwdc2023/10196 (2023 - Dive deeper into SwiftData)
- https://developer.apple.com/xcode/swiftdata/
- https://www.hackingwithswift.com/quick-start/swiftdata/whats-the-difference-between-modelcontainer-modelcontext-and-modelconfiguration
The pieces
After going through some basic hello world tutorials/intro WWDC videos, definitely do watch the 2023 Dive Deeper video to understand how the parts work together.
Schema
If this word is unfamiliar to you: https://en.wikipedia.org/wiki/Database_schema
@Model
‘ing classes in your code is how SwiftData
adds what it needs to use those classes to build a schema. There are a couple of ways to set up a Schema
type. The fast way when there is only one class is just to give that @Model
decorated class to the simple ModelContainer
view modifier style init.
@Model
final class MyModelType {
var item:Item?
init(item:Item) {
self.item = item
}
}
SomeView().modelContainer(for: MyModelType.self, inMemory: true)
Expanding that macro adds the following code:
import Foundation
import SwiftData
final class MyModelType {
@_PersistedProperty var item:Item?
init(textInfo: String, timestamp: Date) {
self.item = Item(textInfo: textInfo, timestamp: timestamp)
}
@Transient
private var _$backingData: any SwiftData.BackingData<MyModelType> = MyModelType.createBackingData()
public var persistentBackingData: any SwiftData.BackingData<MyModelType> {
get {
return _$backingData
}
set {
_$backingData = newValue
}
}
static var schemaMetadata: [SwiftData.Schema.PropertyMetadata] {
return [
SwiftData.Schema.PropertyMetadata(name: "item", keypath: \MyModelType.item, defaultValue: nil, metadata: nil)
]
}
init(backingData: any SwiftData.BackingData<MyModelType>) {
_item = _SwiftDataNoType()
self.persistentBackingData = backingData
}
@Transient private let _$observationRegistrar = Observation.ObservationRegistrar()
internal nonisolated func access<_M>(
keyPath: KeyPath<MyModelType, _M>
) {
_$observationRegistrar.access(self, keyPath: keyPath)
}
internal nonisolated func withMutation<_M, _MR>(
keyPath: KeyPath<MyModelType, _M>,
_ mutation: () throws -> _MR
) rethrows -> _MR {
try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
}
struct _SwiftDataNoType {
}
}
extension MyModelType: SwiftData.PersistentModel {
}
extension MyModelType: Observation.Observable {
}
Note the conformance to Observation.Observable
at the end there, which is part of how SwiftData
works so seamlessly with SwiftUI.
If there will be more than one class as part of the schema one can declare a Schema
type explicitly by giving it an array of all the relevant macro’ed types.
let schema = Schema([
MyModelType.self, MyHelperType.self
])
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
Glue Layer Between Models and Persistence
So whole point of using SwiftData
is to persist the data associated with the models across app launches. There needs to be code that moves the information between active use and persistent storage and vice versa. In SwiftData
the ModelContainer
hold the description of how that data will be saved and retrieved.
ModelContainer
has a configuration type ModelConfiguration
with several initializers like the two below:
ModelConfiguration(_ name: String?,
schema: Schema?,
isStoredInMemoryOnly: Bool,
allowsSave: Bool,
groupContainer: ModelConfiguration.GroupContainer,
cloudKitDatabase: ModelConfiguration.CloudKitDatabase)
ModelConfiguration(<name: String?,
schema: Schema?,
url: URL,
allowsSave: Bool,
cloudKitDatabase: ModelConfiguration.CloudKitDatabase)
One can instantiate a model container using a Schema
and an array of configurations:
return try ModelContainer(for: schema, configurations: [modelConfiguration])
This initializer can fail because some aspect of what you’re telling the ModelContainer
to believe about the persistence layer could end up being wrong. The file location could not exist. The code could not have permission to change the file, etc.
That’s in contrast to the convenience initializer that uses the default data store as the basis of its assumed configuration. (recall: SomeView().modelContainer(for: MyModelType.self, inMemory: true)
)
Once the ModelContainer
gets created it gets passed into an App’s ModelContext
. The ModelContext
does the actually pretty darn fancy footwork of moving information between the active processes and the data storage. For example, if with every little change of information the program was constantly writing to disk the code would end up being very slow. The ModelContext
batches the changes, balancing speed and safety.
Make a struct and a matching model
The easiest way to check out SwiftData
would be be to create a new project with SwiftData
the CloudKit
options selected. But following the HWS example listed in the references up top, Paul Hudson takes you through the process from the beginning. That tutorial is way more complete than these notes. Follow that. As are the WWDC videos. Watch those.
When looking at tutorials what one will see is that everyone makes their model a class when using SwiftData
. And when that class is going to be used with CloudKit
, each parameter has to be optional or have a default value. This helps the code know how to resolve when connections fail.
However that’s kind of annoying if one wants use structs for your data type. When writing a project without SwiftData
I have typically used structs for my models and an Actor
for my DataStore and protocol based PersistenceService
to save the data.
I will have to change how I work to use SwiftData
, but I wanted to see how keeping structs in the mix might still work.
I tried three different approaches.
Approach 1: Class wrapper for the struct
One approach uses the SwiftData
model class as a thin wrapper around a struct. “Business logic” code interacts with the struct, and the UI code selects a record via the class’s code and then pulls out the underlying struct to render, update or access business logic.
Since my interest is working with CloudKit
, this example shows having the struct be an Optional in the class.
import Foundation
import SwiftData
import Foundation
struct Item:Sendable, Codable, Hashable, Equatable {
var textInfo:String
var timestamp:Date
var displayMe:String {
"\(textInfo) at \(timestamp.formatted(date: .long, time: .shortened))"
}
}
extension Item {
init(_ text:String) {
self.init(textInfo: text, timestamp: .now)
}
init() {
self.init(textInfo: "Very important information!!!", timestamp: .now)
}
}
@Model
final class ItemSDMC {
var item:Item?
init(textInfo: String, timestamp: Date) {
self.item = Item(textInfo: textInfo, timestamp: timestamp)
}
}
extension ItemSDMC {
convenience init(_ text:String) {
self.init(textInfo: text, timestamp: .now)
}
convenience init() {
self.init(textInfo: "Very important information!!!", timestamp: .now)
}
}
//
// EditItemView.swift
// DataFling
//
//
import SwiftUI
struct EditItemView: View {
@Bindable var sdModelItem:ItemSDMC
@State var nonOptionalItemHolder:Item
init(_ item: ItemSDMC) {
self.sdModelItem = item
if let wrapped = item.item {
nonOptionalItemHolder = wrapped
} else {
nonOptionalItemHolder = Item(textInfo: "Empty Item", timestamp: Date())
}
}
var body: some View {
var boundItem = Binding(
get: {
_nonOptionalItemHolder.wrappedValue
},
set: {
//Note this should never happen in this view because
//is ItemSDMC somehow returns a nil Item, no edit view will
//be displayed.
if $0.textInfo != "EmptyItem" {
nonOptionalItemHolder = $0
sdModelItem.item = $0
} else {
fatalError("How did an Empty Item get sent to be updated?")
}
}
)
VStack {
if sdModelItem.item != nil {
Text(sdModelItem.item!.displayMe)
Form {
TextField("text", text:boundItem.textInfo)
DatePicker("Date", selection: boundItem.timestamp)
}
} else {
Text("There has been an mistake. This item has no data")
}
}
#if os(iOS)
.navigationTitle("Edit Item")
.navigationBarTitleDisplayMode(.inline)
#endif
}
}
#Preview {
@Previewable var myItem = ItemSDMC("Example Item for Preview")
EditItemView(myItem)
}
Discussions on how to handle bindings:
- https://www.hackingwithswift.com/quick-start/swiftui/how-to-create-custom-bindings
- https://forums.swift.org/t/passing-custom-getter-and-setter-to-property-wrapper-initializer/32000/6
- https://stackoverflow.com/questions/69762157/how-to-use-setter-of-custom-swiftui-binding-after-value-conversion
Approach 2: Keep the struct dumb
This approach differs because both the class and the struct have all the fields, and also because the class would contain the business logic and the struct would be a fairly dumb data structure.
Since my interest is working with CloudKit
, this example shows having each parameter having a default value, the other choice being to use optionals like in the previous approach.
One of the advantages of this approach is that the newish Observation.Observable
protocol has more granularity than what you could do before, i.e. an update to one parameter won’t trigger updates unless that parameter is in fact being observed by something active in the program.
import Foundation
import SwiftData
struct ThingLite:Sendable, Codable, Hashable, Equatable {
let textInfo:String
let timestamp:Date
}
@Model
final class Thing {
//using this default value requires writing some clean up logic looking for empty text info.
var textInfo:String = ""
//using this default value would require writing some data clean up functions looking for out of bound dates.
var timestamp:Date = Date.distantPast
init(textInfo: String, timestamp: Date) {
self.textInfo = textInfo
self.timestamp = timestamp
}
var displayMe:String {
"\(textInfo) at \(timestamp.formatted(date: .long, time: .shortened))"
}
var liteCopy:ThingLite {
ThingLite(textInfo: textInfo, timestamp: timestamp)
}
var liteCopyWithID:(data:ThingLite, id:PersistentIdentifier) {
//(sendableCopy, id) also works
(sendableCopy, persistentModelID)
}
}
//
// EditThingView.swift
// DataFling
//
//
import SwiftUI
struct EditThingView: View {
@Bindable var thing:Thing
var body: some View {
VStack {
Text(thing.displayMe)
Form {
TextField("text", text:$thing.textInfo)
DatePicker("Date", selection: $thing.timestamp)
}
}.task {
await doSomeAsyncThing(thing:thing)
}
#if os(iOS)
.navigationTitle("Edit Item")
.navigationBarTitleDisplayMode(.inline)
#endif
}
func doSomeAsyncThing(thing:Thing) async {
print(thing.liteCopy)
}
}
#Preview {
@Previewable var myThing = Thing(textInfo:"Example for Preview", timestamp: Date())
EditThingView(thing: myThing)
}
Third Approach: Protocol
The third approach is to use a protocol as the main basic for all the business logic. I won’t get into that too much now because its the one I’m going to be trying out for a bit.
It might look a little something like the below, although there is still things to work out about how ID would work.
import Foundation
import SwiftData
protocol Thingable:Identifiable {
var textInfo:String { get }
var timestamp:Date { get }
}
extension Thingable {
var thingDisplay:String {
"\(textInfo) with \(id) at \(timestamp.formatted(date: .long, time: .shortened))"
}
}
extension Thingable where Self:PersistentModel {
var thingDisplayWithID:String {
"\(textInfo) with modelID \(self.persistentModelID.id) in \(String(describing: self.persistentModelID.storeIdentifier)) at \(timestamp.formatted(date: .long, time: .shortened))"
}
}
struct ThingLite:Thingable, Codable, Sendable {
var textInfo: String
var timestamp: Date
var id: Int
}
@Model
final class Thing:Thingable {
//using this default value requires writng some clean up logic looking for empty text info.
var textInfo:String = ""
//using this default value would require writing some data clean up functions looking for out of bound dates.
var timestamp:Date = Date.distantPast
init(textInfo: String, timestamp: Date) {
self.textInfo = textInfo
self.timestamp = timestamp
}
}
extension Thing {
var LiteThing:ThingLite {
ThingLite(textInfo: textInfo, timestamp: timestamp, id: persistentModelID.hashValue)
}
}
If one goes that route, at least as of May 2025, it is ill advised to try to make a generic view that takes in some Thingable
because Thinable
’s that come from an @State
deceleration will want to work with @Binding
as opposed to @Model
objects which, since they conform to Observation.Observable
and are owned by the context, will be passed via @Bindable
. Those play very poorly together since @State provides a binding to the enclosing object and @Bindable only provides bindings to the properties. .
- Post By Me: https://developer.apple.com/forums/thread/782729
- Post By Me: https://developer.apple.com/forums/thread/783142
- https://developer.apple.com/videos/play/wwdc2023/10149/ (Discover Observation in SwiftUI)
- https://www.hackingwithswift.com/quick-start/swiftdata/whats-the-difference-between-bindable-and-binding
Conclusion
Leaning into using SwiftData` requires different methods working with information than previous approaches. I imagine I’ll keep changing my mind as to what patterns I want to try out project by project for some time.