121

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.

1
  • if array have many items ,change one state or value Commented Dec 5, 2023 at 6:36

7 Answers 7

135

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")
}
Sign up to request clarification or add additional context in comments.

4 Comments

@kontiki I gave up on trying to get my NSManagedObject subclasses to publish their own changes. I did solve my problem by calling objectWillChange from NSFetchedResultsController's controllerDidChangeContent delegate or from observing NSManagedObjectContextDidSave notifications when not using a fetched results controller.
@kontiki Thanks for this great solution! Can someone maybe explain why it is working with a struct and why it is not working with classes?
Hi @kontiki, your answer is absolutely correct and every observable object is supposed to work this way, but I am facing one weird issue. So, what I want to do is that I have to add a new person, in onAppear instead of adding a new person on tap of the button. In short, the people array lets say, already has three persons and I have to add the fourth one just while landing on that view. The weird thing is that person gets added if this view if the root view, however, it does not work when this view is pushed using Navigation Link.
why self.mypeople.objectWillChange.send() has to be put before self.mypeople.people[0].name="Jaime" ? It makes more sense to do the opposite way. @kon
78

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.

5 Comments

Thank you, this is exactly what I was looking for. This is the only solution I've seen that allows you to trigger a re-render by mutating a property of a given reference within a collection without operating on the collection itself (using by-index access or otherwise). e.g. this allows one to store a random element from an array of ObservableObjects in a variable and trigger a re-render by operating only on that variable.
Agreed, this didn't require passing an index around or some sort of weird voodoo and helped to break down each view into it's separate entity.
It almost broke my code sanity that I've used all in struct approach, and had to write lots of glue code. For example, all my view's initializer are almost handwritten to properly initialize the view's bindings. And there are lots of Binding(get:{}, set{}), even worse is for instance .sheet(isPresented: Binding(get:{}, set{})){ SomeView(......)). The @Binding is really not matured enough when coming to dealing with collection of data, and nested structs.
And another debatable issue is that in your model layer or even viewmodel, should you use SwiftUI property wrappers like @Binding, @StateObject, etc? Ideally, it is insane, but I've seen code do that. I believe, the all in struct model approach is more suitable to FRP, but the problem is SwiftUI doesn't works with FRP well. I feel like Combine and SwiftUI are two independently developed projects, but int he fact FRP + Value-Typed + Declarative UI are a whole.
Downvoted sorry. The problem with nested ObservableObjects is when a person's name is changed the list doesn't update properly. It is better to model the data with a struct so the list will know to update when that happens. You might not hit the problem straight away but you will when you try to implement filtering.
18

This is a more generic approach to kontiki's answer. This approach will not have you 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")
            }
        }
    }
}

3 Comments

Any thoughts on how to initialise it as empty array?
What's to stop this line from becoming a memory leak? self.cancellables.append(c) That the user will eventually navigate away from the view?
Compiler is rejecting ObservableArray as the generic type on observeChildrenChanges is shadowing the outer type (on the class)
9

The ideal thing to do would be to chain @ObservedObject or @StateObject and some other property wrapper that is suitable for sequences, e.g. @StateObject @ObservableObjects. But you can't use more than one property wrapper, so you need to make different types to handle the two different cases. Then you can use either one of the following, as appropriate.

(Your People type is unnecessary—its purpose can be abstracted to all sequences.)

@StateObjects var people = [
  Person(id: 1, name:"Javier"),
  Person(id: 2, name:"Juan"),
  Person(id: 3, name:"Pedro"),
  Person(id: 4, name:"Luis")
]

@ObservedObjects var people: [Person]
import Combine
import SwiftUI

@propertyWrapper
public final class ObservableObjects<Objects: Sequence>: ObservableObject
where Objects.Element: ObservableObject {
  public init(wrappedValue: Objects) {
    self.wrappedValue = wrappedValue
    assignCancellable()
  }

  @Published public var wrappedValue: Objects {
    didSet { assignCancellable() }
  }

  private var cancellable: AnyCancellable!
}

