In the previous episode, I highlighted a few problems a typical SwiftUI application can suffer from. These problems can be resolved in several ways. In this series, we explore how the Model-View-ViewModel pattern solves these problems. The goal is to put the views of the application on a diet and decouple view logic from business logic.
I won't cover the details of the Model-View-ViewModel pattern in this episode. In this episode, we explore how MVVM solves the problems we exposed in the previous episode. Later in this series, you learn more about the finer details. Don't worry about those details for now.
Refactoring the Note View
We start with the NoteView struct. The note view displays the note's title and contents. To displays these strings, the note view doesn't need to know about the note. Remember that a view should be dumb. It doesn't need to know what it displays.
Let's replace the note property with a title property and a contents property. Both properties are of type String. We don't need to overcomplicate the implementation of the NoteView struct. We could use a protocol to hide the note behind, but let's keep it as simple as possible.
struct NoteView: View {
// MARK: - Properties
let title: String
let contents: String
...
}
By removing the note property, we introduced a breaking change. We need to update the computed body property and the preview of the NoteView struct. Both changes are straightforward.
var body: some View {
VStack(alignment: .leading) {
Text(title)
.font(.title)
Text(contents)
.font(.body)
}
}
struct NoteView_Previews: PreviewProvider {
static var previews: some View {
NoteView(
title: "My Note",
contents: "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
)
}
}
Refactoring the Notes View
Refactoring the NotesView struct is a bit more involved. The NotesView struct shouldn't have direct access to the array of Note objects and it shouldn't fetch notes from a remote API. The plan is to use a view model to hide these details from the notes view.
Add a Swift file to the Views group and name it NotesViewModel.swift. Declare a final class with name NotesViewModel. The NotesViewModel class conforms to the ObservableObject protocol. That allows the view to observe the view model.
import Foundation
final class NotesViewModel: ObservableObject {
}
We can use the compiler to help us with the refactoring. Let me show you how that works. Open NotesView.swift and remove the notes property. We declare a variable property with name viewModel and annotate it with the ObservedObject attribute to render it observable. The notes view creates and assigns a NotesViewModel instance to its viewModel property.
import SwiftUI
struct NotesView: View {
// MARK: - Properties
@ObservedObject var viewModel = NotesViewModel()
...
}
The compiler throws a few errors. Those errors are the breadcrumbs we need to follow. The notes view no longer has access to the array of notes it should display. The view model should manage the array of notes instead. Open NotesViewModel.swift and declare a Published property with name notes of type [Note]. Note that we declare the setter of the property privately and set the initial value to an empty array.
import Foundation
final class NotesViewModel: ObservableObject {
// MARK: - Properties
@Published private(set) var notes: [Note] = []
}
Revisit the NotesView struct. The view can access the array of notes through its view model.
var body: some View {
NavigationView {
List(viewModel.notes) { note in
NoteView(note: note)
}
.navigationTitle("Notes")
}
.task {
...
}
}
Remember that the initializer of the NoteView struct no longer accepts a Note object. It accepts the note's title and contents instead.
var body: some View {
NavigationView {
List(viewModel.notes) { note in
NoteView(
title: note.title,
contents: note.contents
)
}
.navigationTitle("Notes")
}
.task {
...
}
}
The next step is removing the reference to the notes property in the task modifier. The view shouldn't fetch the array of notes from the remote API. Let's move that responsibility to the view model. Open NotesViewModel.swift and define an asynchronous, throwing method with name fetchNotes().
func fetchNotes() async throws {
}
Open NotesView.swift in the assistant editor on the right. Move the contents of the do clause in the task modifier to the fetchNotes() method.
func fetchNotes() async throws {
let url = URL(string: "https://cdn.cocoacasts.com/2354d51028d53fcc00ceb0c66f25475d5c79bff0/notes.json")!
let (data, _) = try await URLSession.shared.data(from: url)
notes = try JSONDecoder().decode([Note].self, from: data)
}
The notes view invokes the view model's fetchNotes() method in the closure of the task modifier.
var body: some View {
NavigationView {
List(viewModel.notes) { note in
NoteView(
title: note.title,
contents: note.contents
)
}
.navigationTitle("Notes")
}
.task {
do {
try await viewModel.fetchNotes()
} catch {
print("Unable to Fetch Notes \(error)")
}
}
}
Runtime Issues
Let's see if the changes we made broke anything. Build and run the application in the simulator. The application displays the list of notes so that is a good sign. There is a subtle issue we need to look into, though. Xcode reports a runtime issue in the NotesViewModel class.
In the fetchNotes() method, the shared URL session fetches the notes on a background thread and returns the results on that same thread. This means that the notes property of the notes view model is updated on a background thread. This isn't an issue per se. The issue is that notes is a published property that is being observed by the notes view. The result, and problem, is that the notes view is also updated on a background thread. That is a red flag since the user interface should always be updated on the main thread.
The solution is surprisingly simple thanks to Swift Concurrency. We simply annotate the NotesViewModel class with the MainActor attribute. Take a look at Mastering Swift Concurrency if you want to learn more about actors and the main actor. The gist is that we ensure the notes property is always updated on the main thread.
import Foundation
@MainActor final class NotesViewModel: ObservableObject {
...
}
That is the only change we need to make. Build and run the application one more time. Xcode no longer reports the runtime error.
Room for Improvement
We successfully applied the Model-View-ViewModel pattern. The notes view no longer stores an array of Note objects and the note view simply displays the strings we pass to the initializer. That's a good start, but we can do better.
There are number of code smells and red flags we need to address. It is true that the notes view no longer stores an array of Note objects, but it still has access to them through its view model. The view model performs the network request to the remote API, but it is the notes view that invokes the fetchNotes() method. The preview still performs a network request every time it is refreshed. Remember that we need to be in control of the environment the preview runs in. The network shouldn't be a dependency.
What's Next?
Before we build the sample application of this series, we need to address these issues. We do that in the next episode.