SwiftData was announced at WWDC in June 2023. Its primary goal is to enable data persistence in a SwiftUI app with as little fuss as possible.
Most of the tutorials and snippets we see on the web show it being used directly in a SwiftUI View. For example, let's consider the minimal code to query, insert, and delete items from a list.
Suppose we have a model of an item that we'll want to display in a list. For example, here's the @Model
that XCode generates if we select "use SwiftData" in the project template:
@Model
final class Item {
var timestamp: Date
init(timestamp: Date) {
self.timestamp = timestamp
}
}
With very little code, we can use the @Query
property wrapper to read some existing items and show them in a list:
struct ContentView: View {
@Query private var items: [Item]
var body: some View {
List {
ForEach(items) { item in
Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
}
}
}
}
Of course, we'll also need some way to add and remove those items. For whatever reason, those actions happen through a separate ModelContext
object:
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query private var items: [Item]
var body: some View {
VStack {
Button(action: {
modelContext.insert(Item(timestamp: Date()))
}) {
Label("Add Item", systemImage: "plus")
}
List {
ForEach(items) { item in
Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
}
.onDelete(perform: { offsets in
for index in offsets {
modelContext.delete(items[index])
}
})
}
}
}
}
Above, we've included a simple button to add new Item
s, and a swipe-to-delete interaction that can delete Item
s. Here's what the UI ends up looking like:
The magic here, of course, is that the items will persist even after the app is killed: that's the point of using SwiftData.
First Attempt at MVVM & SwiftData
Let's now suppose that we want to split apart our code and introduce an MVVM pattern. Doing so will help us to create testable, reusable layers in our app.
We have a few new challenges here, using SwiftData. Firstly, we'll need a way to get access to the ModelContext
outside of the SwiftUI View. Let's try a naive approach of using the same @Environment
property binding to access ModelContext
from our new ViewModel, instead of the ContentView
. We might end up with something like below. Note that all data-management actions have been moved out of the ContentView
and now live in a new class ViewModel
.
struct ContentView: View {
@State private var viewModel = ViewModel()
var body: some View {
VStack {
Button(action: {
viewModel.appendItem()
}) {
Label("Add Item", systemImage: "plus")
}
List {
ForEach(viewModel.items) { item in
Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
}
.onDelete(perform: { offsets in
for index in offsets {
viewModel.removeItem(index)
}
})
}
}
}
}
And a really simple-minded ViewModel to go with it:
@Observable
class ViewModel {
@ObservationIgnored
@Environment(\.modelContext) private var modelContext
var items: [Item] = []
func appendItem() {
modelContext.insert(Item(timestamp: Date()))
}
func fetchItems() {
do {
items = try modelContext.fetch(FetchDescriptor<Item>())
} catch {
fatalError(error.localizedDescription)
}
}
func removeItem(_ index: Int) {
modelContext.delete(items[index])
}
}
At this point, we try to run our simple app and we get this error:
Accessing Environment<ModelContext>'s value outside of being installed on a View. This will always read the default value and will not update.
Darn.
Updating App integration
Let's go back and look at our App
class for a moment. The simplest integration for SwiftData is to inject the ModelContext
into the WindowGroup
, like this, which makes it available to SwiftUI:
@main
struct DataPlaygroundApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: Item.self)
}
}
It's worth noting that mainContext
is a property inside of a ModelContainer
. So, instead of accessing the ModelContext
via @Environment
, maybe our ViewModel could access it through the ModelContainer
.
Let's remove that modelContainer(...)
line in the App
altogether and try a different approach. Instead of accessing it from the View layer at all, let's create a data source class.
Our App now looks like this:
@main
struct DataPlaygroundApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Defining a Data Source
Now, let's define an ItemDataSource
that will own the ModelContainer
:
final class ItemDataSource {
private let modelContainer: ModelContainer
private let modelContext: ModelContext
@MainActor
static let shared = ItemDataSource()
@MainActor
private init() {
self.modelContainer = try! ModelContainer(for: Item.self)
self.modelContext = modelContainer.mainContext
}
func appendItem(item: Item) {
modelContext.insert(item)
do {
try modelContext.save()
} catch {
fatalError(error.localizedDescription)
}
}
func fetchItems() -> [Item] {
do {
return try modelContext.fetch(FetchDescriptor<Item>())
} catch {
fatalError(error.localizedDescription)
}
}
func removeItem(_ item: Item) {
modelContext.delete(item)
}
}
A few things to notice here. The class uses @MainActor
, since we must access mainContext
from the main actor. If you don't include @MainActor
, you'll get a compile error like this:
Main actor-isolated property 'mainContext' can not be referenced from a non-isolated context
Also, I've made the component a singleton so we only have one instance of the ModelContainer
. (There are many other ways to achieve this, this was easy for demonstration purposes.)
Now, our simple ViewModel
just calls out to the data source. When items
changes, since the ViewModel
is @Observable
, the ContentView
will automatically respond to it and render the new items.
@Observable
class ViewModel {
@ObservationIgnored
private let dataSource: ItemDataSource
var items: [Item] = []
init(dataSource: ItemDataSource = ItemDataSource.shared) {
self.dataSource = dataSource
items = dataSource.fetchItems()
}
func appendItem() {
dataSource.appendItem(item: Item(timestamp: Date()))
}
func removeItem(_ index: Int) {
dataSource.removeItem(items[index])
}
}
And there you have it! You can re-use the ItemDataSource
in any number of your SwiftUI View/ViewModels, and you can start building up tests around each component in isolation.
Let me know in the comments what you think of this approach.
Top comments (4)
Great article @jameson and this is very useful in a professional environment where you need to be able to unit test your code and use mock data. It works great locally.
However I am having an issue when I want to sync the data on multiple devices using CloudKit. As soon as I switch away from using a more direct SwiftUI approach (@Query), I lose the live data update between devices when the same app runs on these two devices at the same time.
I currently have to manually call the modelContext.fetch method to show the latest data on both devices.
What could be missing? Thanks
So far, so good. Thanks a lot!
Xcode 15.3 returns the warning:
Main actor-isolated static property 'shared' can not be referenced from a non-isolated context; this is an error in Swift 6
when initialising the viewModel like so:
This (stackoverflow.com/questions/778498...) is another possible approach. I would be very interested in your take on it. Also a "vote to re-open" the question would be appreciated by anyone. I suspect the people that voted to close it did not understand the subject. Is it true that in both our approaches, we are forcing the full array of objects to be memory resident at all times?