Model-View-ViewModel with SwiftUI
Model-View-ViewModel (MVVM) is a software architecture pattern
that separates the user interface from the business logic. It takes a reactive approach to data binding and allows for a clean separation of concerns. It's a popular choice for building iOS apps, but now that SwiftUI is a thing, it becomes even more relevant, given the declarative nature of SwiftUI.
How does MVVM work?
With this pattern, the UI has a direct reference to the view model, which is responsible for exposing the data to the view.
struct ProductsListView: View {
@ObservedObject var viewModel: ProductsListViewModel
var body: some View {
VStack {
Text(viewModel.title)
List(viewModel.products) { product in
Text(product.name)
}
}
}
}
class ProductsListViewModel: ObservableObject {
@Published var products: [ProductItemViewModel] = []
@Published var title: String = "Loading"
func onProductsFetched(_ products: [Product]) {
title = "\(products.count) Products"
self.products = products.map {
ProductItemViewModel(product: $0)
}
}
}
A quick Model-View-Presenter comparison
MVVM is similar to Model-View-Presenter (MVP) in that it separates the user interface from the business logic. However, in MVP, the UI is updated manually by the presenter, by calling methods to be implemented by the view.
What are ViewModels responsible for?
In a clean and modular architecture, the view model is responsible for exposing the data to the view, in a format that enables the view to display as is.
It should not contain any business logic. It should not know where the data comes form. It should not be aware of the view.
It is also important to mention that the View should not call methods on the ViewModel, only bind to it's exposed data properties.
Where is the business logic?
The business logic is usually implemented in a separate layer, by different Use Cases. This layer also defines what data will be needed, but not including the implementation details of how to fetch it.
struct Product {
let id: String
let name: String
}
protocol ProductsRepository {
func getAll() async -> [Product]
}
struct GetAllProductsUseCase {
let productsRepository: ProductsRepository
let onProductsFetched: ([Product]) -> Void
func execute() async {
let products = await productsRepository.getAll()
onProductsFetched(products)
}
}
Implementations of the ProductsRepository
protocol can be injected into the GetAllProductsUseCase
to fetch the data from different sources, such as a local database, a remote API, or a mock.
How do we connect the dots?
Introducing the Composition Root Layer
. This layer is responsible for creating the dependencies and connecting them together.
This layer lives close to the app's entry point, such as the AppDelegate
or the SceneDelegate
, and it can take different forms, depending on the app's architecture, such as Factories, Builders, or DI Containers.
class ProductsListViewFactory {
func make() -> ProductsListView {
let viewModel = ProductsListViewModel()
let useCase = GetAllProductsUseCase(
productsRepository: ApiProductsRepository(),
onProductsFetched: viewModel.onProductsFetched
)
return ProductsListView(viewModel: viewModel)
.onAppear {
Task { await useCase.execute() }
}
}
}
Here we can see the wiring of the dependencies. The onAppear
modifier is used to trigger the execution of the use case. The ViewModel's onProductsFetched
method is passed as a callback to the use case, so that it can be called when the data is fetched.
A bit more
As we start composing different events with different functionalities, we can start to see the benefits of this pattern, but we'll start to see the need to extract logic awaiy from the factory, as it should only be responsible for creating the dependencies and connecting them together.
class ProductsListViewFactory {
func make() -> ProductsListView {
let viewModel = ProductsListViewModel()
let logger = ProductsLogger()
let useCase = GetAllProductsUseCase(
productsRepository: ApiProductsRepository(),
onProductsFetched: { products in
viewModel.onProductsFetched(products)
logger.log(products)
}
)
return ProductsListView(viewModel: viewModel)
.onAppear {
logger.log("showing Products screen")
Task { await useCase.execute() }
}
}
}
We can refactor it by adding adapters or decorators class, leavign the factory doing what's good at.
struct ProductsListAdapter {
let viewModel: ProductsListViewModel
let logger: ProductsLogger
let useCase: GetAllProductsUseCase
func onProductsFetched(_ products: [Product]) {
viewModel.onProductsFetched(products)
logger.log(products)
}
func onScreenLoaded() {
logger.log("showing Products screen")
Task { await useCase.execute() }
}
}
class ProductsListViewFactory {
func make() -> ProductsListView {
let viewModel = ProductsListViewModel()
let adapter = ProductsListAdapter(
viewModel: viewModel,
logger: ProductsLogger(),
useCase: GetAllProductsUseCase(
productsRepository: ApiProductsRepository(),
onProductsFetched: adapter.onProductsFetched
)
)
return ProductsListView(viewModel: viewModel)
.onAppear(perform: adapter.onScreenLoaded)
}
}
Using this pattern, all the different components in turn respect the Single Responsibility Principle, and the code is easier to maintain and test.
Leveraging Polymorphism with protocols
The GetAllProductsUseCase can depend on an Output
protocol, instead of a closure, so that it we can compose it a bit more easily.
protocol GetAllProductsUseCaseOutput {
func onProductsFetched(_ products: [Product])
func onErrorFetchingProducts(_ error: Error)
}
class ProductsListViewModel: ObservableObject, GetAllProductsUseCaseOutput {
// ...
func onProductsFetched(_ products: [Product]) {
// show products in the view
}
}
class ProductsLoggerDecorator: GetAllProductsUseCaseOutput {
let decoratee: GetAllProductsUseCaseOutput
func onProductsFetched(_ products: [Product]) {
Logger.log("Products fetched: \(products)")
decoratee.onProductsFetched(products)
}
}
class ProductsListViewFactory {
func make() -> ProductsListView {
let viewModel = ProductsListViewModel()
let adapter = ProductsListAdapter(
output: ProductsLoggerDecorator(decoratee: viewModel),
useCase: GetAllProductsUseCase(
productsRepository: ApiProductsRepository(),
output: ProductsLoggerDecorator(decoratee: viewModel)
)
)
return ProductsListView(viewModel: viewModel)
.onAppear { Task { await useCase.execute() } }
}
}
Closing thoughts
This is a very simplified example, but it shows how we can leverage the power of composition to create a modular architecture that is easy to maintain and test.
Thanks for reading!