mergesort / Boutique

✨ A magical persistence library (and so much more) for state-driven iOS and Mac apps ✨
https://build.ms/boutique/docs
MIT License
920 stars 45 forks source link

StoredValue Publisher Between Models #68

Closed dentvii closed 4 months ago

dentvii commented 4 months ago

Changes to a property stored in a view model do not synchronize with another view model having the same property. The problem and expected behavior are outlined below.

How to Reproduce the Problem

  1. Create Two Distinct ViewModels:

    • ViewModel A: Contains a stored property.
    • ViewModel B: Contains the same stored property.
  2. Query Both ViewModels:

    • Both will show the same property value initially.
  3. Attempt to Subscribe to Property Changes:

    • Changes in ViewModel A are not reflected in ViewModel B, and vice versa.
import SwiftUI
import Combine
import Boutique

// ModelA: A class with a published variable
final actor ModelA: ObservableObject {
    @MainActor @StoredValue(key:"subscriptionScreen") var variable: String? = nil
    private var cancellables = Set<AnyCancellable>()

    init() {
        Task {
        $variable.publisher
            .receive(on: RunLoop.main)
            .sink { newValue in
                print("ModelA - New value has changed: \(newValue)")
            }
            .store(in: &cancellables)
        }
    }
}

// ModelB: A final actor class with a published variable
final actor ModelB: ObservableObject {
    @MainActor @StoredValue(key:"subscriptionScreen") var variable: String? = nil
    private var cancellables = Set<AnyCancellable>()

    init(modelA: ModelA) {
        Task {
            $variable.publisher
            .receive(on: RunLoop.main)
            .sink { newValue in
                print("ModelB - New value has changed: \(newValue)")
            }
            .store(in: &cancellables)
        }
    }
}

// ContentView: A SwiftUI view that displays the variables from both models
struct ContentView: View {
    @StateObject private var modelA = ModelA()
    @StateObject private var modelB: ModelB()

    init() {
    }

    var body: some View {
        VStack {
            Text("ModelA's A: \(modelA.A)")
            Text("ModelB's A: \(modelB.A)")

            Button("Change ModelA's A") {
                modelA.A = "changed by ModelA"
            }

            Button("Change ModelB's A") {
                modelB.A = "changed by ModelB"
            }
        }
        .padding()
    }
}

The Expected Behavior

By following these steps, you can reproduce and understand the synchronization issue between view models in an iOS app. Again, not sure if expected, just documenting as it might be a use of case of some developers.

mergesort commented 4 months ago

Hey @dentvii, thanks for the detailed report. The problem you're running into is that @StoredValue isn't meant to be used this way, you should maintain one reference to a @StoredValue somewhere both ViewModels can access it.

Since StoredValue is a struct which that means that each of your ViewModels has created a StoredValue with it's own separate publisher. You ostensibly have two different properties updating the same underlying value, but when you try to update variable through ViewModel A only the observers of View Model A's version of A.variable will be notified of the change. While the underlying value has changed, there is no way for ViewModel B's version of B.variable to know this, and so the observer of ViewModel B doesn't know that the change has occurred.

My recommendation is to create a struct that holds state, in my apps I have the concept of an AppState and Preferences. How you handle it doesn't really matter, the key is this struct would have @StoredValue(key:"subscriptionScreen") var variable: String?, which you can reference from either ViewModel, or any View that you wish to use this in.

Hope that helps answer the question and adds a little clarity!

dentvii commented 4 months ago

Thanks Joe. After seeing you Pandas demo that is the approach I used. However, if you use @AppStorage on views (Not View Models) this behavior is perfectly possible to listen and react to changes on Views - and with a few workarounds on viewmodels as well.