arraysswiftuiobservableobject

Boolean inside ObservableObject Array does not trigger re-render


In my current project, i am having a hard time getting SwiftUI to recognize a change in a nested ObservableObject, here a demo / debug code


import SwiftUI
import SwiftData

class Collection: ObservableObject, Identifiable, Hashable {
    
    static func == (lhs: Collection, rhs: Collection) -> Bool {
        return lhs.title == rhs.title && lhs.items == rhs.items
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(title)
    }
    var id = UUID()
    @Published var title : String = ""
    @Published var items : [String] = []
    @Published var isCollapsed: Bool = true
    
    init(title: String, items: [String]) {
        self.title = title
        self.items = items
    }
}



class GridViewModel: ObservableObject {
    @Published var collections : [Collection] = []
}


struct ContentView: View {
    
//    @EnvironmentObject var gridViewModel : GridViewModel
    @StateObject var  gridViewModel = GridViewModel()

    var body: some View {
        VStack {
            Text("Toggle Visibility with observed Objects")
            Button("Add Collection", action: {
                addCollection()
            })
            
            ScrollView {
                ForEach(gridViewModel.collections, id: \.self) { coll in
                    Button("toggle visibility for:", action: {
                        coll.isCollapsed.toggle()     // does not work
                    })
                    Button("edit", action: {
                        changeItem()
                    })
                    Text(coll.title)
                    
                    if coll.isCollapsed {
                        ForEach(coll.items, id: \.self) { item in
                            Text(item)
                        }
                    }
                    Spacer(minLength: 20)
                }
            }
            
        }
        .onAppear {
            let collection1 = Collection(title: "first Collection", items: ["apple", "banana", "citrus"])
            let collection2 = Collection(title: "second Collection", items: ["banana", "citrus", "apple"])
            let collection3 = Collection(title: "third Collection", items: ["citrus", "banana", "apple"])

            gridViewModel.collections = [collection1, collection2, collection3]
        }
    }
    
    func addCollection() {
        let collectionNew = Collection(title: "new_vierte Collection", items: ["apple", "banana", "citrus"])
        gridViewModel.collections.append(collectionNew)
        gridViewModel.objectWillChange.send()   // does work
    }
    
    func changeItem() {
        gridViewModel.collections[0].items[0] = "---edit------edit------edit---"
        print("done: changed to: \(gridViewModel.collections[0].items[0])")
        gridViewModel.objectWillChange.send()   // works
    }



}

#Preview {
    var gridViewModel = GridViewModel()
    return ContentView().environmentObject(gridViewModel)
    #if os(macOS)
        .frame(width: 700, height: 500)
    #endif

}

As you can see, the change in the first level, which is inside GridViewModel, triggers a re-render, but a change inside Collection does not..

i need SwiftUI to recognize that isCollapsed boolean has changed, and the UI needs to be updated, any suggestions how to make that work?


Solution

  • Try using gridViewModel.objectWillChange.send() as shown in this code

    Button("toggle visibility for:", action: {
        gridViewModel.objectWillChange.send() // <--- here
        coll.isCollapsed.toggle()
    })
    

    EDIT-1:

    As mentioned you could also use a struct Collection to include into the older style class GridViewModel: ObservableObject, such as:

    struct Collection: Identifiable, Hashable {  //<--- here
        let id = UUID()
        
        var title: String = ""
        var items: [String] = []
        var isCollapsed: Bool = true
    }
    
    class GridViewModel: ObservableObject {
        @Published var collections : [Collection] = []
    }
    
    struct ContentView: View {
        //    @EnvironmentObject var gridViewModel : GridViewModel
        @StateObject private var gridViewModel = GridViewModel()
        
        var body: some View {
            VStack {
                Text("Toggle Visibility with observed Objects")
                Button("Add Collection") {
                    addCollection()
                }
                
                ScrollView {
                    ForEach($gridViewModel.collections) { $coll in  //<--- here $
                        Button("toggle visibility for:", action: {
                            coll.isCollapsed.toggle()
                        })
                        Button("edit", action: {
                            changeItem()
                        })
                        Text(coll.title)
                        
                        if coll.isCollapsed {
                            ForEach(coll.items, id: \.self) { item in
                                Text(item)
                            }
                        }
                        Spacer(minLength: 20)
                    }
                }
            }
            .onAppear {
                let collection1 = Collection(title: "first Collection", items: ["apple", "banana", "citrus"])
                let collection2 = Collection(title: "second Collection", items: ["banana", "citrus", "apple"])
                let collection3 = Collection(title: "third Collection", items: ["citrus", "banana", "apple"])
                
                gridViewModel.collections = [collection1, collection2, collection3]
            }
        }
        
        func addCollection() {
            let collectionNew = Collection(title: "new_vierte Collection", items: ["apple", "banana", "citrus"])
            gridViewModel.collections.append(collectionNew) 
        }
        
        func changeItem() {
            gridViewModel.collections[0].items[0] = "---edit------edit------edit---" 
            print("done: changed to: \(gridViewModel.collections[0].items[0])")
        }
        
    }
    

    EDIT-2:

    As mentioned in my comments, using the recommended more modern Observable framework

    @Observable class Collection: Identifiable {  // <--- here
        let id = UUID()
        
        var title: String
        var items: [String]
        var isCollapsed: Bool
        
        init(title: String, items: [String], isCollapsed: Bool = true) {
            self.title = title
            self.items = items
            self.isCollapsed = isCollapsed
        }
    }
    
    @Observable class GridViewModel {  // <--- here
        var collections : [Collection] = []
    }
    
    struct ContentView: View {
        // when passing from parent ...  .environment(gridViewModel)
        // @Environment(GridViewModel.self) private var gridViewModel
        
        @State private var  gridViewModel = GridViewModel() // <---here
        
        var body: some View {
            VStack {
                Text("Toggle Visibility with observed Objects")
                Button("Add Collection") {
                    addCollection()
                }
                
                ScrollView {
                    ForEach(gridViewModel.collections) { coll in
                        Button("toggle visibility for:", action: {
                            coll.isCollapsed.toggle()
                        })
                        Button("edit", action: {
                            changeItem()
                        })
                        Text(coll.title)
                        
                        if coll.isCollapsed {
                            ForEach(coll.items, id: \.self) { item in
                                Text(item)
                            }
                        }
                        Spacer(minLength: 20)
                    }
                }
            }
            .onAppear {
                let collection1 = Collection(title: "first Collection", items: ["apple", "banana", "citrus"])
                let collection2 = Collection(title: "second Collection", items: ["banana", "citrus", "apple"])
                let collection3 = Collection(title: "third Collection", items: ["citrus", "banana", "apple"])
                
                gridViewModel.collections = [collection1, collection2, collection3]
            }
        }
        
        func addCollection() {
            let collectionNew = Collection(title: "new_vierte Collection", items: ["apple", "banana", "citrus"])
            gridViewModel.collections.append(collectionNew) //<--- here
        }
        
        func changeItem() {
            gridViewModel.collections[0].items[0] = "---edit------edit------edit---" //<--- here
            print("done: changed to: \(gridViewModel.collections[0].items[0])")
        }
        
    }
    

    Note, don't use your static func == ... and func hash..., remove them.

    Note also, using ForEach(coll.items, id: \.self) is bad practice, make sure your coll.items do not contain multiple same Strings by using for example a struct ItemName: Identifiable {....}