// MARK: - private
private extension ObservableObjects {
  func assignCancellable() {
    cancellable = Publishers.MergeMany(wrappedValue.map(\.objectWillChange))
      .sink { [unowned self] _ in objectWillChange.send() }
  }
}


// MARK: -

@propertyWrapper
public struct ObservedObjects<Objects: Sequence>: DynamicProperty
where Objects.Element: ObservableObject {
  public init(wrappedValue: Objects) {
    _objects = .init(
      wrappedValue: .init(wrappedValue: wrappedValue)
    )
  }

  public var wrappedValue: Objects {
    get { objects.wrappedValue }
    nonmutating set { objects.wrappedValue = newValue }
  }

  public var projectedValue: Binding<Objects> { $objects.wrappedValue }

  @ObservedObject private var objects: ObservableObjects<Objects>
}

@propertyWrapper
public struct StateObjects<Objects: Sequence>: DynamicProperty
where Objects.Element: ObservableObject {
  public init(wrappedValue: Objects) {
    _objects = .init(
      wrappedValue: .init(wrappedValue: wrappedValue)
    )
  }

  public var wrappedValue: Objects {
    get { objects.wrappedValue }
    nonmutating set { objects.wrappedValue = newValue }
  }

  public var projectedValue: Binding<Objects> { $objects.wrappedValue }

  @StateObject private var objects: ObservableObjects<Objects>
}

Comments

6

Maintaining the relationship between the View and DataModel (the source of truth) can be complex and challenging. However, the introduction of the Observation framework in iOS 17 has streamlined this process significantly.

To utilize this framework, follow these two steps:

  1. Establish a single source of truth using @State. This eliminates the need for @Published, @EnvironmentObject, and @StateObject.
  2. Apply the @Observable wrapper to your DataModel class in a cascading manner.

Here is an illustrative example:

@Observable // By @Observable macro, the class becomes observable by SwiftUI view.
class Person: Identifiable {
  var id: Int
  var name: String
  init(id: Int, name: String) {
    self.id = id
    self.name = name
  }
}

@Observable
class People {
  var people: [Person]
  init() {
    people = [
      Person(id: 1, name: "John"),
      Person(id: 2, name: "Paul"),
      Person(id: 3, name: "George"),
      Person(id: 4, name: "Ringo"),
    ]
  }
}

struct TestView: View {
  @State var mypeople: People // new way to define source of truth

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

Note: The Observation framework offers additional exciting features. For more information, please refer to this article.

Comments

6

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 modeled 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)")
    }
}

2 Comments

Not sure whether it might be better to use @StateObject to own the ObservedObjectCollectionBox, I'm assuming not because it isn't a new source of truth, but advice welcome.
I think two types are the way to go. Added in an answer just now.
0
import Foundation
import SwiftUI
import Combine


class Person: Equatable, Hashable ,Identifiable,ObservableObject{
    var id = UUID()
    @Published var name: String = ""
    
    static func == (lhs: Person, rhs: Person) -> Bool {
        lhs.id == rhs.id
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
        hasher.combine(name)
    }
  
    init(name: String) {
        self.name = name
    }
}

class VModel: ObservableObject{
    
    @Published var people: [Person]

    init(){
        self.people = [
            Person(name:"Juan"),
            Person(name:"Pedro"),
            Person(name:"Luis"),
            Person(name:"Javier1"),
        ]
    }
}

struct ListRow: View {
    
    @ObservedObject var people : Person
    
    var body: some View {
        HStack {
            Text("\(people.name)")
            Spacer()
            Button(action: {
                people.name = people.name + "1"
            }, label: {
                Text("Change Name")
            })
        }
        
        
    }
}

struct ContentView1: View {
    @StateObject var vmodel = VModel()

    var body: some View {
        VStack{
            List {
                ForEach(vmodel.people ,id: \.self){ person in
                     ListRow(people: person)
                }
            }
        }
        .environmentObject(vmodel) 
    }
}


#Preview {
    ContentView1()
}

not use objectWillChange not use elf.mypeople.people[0].name="Jaime"

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.