swiftswiftui

Bidirectional state updates in @Observable


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()
}



Solution

  • 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 @States 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 @States 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 }
        }
    }