Context
I’m building a multi-platform SwiftUI app targeting iOS 26 / macOS 26 (iPhone, iPad, Mac). I’m using MVVM with a Repository layer and the modern @Observable macro throughout. The app has list -> detail navigation and performs one-shot async data fetching.
My goal: enable rich Xcode Previews on all three platforms without spinning up real dependencies (network, database), while keeping the production ViewModel fast (no existential boxing overhead).
The pattern I landed on
1. ViewModel protocol
protocol RecipeListViewModelProtocol: AnyObject, Observable {
var recipes: [Recipe] { get }
var isLoading: Bool { get }
func fetch() async
@discardableResult func delete(id: UUID) async -> Bool
}
2. Production ViewModel
@Observable
final class RecipeListViewModel: RecipeListViewModelProtocol {
private(set) var recipes: [Recipe] = []
private(set) var isLoading = false
private let repository: RecipeRepositoryProtocol
init(repository: RecipeRepositoryProtocol = RecipeRepository()) {
self.repository = repository
}
func fetch() async {
isLoading = true
defer { isLoading = false }
recipes = (try? await repository.fetchAll()) ?? []
}
@discardableResult func delete(id: UUID) async -> Bool {
guard (try? await repository.delete(id: id)) != nil else { return false }
await fetch()
return true
}
}
3. Mock ViewModel (preview injection)
@Observable
final class RecipeListViewModelMock: RecipeListViewModelProtocol {
var recipes: [Recipe]
var isLoading: Bool
init(recipes: [Recipe] = Recipe.samples, isLoading: Bool = false) {
self.recipes = recipes
self.isLoading = isLoading
}
func fetch() async {}
@discardableResult func delete(id: UUID) async -> Bool { true }
}
4. Generic view (static dispatch, no any)
struct RecipeList: View {
@State var vm: VM
var body: some View {
List(vm.recipes) { recipe in
Text(recipe.title)
}
.overlay { if vm.isLoading { ProgressView() } }
.task { await vm.fetch() }
}
}
5. Multi-platform PreviewProvider
struct RecipeList_Previews: PreviewProvider {
static var vm = RecipeListViewModelMock()
static var previews: some View {
Group {
RecipeList(vm: vm)
.previewDevice(PreviewDevice(rawValue: "iPhone 17 Pro"))
.previewDisplayName("iPhone")
RecipeList(vm: vm)
.previewDevice(PreviewDevice(rawValue: "iPad Pro 11-inch (M5)"))
.previewDisplayName("iPad")
RecipeList(vm: vm)
.previewDevice(PreviewDevice(rawValue: "My Mac"))
.previewDisplayName("Mac")
}
}
}
Questions
-
Is
protocol: AnyObject, Observable+ generic view (struct RecipeList) the recommended approach in SwiftUI 6 / iOS 26? -
Should the mock live at the ViewModel layer or the Repository layer for Xcode Previews? The alternative to the mock VM above would be injecting a mock repository into the real VM:
@Observable
final class RecipeRepositoryMock: RecipeRepositoryProtocol {
func fetchAll() async throws -> [Recipe] { Recipe.samples }
func delete(id: UUID) async throws {}
}
// in the preview:
static var previews: some View {
RecipeList(vm: RecipeListViewModel(repository: RecipeRepositoryMock()))
...
}
This exercises the real VM’s fetch/delete logic but requires the VM to be constructible in the preview. Is this the preferred approach?
- How do generics propagate to child views?
RecipeListis generic overVM, but child views likeRecipeRowonly need aRecipevalue and have no dependency on the VM. Is passing the model directly sufficient, or does the generic constraint need to flow down?
struct RecipeRow: View {
let recipe: Recipe
var body: some View { Text(recipe.title) }
}
// used inside RecipeList:
List(vm.recipes) { recipe in
RecipeRow(recipe: recipe) // no VM generic needed here?
}
-
#Previewdoesn’t seem to support rendering multiple devices simultaneously. IsPreviewProvider+.previewDevice()still the correct approach for side-by-side iPhone / iPad / Mac previews in iOS 26? -
Any architectural red flags in the pattern above?