102

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.

karel
  • 5,489
  • 46
  • 45
  • 50
jarnaez
  • 1,402
  • 3
  • 10
  • 14

5 Answers5

124

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")
}
malhal
  • 26,330
  • 7
  • 115
  • 133
kontiki
  • 37,663
  • 13
  • 111
  • 125
  • 3
    Modified the answer, to include another option (using struct, instead of class) – kontiki Aug 12 '19 at 11:00
  • 2
    Thanks!! Well explained and understood. Both solutions work but I'll use the struct instead the class as you recommend. It's cleaner. – jarnaez Aug 12 '19 at 12:51
  • @kontiki great answer. I've been trying to do this same thing for a subclass of NSManagedObject without success. Any suggestions? Thanks. – Chuck H Aug 12 '19 at 17:36
  • Hi @ChuckH. To be perfectly honest, I haven't work much with CoreData + SwiftUI yet. But if you think it won't get flagged as duplicate, maybe you can post another question and elaborate a little more on the challenges you face. If not me, certainly someone will be able to help. Although I'll give it a try too ;-) Cheers. – kontiki Aug 12 '19 at 18:44
  • Hi @kontiki Thanks. I went on a very long road trip yesterday and today and thought about this a lot. I think I may have a solution/workaround, but won't be able to try it out for a day or two. I'll post my findings. Thanks. – Chuck H Aug 14 '19 at 03:45
  • @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. – Chuck H Aug 16 '19 at 17:01
  • @kontiki the objectWillChange works beautifully on my CoreData project. Thank you so much! – Anthony Nov 07 '19 at 03:03
  • @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? – Mitemmetim Nov 21 '19 at 09:26
  • Getting stuck for almost one week in this issue. MILLIONS OF THANKS! I need to use "class" as my view model (so that some common variables and methods can be put into super class), so my solution is for each @Published var, add a { willSet { objectWillChange.send() } } and it works magically! – Zhengqian Kuang Feb 26 '20 at 17:59
  • 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. – Amrit Sidhu Apr 15 '20 at 18:52
  • @kontiki: I would be grateful if you could look into this issue. Here is the link to the exact question: http://www.shorturl.at/fkBTU – Amrit Sidhu Apr 15 '20 at 19:05
  • 2
    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 – LiangWang Jun 10 '20 at 00:22
  • @Jacky have a look at the documentation for ObservableObject, it describes the contract. – Antony Stubbs Jul 04 '20 at 14:43
  • Using struct is not the solution to everything! This answer does not properly answer the OP's questions and thus should not be the accepted one. – zrfrank Sep 04 '20 at 00:32
  • Hi, may somebody help me with a similar problem of mine? can-not-reach-to-array-element-from-another-view-swiftui – Mert Köksal Sep 22 '20 at 08:18
  • Unrelated. But thank you so much for the Published var suggesting. For this using an AWS Observer to load your data, I had an issues with some foreach's not updating. In addition to using a class as an ObservableObject, I was required to set the array in that class to @Published. – DaWiseguy Dec 19 '20 at 02:27
67

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.

Stuart Malone
  • 779
  • 5
  • 3
  • 4
    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. – Ian Woodley Jan 25 '20 at 23:12
  • 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. – leopic May 25 '20 at 00:17
  • 1
    I think this should be the accepted answer. And many swiftui data related videos from WWDC20 has also recommended this approach. I think either you use all struct approach, that passing index and identifiers around (it is very hard to get a filtered array of binding from an array of binding, believe me!), or use all ObservableObjects for data modeling and proper separate your views. – zrfrank Aug 25 '20 at 10:27
  • 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. – zrfrank Aug 25 '20 at 10:32
  • 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. – zrfrank Aug 25 '20 at 10:37
  • This should be the accepted solution - very neat and fits nicely with the idea of watching an object for changes. – Houdi Oct 23 '20 at 08:53
  • 4
    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. – malhal Jan 20 '21 at 12:53
18

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

Gene Loparco
  • 2,157
  • 23
  • 23
Networks
  • 2,144
  • 1
  • 10
  • 17
5

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)")
    }
}
Luke Howard
  • 469
  • 5
  • 5
  • 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. – Luke Howard Oct 26 '20 at 01:12
  • I think two types are the way to go. Added in an answer just now. –  Mar 22 '22 at 22:03
5

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