DEV Community

Jameson
Jameson

Posted on

Splitting SwiftData and SwiftUI via MVVM

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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))
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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])
                    }
                })
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Above, we've included a simple button to add new Items, and a swipe-to-delete interaction that can delete Items. Here's what the UI ends up looking like:

A simple SwiftUI list showing add and delete actions

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)
                    }
                })
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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])
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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])
    }
}
Enter fullscreen mode Exit fullscreen mode

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 (3)

Collapse
 
christophe_poulin_813ccc6 profile image
Christophe Poulin

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

Collapse
 
mezhnik profile image
mezhnik

So far, so good. Thanks a lot!

Collapse
 
nerdybunz profile image
Adam

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?