Why is an ObservedObject array not updated in my SwiftUI application?
I'm playing with SwiftUI, trying to understand how ObservableObject
works. I have an array of Person
objects. When I add a new Person
into the array, it is reloaded in my View, however if I change the value of an existing Person
, it is not reloaded in the View.
// NamesClass.swift
import Foundation
import SwiftUI
import Combine
class Person: ObservableObject,Identifiable{
var id: Int
@Published var name: String
init(id: Int, name: String){
self.id = id
self.name = name
}
}
class People: ObservableObject{
@Published var people: [Person]
init(){
self.people = [
Person(id: 1, name:"Javier"),
Person(id: 2, name:"Juan"),
Person(id: 3, name:"Pedro"),
Person(id: 4, name:"Luis")]
}
}
struct ContentView: View {
@ObservedObject var mypeople: People
var body: some View {
VStack{
ForEach(mypeople.people){ person in
Text("\(person.name)")
}
Button(action: {
self.mypeople.people[0].name="Jaime"
//self.mypeople.people.append(Person(id: 5, name: "John"))
}) {
Text("Add/Change name")
}
}
}
}
If I uncomment the line to add a new Person
(John), the name of Jaime is shown properly, however if I just change the name this is not shown in the View.
I'm afraid I'm doing something wrong or maybe I don't get how the ObservedObjects
work with arrays.
You can use a struct instead of a class. Because of a struct's value semantics, a change to a person's name is seen as a change to Person struct itself, and this change is also a change to the people array so @Published will send the notification and the View body will be recomputed.
import Foundation
import SwiftUI
import Combine
struct Person: Identifiable{
var id: Int
var name: String
init(id: Int, name: String){
self.id = id
self.name = name
}
}
class Model: ObservableObject{
@Published var people: [Person]
init(){
self.people = [
Person(id: 1, name:"Javier"),
Person(id: 2, name:"Juan"),
Person(id: 3, name:"Pedro"),
Person(id: 4, name:"Luis")]
}
}
struct ContentView: View {
@StateObject var model = Model()
var body: some View {
VStack{
ForEach(model.people){ person in
Text("\(person.name)")
}
Button(action: {
self.mypeople.people[0].name="Jaime"
}) {
Text("Add/Change name")
}
}
}
}
Alternatively (and not recommended), Person
is a class, so it is a reference type. When it changes, the People
array remains unchanged and so nothing is emitted by the subject. However, you can manually call it, to let it know:
Button(action: {
self.mypeople.objectWillChange.send()
self.mypeople.people[0].name="Jaime"
}) {
Text("Add/Change name")
}
I think there is a more elegant solution to this problem. Instead of trying to propagate the objectWillChange
message up the model hierarchy, you can create a custom view for the list rows so each item is an @ObservedObject:
struct PersonRow: View {
@ObservedObject var person: Person
var body: some View {
Text(person.name)
}
}
struct ContentView: View {
@ObservedObject var mypeople: People
var body: some View {
VStack{
ForEach(mypeople.people){ person in
PersonRow(person: person)
}
Button(action: {
self.mypeople.people[0].name="Jaime"
//self.mypeople.people.append(Person(id: 5, name: "John"))
}) {
Text("Add/Change name")
}
}
}
}
In general, creating a custom view for the items in a List/ForEach allows each item in the collection to be monitored for changes.
For those who might find it helpful. This is a more generic approach to @kontiki 's answer.
This way you will not have to be repeating yourself for different model class types
import Foundation
import Combine
import SwiftUI
class ObservableArray<T>: ObservableObject {
@Published var array:[T] = []
var cancellables = [AnyCancellable]()
init(array: [T]) {
self.array = array
}
func observeChildrenChanges<T: ObservableObject>() -> ObservableArray<T> {
let array2 = array as! [T]
array2.forEach({
let c = $0.objectWillChange.sink(receiveValue: { _ in self.objectWillChange.send() })
// Important: You have to keep the returned value allocated,
// otherwise the sink subscription gets cancelled
self.cancellables.append(c)
})
return self as! ObservableArray<T>
}
}
class Person: ObservableObject,Identifiable{
var id: Int
@Published var name: String
init(id: Int, name: String){
self.id = id
self.name = name
}
}
struct ContentView : View {
//For observing changes to the array only.
//No need for model class(in this case Person) to conform to ObservabeObject protocol
@ObservedObject var mypeople: ObservableArray<Person> = ObservableArray(array: [
Person(id: 1, name:"Javier"),
Person(id: 2, name:"Juan"),
Person(id: 3, name:"Pedro"),
Person(id: 4, name:"Luis")])
//For observing changes to the array and changes inside its children
//Note: The model class(in this case Person) must conform to ObservableObject protocol
@ObservedObject var mypeople: ObservableArray<Person> = try! ObservableArray(array: [
Person(id: 1, name:"Javier"),
Person(id: 2, name:"Juan"),
Person(id: 3, name:"Pedro"),
Person(id: 4, name:"Luis")]).observeChildrenChanges()
var body: some View {
VStack{
ForEach(mypeople.array){ person in
Text("\(person.name)")
}
Button(action: {
self.mypeople.array[0].name="Jaime"
//self.mypeople.people.append(Person(id: 5, name: "John"))
}) {
Text("Add/Change name")
}
}
}
}
ObservableArray is very useful, thank you! Here's a more generalised version that supports all Collections, which is handy when you need to react to CoreData values indirected through a to-many relationship (which are modelled as Sets).
import Combine
import SwiftUI
private class ObservedObjectCollectionBox<Element>: ObservableObject where Element: ObservableObject {
private var subscription: AnyCancellable?
init(_ wrappedValue: AnyCollection<Element>) {
self.reset(wrappedValue)
}
func reset(_ newValue: AnyCollection<Element>) {
self.subscription = Publishers.MergeMany(newValue.map{ $0.objectWillChange })
.eraseToAnyPublisher()
.sink { _ in
self.objectWillChange.send()
}
}
}
@propertyWrapper
public struct ObservedObjectCollection<Element>: DynamicProperty where Element: ObservableObject {
public var wrappedValue: AnyCollection<Element> {
didSet {
if isKnownUniquelyReferenced(&observed) {
self.observed.reset(wrappedValue)
} else {
self.observed = ObservedObjectCollectionBox(wrappedValue)
}
}
}
@ObservedObject private var observed: ObservedObjectCollectionBox<Element>
public init(wrappedValue: AnyCollection<Element>) {
self.wrappedValue = wrappedValue
self.observed = ObservedObjectCollectionBox(wrappedValue)
}
public init(wrappedValue: AnyCollection<Element>?) {
self.init(wrappedValue: wrappedValue ?? AnyCollection([]))
}
public init<C: Collection>(wrappedValue: C) where C.Element == Element {
self.init(wrappedValue: AnyCollection(wrappedValue))
}
public init<C: Collection>(wrappedValue: C?) where C.Element == Element {
if let wrappedValue = wrappedValue {
self.init(wrappedValue: wrappedValue)
} else {
self.init(wrappedValue: AnyCollection([]))
}
}
}
It can be used as follows, let's say for example we have a class Fridge that contains a Set and our view needs to react to changes in the latter despite not having any subviews that observe each item.
class Food: ObservableObject, Hashable {
@Published var name: String
@Published var calories: Float
init(name: String, calories: Float) {
self.name = name
self.calories = calories
}
static func ==(lhs: Food, rhs: Food) -> Bool {
return lhs.name == rhs.name && lhs.calories == rhs.calories
}
func hash(into hasher: inout Hasher) {
hasher.combine(self.name)
hasher.combine(self.calories)
}
}
class Fridge: ObservableObject {
@Published var food: Set<Food>
init(food: Set<Food>) {
self.food = food
}
}
struct FridgeCaloriesView: View {
@ObservedObjectCollection var food: AnyCollection<Food>
init(fridge: Fridge) {
self._food = ObservedObjectCollection(wrappedValue: fridge.food)
}
var totalCalories: Float {
self.food.map { $0.calories }.reduce(0, +)
}
var body: some View {
Text("Total calories in fridge: \(totalCalories)")
}
}