Stackbever
Stackbever

Reputation: 443

Why is an ObservableObject embedded in an EnvironmentObject not adding new items to a List

I am learning SwiftUI and modified an existing example because I wanted to test if it is possible to have an ObservableObject accessible across views. When reading the documentation I found I should use the @EnvironmentObject for this. I tried this but this does not work: the array persons is filled with new persons (using the debugger) but the UI is not updated. If I use the people property outside of the @EnvironmentObject it works as expected.

My code:

import SwiftUI

struct ContentView: View {
    @EnvironmentObject var envTest: EnvTest
    var body: some View {
        VStack {
            ForEach(envTest.people.persons) { person in
                Text("\(person.name)")
            }
            Button(action: {
                self.envTest.people.persons.append(Person(id: 4, name: "I am new here"))
            }) {
                Text("Add/Change name")
            }
        }

    }
}

struct ContentView_Previews: PreviewProvider {
    static let envTest = EnvTest()

    static var previews: some View {
        ContentView().environmentObject(envTest)
    }
}

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 persons: [Person]

    init() {
        self.persons = [
            Person(id: 1, name: "Jabba"),
            Person(id: 2, name: "Polke"),
            Person(id: 3, name: "Lori")]
    }
}

class EnvTest: ObservableObject {
    @ObservedObject var people = People()
}

I added the EnvTest to the SceneDelegate using:

var window: UIWindow?
var envTest = EnvTest()

func scene(...) {

    let contentView = ContentView().environment(\.managedObjectContext, context).environmentObject(envTest)
}

Upvotes: 3

Views: 2788

Answers (2)

Benedict
Benedict

Reputation: 41

There are two problems to fix to get to the result you want:

  1. Import Combine and replace @ObservedObject in EnvTest with @Published
  2. Manually trigger EnvTest.objectWillChange when the 'people' property changes by adding a subscriber to objectWillChange of the people property.

The reason for these two changes is to do with how ObservableObject works. The documentation tells us

An ObservableObject synthesizes an objectWillChange publisher that emits the changed value before any of its @Published properties changes.

If you don't have a @Published property then the People.objectWillChange publisher won't emit anything. Once we've fixed that, when we change the persons property on People that triggers the People.objectWillChange publisher to emit a value. However, a second problem is revealed, that SwiftUI is listening to the EnvTest.objectWillChange publisher, so we won't see the UI update. This is why we have to manually call send on the EnvTest.objectWillChange publisher when we receive a value from the People.objectWillChange publisher, to inform SwiftUI that it needs to generate new Views.

It would be nice if Combine automatically propagated objectWillChange calls when you put a @Published property wrapper on a property of a type conforming to ObservableObject. Similarly, it would be nice if SwiftUI watched the lowest ObservableObject rather than the top level one (i.e. in this case watched People instead of EnvTest). Perhaps this will be introduced in the future, because the current solution of manually linking the two is a pain and won't scale well.

The full solution in code to do what you wanted is:

import SwiftUI
import Combine

struct ContentView: View {
    @EnvironmentObject var envTest: EnvTest
    var body: some View {
        VStack {
            ForEach(envTest.people.persons) { person in
                Text("\(person.name)")
            }
            Button(action: {
                self.envTest.people.persons.append(Person(id: 4, name: "I am new here"))
            }) {
                Text("Add/Change name")
            }
        }

    }
}

struct ContentView_Previews: PreviewProvider {
    static let envTest = EnvTest()

    static var previews: some View {
        ContentView().environmentObject(envTest)
    }
}

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 persons: [Person]

    init() {
        self.persons = [
            Person(id: 1, name: "Jabba"),
            Person(id: 2, name: "Polke"),
            Person(id: 3, name: "Lori")]
    }
}

class EnvTest: ObservableObject {
    @Published var people = People()

    init(people: People = People()) {
        self.people = people
        people.objectWillChange.receive(subscriber: Subscribers.Sink(receiveCompletion: { _ in }) {
            self.objectWillChange.send()
        })
    }
}

Upvotes: 4

Stackbever
Stackbever

Reputation: 443

Well, it seems this is not possible, but as my understanding of Swift got better, I know I can easily share the observable between views without embedding it in an EnvironmentObject.

Upvotes: 0

Related Questions