0

Please see my example provided, I have recreated my pattern as accurately as possible while leaving out details that are not relevant to the question.

I have @Published property variables in my viewmodel which are updated/assigned after a fetch to firebase. Each time one of the root or child views is accessed, the fetch logic runs (or takes from cache), and then maps my values to the @Published dictionaries I have in my view model. What concerns me, is that my CardView always updates successfully, while my AlternateCardView only gets the correct values from my dictionary on first load, but never again unless I kill the app.

Am I missing an obvious best-practice here? Is there a better way to implement my pattern to avoid this bug? I'd like my AlternateCardView to update whenever a change is detected, and I have verified that my viewmodel is indeed updating the values - they're just not translating into my view.

Please note: I have also tried this solution using a managed collection of custom defined Structs instead of the literal dictionaries presented in my example. Despite that, the bug I am describing still persisted - so I am sure that is not the issue. I did this because I thought it would guarantee firing objectWillChange, but I wonder if I am actually running into a weird quip with SwiftUI.

I am using Xcode Version 13.2.1, Swift5.1, and running on iOS15 iPhone 11 simulator.

Content view:

struct ContentView: View {
    // ...
    var body: some View {
        VStack {
            RootView().environmentObject(ProgressEngine())
        }
    }
}

Root view:

struct RootView: View {
    @EnvironmentObject var userProgress: ProgressEngine

    var body: some View {
        VStack {
            NavigationLink(destination: ChildView().environmentObject(self.userProgress)) {
              CardView(progressValue: self.$userProgress.progressValues)
            }
        }
        .onAppear {
            self.userProgress.fetchAllProgress() // This is fetching data from firebase, assigns to my @Published properties
        }
    }
}

Card view:

// This view works and updates all the time, successfully - no matter how it is accessed
struct CardView: View {
    @EnvironmentObject var userProgress: ProgressEngine
    @Binding var progressVals: [String: CGFloat] // binding to a dict in my viewmodel
    var body: some View {
        VStack {
            // just unwrapping for example
            Text("\(self.userProgress.progressValues["FirstKey"]!)") 
        }
    }
}

Child view:

struct ChildView: View {
    @EnvironmentObject var userProgress: ProgressEngine
    @EnvironmentObject var anotherObject: AnotherEngine
    VStack {
        // I have tried this both with a ForEach and also by writing each view manually - neither works
        ForEach(self.anotherObject.items.indices, id: \.self) { index in 
            NavigationLink(destination: Text("another view").environmentObject(self.userProgress)) {
                // This view only shows the expected values on first load, or if I kill and re-load the app
                AlternateCardView(userWeekMap: self.$userProgress.weekMap)
            }
        }
    }
    .onAppear {
        self.userProgress.fetchAllProgress()
        self.userProgress.updateWeekMap()
}

AlternateCardView:

// For this example, this is basically the same as CardView, 
// but shown as a unique view to replicate my situation
struct AlternateCardView: View {
    @EnvironmentObject var userProgress: ProgressEngine
    @Binding var weekMap: [String: [String: CGFloat]] 
    var body: some View {
        VStack {
            // just unwrapping for example
            // defined it statically for the example - but dynamic in my codebase
            Text("\(self.userProgress.weekMap["FirstKey"]!["WeekKey1"]!)") 
        }
    }
}

View model:

class ProgressEngine: ObservableObject {

    // Accessing values here always works
    @Published var progressValues: [String: CGFloat] = [
        "FirstKey": 0,
        "SecondKey": 0,
        "ThirdKey": 0
    ]
    
    // I am only able to read values out of this the first time view loads
    // Any time my viewmodel updates this map, the changes are not reflected in my view
    // I have verified that these values update in the viewmodel in time,
    // To see the changes, I have to restart the app
    @Published var weekMap: [String: [String: CGFloat]] = [
        "FirstKey": [
            "WeekKey1": 0,
            "WeekKey2": 0,
            "WeekKey3": 0,
            .....,
            .....,
         ],
         "SecondKey": [
            .....,
            .....,
         ],
         "ThirdKey": [
            .....,
            .....,
         ]
    ]

    func fetchAllProgress(...) { 
        // do firebase stuff here ...

        // update progressValues
    }

    func updateWeekMap(...) {
        // Uses custom params to map data fetched from firebase to weekMap
    }
}
Andre
  • 562
  • 2
  • 7
  • 18
  • Just proposing a couple of things, not sure if they will work but worth the try: 1) pass again the `.environmentObject(userProgress)`, as a modifier to the `AlternateCardView`; 2) Create another state variable in `AlternateCardView` - `@State private var weekMap: [...]()` and change it using `.onChange(of:)`, whenever the original dictionary changes in the view model. – HunterLion Mar 20 '22 at 18:58
  • try inserting `self.objectWillChange.send()` at beginning of `fetchAllProgress` – ChrisR Mar 20 '22 at 19:28
  • Also, how do you create the `@EnvironmentObject`? – Yrb Mar 20 '22 at 20:10

1 Answers1

2

We don’t init objects in body. It has to either be a singleton if the model’s lifetime is of the app or as a @StateObject if the model’s lifetime should be tied to a view. In your case it’s the latter, however for these kind of loader/fetcher objects we don’t usually use environmentObject because usually they aren’t shared in deep view struct hierarchies.

Note that ObservableObject is part of the combine framework so if your fetching isn’t using Combine then you might want to try using the newer async/await pattern and if you pair it with SwiftUI’s task modifier then you don’t even need an object at all!

malhal
  • 26,330
  • 7
  • 115
  • 133
  • Thank you for this perspective - so environmentObject is not an intended solution for deeply nested views updating values from a model? Could you provide an example of ObservableObject or the combine framework with async/await - or a resource you trust? – Andre Mar 20 '22 at 21:26
  • 1
    environmentObject Is just a way to avoid having to pass an object into many View that don’t need the object – malhal Mar 20 '22 at 21:28
  • So I am understanding that Combine is like reactive programming, where it relies on events instead of reading code top-to-bottom https://en.wikipedia.org/wiki/Functional_reactive_programming – Andre Mar 20 '22 at 21:34
  • 1
    Yes and it’s main feature is to combine multiple notifications into one using combineLatest – malhal Mar 21 '22 at 08:04