[SOLVED] SwiftUI Child Views not updating as expected with environmentObject

Issue

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

Solution

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!

Answered By – malhal

Answer Checked By – Robin (BugsFixing Admin)

Leave a Reply

Your email address will not be published.