Search code examples
arraysswiftswiftuistateswiftui-list

Updating state for an element of a @Published array, doesn't refresh the view


I have the following LocationToggleable class, which I have attempted to make an observable object, as well as a struct, and various other attempts. This object is being maintained by a LocationService class, which is also an ObservableObject and it's being published in an [LocationToggleable] array. I understand that updating the state for an individual element of that array, doesn't necessarily publish the updates from that element, because the array itself wasn't actually updated, merely a property of an element of the array. How do I properly update a list, when a property is changed, on a @Published array?

Failed Attempts

class LocationToggleable: Hashable, Identifiable {
    // Omitted unrelated properties
    var selected: Bool = false
}

class LocationToggleable: ObservableObject, Hashable, Identifiable {
    // Omitted unrelated properties
    @Published var selected: Bool = false
}

struct LocationToggleable: Hashable, Identifiable {
    // Omitted unrelated properties
    var selected: Bool = false
}

Location Service

class LocationService: ObservableObject {
    //Omitted unrelated properties/functions
    @Published var toggleableLocations: [LocationToggleable]
}

class LocationService: ObservableObject {
    //Omitted unrelated properties/functions
    @StateObject var toggleableLocations: [LocationToggleable]
}

class LocationService: ObservableObject {
    //Omitted unrelated properties/functions
    @ObservedObject var toggleableLocations: [LocationToggleable]
}

List

  • Partial Code, the else is just a copy of the true return
ForEach(locationService.toggleableLocations, id: \.self) { item in
                            if item.selected {
                                Section(content: {}, header: {
                                    TrailDetailCardView(location: item.location)
                                        .frame(maxWidth: .infinity)
                                        .matchedGeometryEffect(id: "location-id-\(item.id)", in: namespace)
                                        .onTapGesture {
                                            withAnimation {
                                                proxy.scrollTo(item.id, anchor: UnitPoint(x: 0, y: 0.01))
                                                locationService.setSelected(for: item, to: !item.selected)
                                            }
                                        }
                                })
                                .id(item.id)
                            }

Set Selected Function

  • This works right now, but that's only because I'm forcing a publish update for the toggleableLocations and it reeks of a code-smell.
    func setSelected(for toggleableLocation: LocationToggleable, to selected: Bool) {
        if let currentIndex = toggleableLocations.firstIndex(where: { $0.selected }) {
            toggleableLocations[currentIndex].selected = false
        }
        toggleableLocation.selected = selected
        toggleableLocations = toggleableLocations
    }

Notes

  • SwiftUI updating array state doesn't update view This solution doesn't work in my case, because I need the object to maintain it's selected state for proper management of it's layout in the LazyVGrid. I also need the object to be Hashable for uses elsewhere in the code.

Solution

  • You could try this approach as shown in the example code, to ...properly update a list, when a property is changed, on a @Published array.

    The example code uses one @StateObject var locationService = LocationService(), the source of data truth. It also uses the $ sign in the ForEach to allow changes to be made to the item, and a simple item.selected.toggle() to toggle the selection.

    struct LocationToggleable: Hashable, Identifiable {
        let id = UUID()  // <--- here
        // Omitted unrelated properties
        var selected: Bool
        var location: String  // <--- for testing
    }
    
    class LocationService: ObservableObject {
        //Omitted unrelated properties/functions
        @Published var toggleableLocations: [LocationToggleable] = [
            LocationToggleable(selected: true, location: "location-1"),
            LocationToggleable(selected: false, location: "location-2"),
            LocationToggleable(selected: true, location: "location-3")]  // <--- here for testing, else []
    }
    
    struct ContentView: View {
        @StateObject var locationService = LocationService() // <--- here
        
        var body: some View {
            ForEach($locationService.toggleableLocations) { $item in  // <--- here $
                if item.selected {
                    Section(content: {}, header: {
                        TrailDetailCardView(location: item)  // <--- here
                            .frame(maxWidth: .infinity)
                           // .matchedGeometryEffect(id: "location-id-\(item.id)", in: namespace)
                            .onTapGesture {
                                withAnimation {
    //                                proxy.scrollTo(item.id, anchor: UnitPoint(x: 0, y: 0.01))
                                    item.selected.toggle()  // <--- here
                                }
                            }
                    })
                    .id(item.id)
                } 
                // --- for testing
                else {
                    Text("\(item.location) not selected").id(item.id)
                        .onTapGesture {item.selected.toggle() }
                }
                
            }
        }
    }
    
    
    // for testing
    struct TrailDetailCardView: View {
        var location: LocationToggleable // <--- here, use @Binding if required
        
        var body: some View {
            Text(location.selected ? "true" : "false").border(.red)
        }
    }