SwiftUI Architecture: Shared vs. Separate ViewModels for Your App
Introduction
When developing iOS apps with SwiftUI, one common challenge is managing data across multiple views. This is particularly true in scenarios where we have a list of items and a detail view for each item — a common pattern in many apps. A crucial decision in such scenarios is whether to use a shared ViewModel for both views or to have separate ViewModels.
Let’s dive into these two architectural approaches, using the example of a product listing and editing application.
Approach 1: Separate ViewModels for Each View
ProductsList ViewModel
class ProductListViewModel: ObservableObject {
@Published var products: [Product] = []
func fetchProducts() {
// Fetch product list logic
}
}
struct Product: Identifiable {
let id: Int
let name: String
// Add other product properties
}
ProductDetail ViewModel
class ProductDetailViewModel: ObservableObject {
@Published var productDetail: ProductDetail?
func fetchProductDetail(id: Int) {
// Fetch product detail logic
}
}struct ProductDetail {
// Product detail properties
}
Views
struct ProductListView: View {
@StateObject var viewModel = ProductListViewModel()
var body: some View {
List(viewModel.products) { product in
NavigationLink(destination: ProductDetailView(productId: product.id)) {
Text(product.name)
}
}
.onAppear {
viewModel.fetchProducts()
}
}
}struct ProductDetailView: View {
@StateObject var viewModel = ProductDetailViewModel()
let productId: Int var body: some View {
// Display product details
Text(viewModel.productDetail?.name ?? "")
.onAppear {
viewModel.fetchProductDetail(id: productId)
}
}
}
Approach 2: Single ViewModel for Multiple Views
Shared ViewModel for Both Screens
class SharedProductsViewModel: ObservableObject {
@Published var products: [Product] = []
@Published var selectedProductDetail: ProductDetail?
func fetchProducts() {
// Fetch product list logic
} func fetchProductDetail(id: Int) {
// Fetch product detail logic
}
}
Views
struct ProductListView: View {
@StateObject var viewModel = SharedProductsViewModel()
var body: some View {
List(viewModel.products) { product in
NavigationLink(destination: ProductDetailView(productId: product.id, viewModel: viewModel)) {
Text(product.name)
}
}
.onAppear {
viewModel.fetchProducts()
}
}
}struct ProductDetailView: View {
let productId: Int
@ObservedObject var viewModel: SharedProductsViewModel var body: some View {
// Display product details
Text(viewModel.selectedProductDetail?.name ?? "")
.onAppear {
viewModel.fetchProductDetail(id: productId)
}
}
}
Comparison and Analysis:
- Approach 1 has distinct ViewModels for the product list and product details. This provides separation of concerns but can lead to duplicated code for shared functionality and challenges in maintaining state consistency between the views.
- Approach 2 uses a single shared ViewModel. It provides a unified source of truth for both views, ensuring consistency and reducing redundancy. However, the ViewModel can become complex if it has to handle many different concerns.
Both approaches are valid, and the choice between them depends on the complexity of your app and the degree of interaction between the views. For simpler apps or when views have distinct concerns, separate ViewModels may be preferable. For more complex apps with closely related views, a shared ViewModel might be more efficient.
How about @EnvironmentObject?
Using @EnvironmentObject
is another viable approach in scenarios where you need to share data across multiple views, and it can be particularly useful in the context of the MVVM design pattern in SwiftUI.
How @EnvironmentObject Works:
@EnvironmentObject
is a property wrapper in SwiftUI that allows you to share an object (like a ViewModel) across multiple views without having to pass it explicitly through each view's initializer. An environment object is typically injected into the environment of a view hierarchy and can then be accessed by any view within that hierarchy.
Example Using @EnvironmentObject:
First, you would make your ViewModel conform to ObservableObject
and instantiate it in the parent view or at the app level. Then, inject it into the environment:
@main
struct MyApp: App {
var viewModel = ProductsViewModel()
var body: some Scene {
WindowGroup {
ProductListView()
.environmentObject(viewModel)
}
}
}
Then in your views, you can access the ViewModel using @EnvironmentObject
:
struct ProductListView: View {
@EnvironmentObject var viewModel: ProductsViewModel
// ...
}
struct ProductDetailView: View {
@EnvironmentObject var viewModel: ProductsViewModel
// ...
}
Considerations:
While @EnvironmentObject
is powerful, it's important to use it judiciously:
- Implicit Dependency: Since
@EnvironmentObject
does not make the dependency explicit in the view's initializer, it can sometimes be less clear where the data is coming from, which can be a drawback in terms of readability and maintainability. - Propagation: The object must be injected into the environment before the views are loaded, otherwise the app will crash at runtime when trying to access the environment object.