Architecture
Quick Intro to Model-View-ViewModel (MVVM) in SwiftUI
For this post I assume you are familiar with Swift, iOS app development using SwiftUI and testing Swift code in iOS app projects.
Do you find yourself breaking your SwiftUI iOS app functionality when adding new features? Or maybe even just enhancing current ones? Seems like you could benefit in writing more tests for your SwiftUI app.
However SwiftUI views aren’t so easy to test. Thus any logic within those views will also be hard to test. Let’s take a look at an example.
MyBooks App
Let’s say we have an app that displays a list to track our books.
struct BookListView: View {
@State private var books: [Book] = []
private let booksRepository: BooksRepository = DefaultBooksRepository()
var body: some View {
List(books) { book in
BookCell(book: book)
.listRowSeparator(.hidden)
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.padding()
.onAppear(perform: {
self.books = self.booksRepository.get()
})
}
}
struct BookCell: View {
let book: Book
var body: some View {
VStack {
HStack {
Text(book.title)
.font(.title2)
Spacer()
}
HStack {
Text(book.authors.joined(separator: ", "))
.font(.caption)
Spacer()
}
}
.padding()
.background(RoundedRectangle(cornerRadius: 12).fill(Color(uiColor: UIColor.lightGray)))
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0))
}
}
How can we make sure the subtitle in the book cell is displaying the correct string?
Notice the books repository or the model here is accessed straight by the BookListView
. The conversion from the Book
representation to the view data is done within the BookCell
itself.
So how do we test that the view reflects the correct data? One solution is to extract the logic that converts the book representation into view data. In essence a layer that converts the app state–or model–into view data.
Introducing ViewModel
Now what can bridge logic between your View and your Model? 🤔 ViewModel!
Let’s fix our previous example extracting logic from the View
into the ViewModel
.
struct BookCellViewData: Identifiable, Equatable {
let id: String
let title: String
let substitle: String
}
extension BookListView {
class ViewModel: ObservableObject {
@Published var books: [BookCellViewData] = []
private let booksRepository: BooksRepository
init(booksRepository: BooksRepository) {
self.booksRepository = booksRepository
}
func fetchBooks() {
let books = self.booksRepository.get().map { book in
let substitle = "\(book.authors.joined(separator: ", ")) ⚬ \(book.publicationDate.getYear())"
return BookCellViewData(id: book.id,
title: book.title,
substitle: substitle)
}
self.books = books
}
}
}
In simple we have extracted the logic of fetching the books and most notably we have extracted the logic that converts the book cell subtitle. Notice also that instead of returning Book
s we are now exposing BookCellViewData
which only contains enough information for the view display purposes.
Notice also that we must make our view model an @ObservableObject
so our views can update when the app state changes.
Let’s make the changes to BookListView
to use our new ViewModel
:
struct BookListView: View {
@ObservedObject private var viewModel = ViewModel(booksRepository: DefaultBooksRepository())
var body: some View {
List(viewModel.books) { book in
BookCell(book: book)
.listRowSeparator(.hidden)
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.padding()
.onAppear(perform: {
self.viewModel.fetchBooks()
})
}
}
In order for our view to redraw when the state changes we must mark our viewModel
as @ObservedObject
.
In the example above the ViewModel
is created by the View itself. If the View redraws then a new instance of the ViewModel
will be created. Note that any intermediary state held by the ViewModel
will be lost.
Testing ViewModels
Now that we have extracted logic from our view and created a layer that bridges between the model and the view we are ready to test our newly created layer–the ViewModel.
class StubBooksRepository: BooksRepository {
var getCount = 0
func get() -> [BooksMVVMSwiftUI.Book] {
self.getCount += 1
return [
Book(isbn: "0804139296",
title: "Zero to One",
authors: ["Peter Thiel", "Blake Masters"],
pages: 224,
publicationDate: Date.getDateFromString("04/06/2015"))
]
}
}
class BookListViewModel: XCTestCase {
private var sut: BookListView.ViewModel!
private var booksRepository: StubBooksRepository!
private var cancellables: Set<AnyCancellable> = []
override func setUp() {
self.booksRepository = StubBooksRepository()
self.sut = BookListView.ViewModel(booksRepository: self.booksRepository)
}
func testFetchBooksReturnsCorrectViewData() {
let expectation = self.expectation(description: "books_view_data")
let expectedBooksViewData = [
BookCellViewData(
id: "0804139296",
title: "Zero to One",
substitle: "Peter Thiel, Blake Masters ⚬ 2015")
]
self.sut.fetchBooks()
self.sut.$books.sink { booksViewData in
XCTAssertEqual(booksViewData, expectedBooksViewData)
expectation.fulfill()
}
.store(in: &cancellables)
wait(for: [expectation], timeout: 5)
}
}
Now we have more confidence in the correctness of our code in displaying the correct subtitle within the BookCell
.
Conclusion
The ViewModel is an easy way from extracting logic from our views and ability to our tests. It’s usually the go to design pattern for getting up and running with new and small projects. However every software architecture pattern has its benefits and drawbacks. Make sure you check requirements before selecting the architecture for your app.
You can find the source code to this post in my Github:
Want to Connect?
For more on iOS development follow me on Twitter!