I have a question about a scenario like this. In my root view (in this case, ContentView), I have a viewModel that performs some action which then opens a sheet. I also have another view with its own viewModel, created to split up a large logic into smaller parts. That second viewModel should also be able to trigger the sheet. What's the best way to handle this using the @Observable macro? So the question in this example is: how do you pass data between models so that they react to each other's changes?
import SwiftUI
@main
struct Example_appApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct Place: Hashable, Identifiable {
var id: String { name }
var name: String
}
// first model
@Observable
class ContentViewModel {
var selectedMapItem: Place?
}
// second model
@Observable
class SecondViewModel {
var selectedMapItem: Place?
init(selectedMapItem: Place? = nil) {
self.selectedMapItem = selectedMapItem
}
}
struct SecondView: View {
var model: SecondViewModel
var body: some View {
Button {
model.selectedMapItem = .init(name: "XXX") // here i want open sheet
} label: {
Text("Second trigger")
}
}
}
struct ContentView: View {
@Bindable var model: ContentViewModel
init() {
self.model = ContentViewModel()
}
var body: some View {
NavigationStack {
Text("First trigger")
.onTapGesture {
model.selectedMapItem = .init(name: "Name") // here i want open sheet too
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(alignment: .bottom) {
SecondView(model: SecondViewModel(selectedMapItem: model.selectedMapItem))
}
.sheet(item: $model.selectedMapItem) { item in
Color.red
}
}
}
}
#Preview {
ContentView()
}
By dividing up the ContentViewModel
this way, you create two sources of truth. But logically you only have one source of truth here.
I would move the stored property selectedMapItem
out of the SecondViewModel
and into a @Binding
in SecondView
.
struct ContentView: View {
@State private var model = ContentViewModel()
var body: some View {
NavigationStack {
Text("First trigger")
.onTapGesture {
model.selectedMapItem = .init(name: "Name")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(alignment: .bottom) {
SecondView(selectedMapItem: $model.selectedMapItem)
}
.sheet(item: $model.selectedMapItem) { item in
Color.red
}
}
}
}
@Observable
class SecondViewModel {
// things exclusive to SecondViewModel goes here...
}
struct SecondView: View {
@Binding var selectedMapItem: Place?
@State private var model = SecondViewModel()
var body: some View {
Button {
selectedMapItem = .init(name: "XXX")
} label: {
Text("Second trigger")
}
}
}
Note that I am directly initialising the @State
s here. If you don't want the initialisers of the @Observable
to be unnecessarily called, you should be initialing them in a .task
or .onAppear
, as the documentation says.
That said, I think you should ditch the whole idea of "one view model per view", or the idea of creating your own view models entirely. The View
struct itself acts like a view model. Declare @State
s in ContentView
, and pass only the states that SecondView
needs as bindings (or otherwise) - that's how you divide a big view up into smaller views.
If all you want is to divide ContentViewModel
up into multiple parts so that it is easier to manage, you can do something like this:
@Observable
class ContentViewModel {
let thingsThatSecondViewNeeds = ThingsThatSecondViewNeeds()
// optional convenient property for accessing thingsThatSecondViewNeeds.selectedMapItem
var selectedMapItem: Place? {
get { thingsThatSecondViewNeeds.selectedMapItem }
set { thingsThatSecondViewNeeds.selectedMapItem = newValue }
}
}
@Observable
class ThingsThatSecondViewNeeds {
var selectedMapItem: Place?
// things exclusive to SecondViewModel goes here...
}
struct ContentView: View {
@State private var model = ContentViewModel()
var body: some View {
NavigationStack {
Text("First trigger")
.onTapGesture {
model.selectedMapItem = .init(name: "Name")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(alignment: .bottom) {
SecondView(model: model.thingsThatSecondViewNeeds)
}
.sheet(item: $model.selectedMapItem) { item in
Color.red
}
}
}
}
struct SecondView: View {
let model: ThingsThatSecondViewNeeds
var body: some View {
Button {
model.selectedMapItem = .init(name: "XXX")
} label: {
Text("Second trigger")
}
}
}
Again, there is only one source of truth - ThingsThatSecondViewNeeds.selectedMapItem
.
Or if you think the source of truth should be in ContentViewModel
, you can do:
@Observable
class ContentViewModel {
let thingsThatSecondViewNeeds = ThingsThatSecondViewNeeds()
var selectedMapItem: Place?
init() {
thingsThatSecondViewNeeds.contentViewModel = self
}
}
@Observable
class ThingsThatSecondViewNeeds {
weak var contentViewModel: ContentViewModel?
var selectedMapItem: Place? {
get { contentViewModel?.selectedMapItem }
set { contentViewModel?.selectedMapItem = newValue }
}
}