How to use MVVM to separate SwiftData from your views
How to use MVVM to separate SwiftData from your views 관련
Updated for Xcode 15
SwiftData and SwiftUI perform best when tightly integrated, and when you separate them – when you want to introduce view models into your code you lose a fair amount of their power. However, with some extra work MVVM can be used just fine with SwiftUI and SwiftData, as long as you're careful to keep your data synchronized.
Tips
If you used Core Data previously you'll know that NSFetchedResultsController
allowed us to execute a query and monitor it for changes. We don't have an equivalent in SwiftData, so you need to keep your data model updated by hand.
Regardless of whether you use MVVM or not, your underlying SwiftData models are the same. For this example, we'll use this simple Movie
model:
@Model
class Movie {
var title: String
var cast: [String]
init(title: String, cast: [String]) {
self.title = title
self.cast = cast
}
}
To demonstrate MVVM, let's first look at the non-MVVM approach where SwiftUI and SwiftData are tied closely using @Query
and the environment. This means our layout and data are both handled directly in the SwiftUI view, like this:
struct ContentView: View {
@Query(sort: \Movie.title) var movies: [Movie]
@Environment(\.modelContext) var modelContext
var body: some View {
NavigationStack {
List(movies) { movie in
VStack(alignment: .leading) {
Text(movie.title)
.font(.headline)
Text(movie.cast.formatted(.list(type: .and)))
}
}
.navigationTitle("MovieDB")
.toolbar {
Button("Add Sample", action: addSample)
}
}
}
func addSample() {
let movie = Movie(title: "Avatar", cast: ["Sam Worthington", "Zoe Saldaña", "Stephen Lang", "Michelle Rodriguez"])
modelContext.insert(movie)
}
}
This suffers from the same problem you'll have seen outside of SwiftData – it's hard to write unit tests for this, because the query requires SwiftUI to be actively involved.
To move this over to MVVM we need to create a new view model class using the @Observable
macro. This needs to be able to perform a SwiftData fetch when it loads, but also to repeat that fetch in the future when the data changes.
Important
The @Query
macro works only when it can access the SwiftUI environment, which makes it incompatible with MVVM.
So, we might make something like this:
extension ContentView {
@Observable
class ViewModel {
var modelContext: ModelContext
var movies = [Movie]()
init(modelContext: ModelContext) {
self.modelContext = modelContext
fetchData()
}
func addSample() {
let movie = Movie(title: "Avatar", cast: ["Sam Worthington", "Zoe Saldaña", "Stephen Lang", "Michelle Rodriguez"])
modelContext.insert(movie)
fetchData()
}
func fetchData() {
do {
let descriptor = FetchDescriptor<Movie>(sortBy: [SortDescriptor(\.title)])
movies = try modelContext.fetch(descriptor)
} catch {
print("Fetch failed")
}
}
}
}
Tips
I place view models into an extension on their view, to help keep names simple – I'd much rather refer to ViewModel
than something like ContentViewViewModel
.
Your view model doesn't need to perform a complete fetch for every change you make, but you do need some way to make sure its data stays up to date over time.
When it comes to creating the view model, you need to be able to pass in the active model context directly rather than trying to read it from the environment – your view model can't access the environment at all, and it won't be available during your SwiftUI view's initializer.
So, you'd need something like this:
struct ContentView: View {
@State private var viewModel: ViewModel
var body: some View {
NavigationStack {
List(viewModel.movies) { movie in
VStack(alignment: .leading) {
Text(movie.title)
.font(.headline)
Text(movie.cast.formatted(.list(type: .and)))
}
}
.navigationTitle("MovieDB")
.toolbar {
Button("Add Sample", action: viewModel.addSample)
}
}
}
init(modelContext: ModelContext) {
let viewModel = ViewModel(modelContext: modelContext)
_viewModel = State(initialValue: viewModel)
}
}
Finally, adjust your App
struct so that you create your model container by hand, then inject its mainContext
into your SwiftUI view, like this:
@main
struct MovieDBApp: App {
let container: ModelContainer
var body: some Scene {
WindowGroup {
ContentView(modelContext: container.mainContext)
}
.modelContainer(container)
}
init() {
do {
container = try ModelContainer(for: Movie.self)
} catch {
fatalError("Failed to create ModelContainer for Movie.")
}
}
}
It takes only a little more work, but the result is definitely more friendly for unit testing you can create a standalone instance of the view model rather than running it through UI tests.
However, this is only the first step to getting MVVM working well, because you'd also need to track updates for individual objects if a Movie
has its cast list adjusted, for example, that needs to be reflected in your data.
This is obviously quite a lot of work right now, which is why a number of people have said outright that they think MVVM is dead with SwiftData. I wouldn't go quite that far, but I am certainly counting down the days until we get an NSFetchedResultsController
equivalent for SwiftData…