EnvironmentObject and ObservedObject - interactions between them

Hello, I am trying to build an app using SwiftUI that uses 3 classes for the model. One is a source of truth for the other two. I'd like to have changes made to the model through interactions with the UI remain consistent through all three classes. I think I have a solution but I want to understand if I am on the right track in terms of how to EnvironmentObject and ObservedObject. Here is the "minimum" amount of code needed to see how things are working.

I add the following lines to SceneDelegate.swift:

    let gd = GameData(maxValue: 10, player: "Player 1")
    let ps = ProblemSpace(gameData: gd)
    let sm = StatsManager(gameData: gd)
    let contentView = CombineTestView(problemSpace: ps, statsManager: sm)
      .environmentObject(gd)

and then add the following to a new SwiftUI view:

import Combine

class GameData: ObservableObject {
  @Published var maxValue: Int
  @Published var player: String
  init(maxValue: Int, player: String) {
    self.maxValue = maxValue
    self.player = player
  }
}

class ProblemSpace: ObservableObject {
  var gameData: GameData
  init(gameData: GameData) {
    self.gameData = gameData
  }

  var maxValue: Int {
    set { self.gameData.maxValue = newValue }
    get { return self.gameData.maxValue }
  }

  var player: String {
    set { self.gameData.player = newValue }
    get { return self.gameData.player }
  }
}

class StatsManager: ObservableObject {
  var gameData: GameData
  init(gameData: GameData) {
    self.gameData = gameData
  }

  var maxValue: Int {
    set { self.gameData.maxValue = newValue }
    get { return self.gameData.maxValue }
  }

  var player: String {
    set { self.gameData.player = newValue }
    get { return self.gameData.player }
  }
}


struct CombineTestView: View {
  @EnvironmentObject var gameData: GameData
  @ObservedObject var problemSpace: ProblemSpace
  @ObservedObject var statsManager: StatsManager

  var body: some View {
    VStack {
      CombineTestPickerView(problemSpace: problemSpace,
                            statsManager: statsManager)
        .environmentObject(gameData)
      Spacer()
      Text("GameData Player is: \(gameData.player), Max is \(gameData.maxValue)")
        .font(.system(.title))
    }
  }
}

struct CombineTestPickerView: View {
  @EnvironmentObject var gameData: GameData
  @ObservedObject var problemSpace: ProblemSpace
  @ObservedObject var statsManager: StatsManager

  var body: some View {
    VStack{
      Picker(selection: $problemSpace.player,
             label: Text(verbatim: "Selected name: \(problemSpace.player)")) {
              ForEach(["Sam", "Gandalf", "Gollum"], id: \.self) { name in
                Text(name)
              }
      }
      Text("Picker ProblemSpace Player: \(problemSpace.player)")
      ShowStatsManager(statsManager: statsManager)
        .environmentObject(gameData)
    }
  }
}

struct ShowStatsManager: View {
  @EnvironmentObject var gameData: GameData
  @ObservedObject var statsManager: StatsManager

  var body: some View {
    VStack {
      Stepper("Set max: ", value: $statsManager.maxValue)
      Text("ShowStatsManager Player: \(statsManager.player), Max: \(statsManager.maxValue)")
    }
  }
}

One thing I've noticed that I am a bit perplexed by is I must pass the EnvironmentObject to all three Views, otherwise changes in the child views don't propagate back to the "source of truth" part of the model.

Do I have the right idea here? Is this the right way to use the SwiftUI API to solve this model consistency goal?

Thanks for your time and any thoughts!

You don't need to pass it multiple times, you can remove all environmentObject except for the top one. That's the whole point of environmentObject (one caveat is that some modifiers spawn a new hierarchy like navigation, pop-over, action sheet, sheet, and you need to re-inject the envObj).

What you need is @EnvironmentObject. When gd changes, all views that has @EnvironmentObject var ...: GameData will be invalidated and updated in no particular order. So views like ShowStatesManager can be updated multiple times (once from its own invalidation, another from its parent).

Same goes with ObservedObject.


I notice that all GameData, ProblemSpace, and StatsManager provides the same set of values. It'd be better to use only one class, probably GameData.

I generally prefer that ObservableObject don't share too much values. If they provides the same value, use the same class.

If ProblemSpace and StatsManager don't share the value, it's better to just split them into two classes (without shared GameData).


If there are other reasons that requires one ObservableObject to refer to another, like how ProblemSpace refers to GameData. You can do it, but you need to link them properly, which is a hassle, and I'd still suggest that you split them into multiple classes, strengthening the notion of single Source of Truth.

Notice that, ObservableObject has objectWillChange publisher, this is what SwiftUI is listening to. Most of the time @Published will do the job for you, though not in this case. What you need, is to make sure that when GameData (which is the real source of truth) fires objectWillChange, ProblemSpace and StatsManager also fire their own objectWillChanges.

You can do this by having them subscribing to the source-of-truth objectWillChange

class ProblemSpace: ObservableObject {
  var subscription: Any! // Need to keep this alive to keep listening
  init() {
    ... // Other setup

    // Not sure if there's a cleaner method, but this is more-or-less what you'll do.
    subscription = gameData.objectWillChange.sink {
      self.objectWillChange.send()
    }
  }
}

You can also add your custom logic to check if the data really change, and avoid firing unnecessary objectWillChange.


When designing ObservableObject, keep in mind that all views with @EnvironmentObject will be invalidated when it changes. If EnvironmentObject encompasses too many variables, a lot of views will be listening, making it costly to even change a single variable.

PS

I'd also suggest that you make EnvironmentObject and State private. You're not suppose to be able to access/initialize them from outside the View. It'd also help with auto-complete

1 Like

All in all, your views would look like this

struct CombineTestView: View {
    @EnvironmentObject private var gameData: GameData
    
    var body: some View {
        VStack {
            CombineTestPickerView()
            Spacer()
            Text("GameData Player is: \(gameData.player), Max is \(gameData.maxValue)")
                .font(.system(.title))
        }
    }
}

struct CombineTestPickerView: View {
    @EnvironmentObject private var gameData: GameData
    
    var body: some View {
        VStack{
            Picker(selection: $gameData.player,
                   label: Text(verbatim: "Selected name: \(gameData.player)")) {
                    ForEach(["Sam", "Gandalf", "Gollum"], id: \.self) { name in
                        Text(name)
                    }
            }
            Text("Picker ProblemSpace Player: \(gameData.player)")
            ShowStatsManager()
        }
    }
}

struct ShowStatsManager: View {
    @EnvironmentObject private var gameData: GameData
    
    var body: some View {
        VStack {
            Stepper("Set max: ", value: $gameData.maxValue)
            Text("ShowStatsManager Player: \(gameData.player), Max: \(gameData.maxValue)")
        }
    }
}

So that all three views share the same GameData source of truth. And you can get rid of ProblemSpace and StatsManager.

@Lantua, thank you very much for taking the time to write a thoughtful response. First, I should mention that I know the classes I presented above all share data in a redundant way in the fashion they are presented. The actual StatsManager and ProblemSpace classes are much, much larger with very different functionality. However, it is important they agree on a few pieces of state. I know how to accomplish this shared dependency in an imperative framework, but I am trying to understand how to share a bit of state across multiple classes in a declarative framework like SwiftUI. I tried to supply code someone could copy and paste into an Xcode project and reproduce everything important I'm seeing quickly.

What I am confused about, for example, is if I comment out the call to .environmentObject(gameData) on the invocation of ShowStatsManager inside the CombineTestPickerView's body method, and comment out the @EnvironmentObject line inside the ShowStatsManager struct, then the state between the three objects can become inconsistent. And oddly, if I tap the Stepper inside ShowStatsManager, it is statsManager that doesn't update, while gameData outside of the struct does update. (Although it does so in a buggy way - the Stepper buttons grey out after a few clicks in one direction or the other, and if you click in the opposite direction, they "come back" and it is possible to make a few more clicks in the original direction.)

I like your suggestion about investigating the objectWillChange method. I will do some reading about that! Thanks again for your time.

The trick here is that you can remove .environmentObject(gameData). It does not do anything to say that the views are listening. It supply gameData reference into the view, its children, its children's children, and so on. Since you already have .environmentObject(gameData) at the top-level (let contentView = ...), CombineTestView, and its children (CombineTestPickerView), and its grandchildren (ProblemSpace, and StatsManager) are already supplied with the instance of gameData that you want. That's why I said, aside from the top-level one (one at let contentView = ...), you can remove all other environmentData.

You need to keep @EnvironmentObject because it tells SwiftUI ShowStatsManager is listening on the change of GameData, and will be updated whenever GameData is changing.

PS

Forgot to mention, this is not really about Swift, but an Apple's specific framework SwiftUI, so it's better that you ask on Apple Development Forum instead.

There are two distinct problems here, so I'd suggest that you make a smaller experiment first, to see each part individually. First is how to tell SwiftUI that a view is listening to an ObservableObject changes. After all, they're not part of SwiftUI, unlike State.

You tell SwiftUI by using @ObservedObject or @EnvironmentObject. Given this smaller example:

class TestObservable: ObservableObject {
  @Published var value = 0.0
}

struct ListeningView: View {
  @ObservableObject var test: TestObservable
  var body: some View { Text("\(test.value)") }
}

struct NotListeningView: View {
  let test: TestObservable
  var body: some View { Text("\(test.value)") }
}

struct ContentView: View {
  @ObservedObject var test: TestObservable
  var body: some View {
    VStack {
      ListeningView(test: test)
      NotListeningView(test: test)
      Slider(value: $test.value)
    }
  }
}

Now, if you display ContentView, and move the slider around, you'll notice that NotListeningView is not updating when test.value changes. Because, NotListeningView is using @ObservedObject, telling SwiftUI to notify them when the value changes. The same goes with @EnvironmentObject.

The only difference between @ObservedObject and @EnvironmentObject is how you supply the class (in this case TestObservable). For @ObservedObject, you need to pass down the value manually (like ListeningView(test: test)). It can be tedious to do over and over again if you have a deep hierarchy.

So EnvironmentObject is introduced, so that you supply it with .environmentObject(gameData) at the outermost view. Any view inside with @EnvironmentObject will be able to grab the instance, and treat is as though you declare it with @ObservedObject. That's why I recommend that you make them private:

struct ListeningView {
  @EnvironmentObject var test: TestObservable
  ...
}

So that you can initialise them like this ListeningView() and supply from the outside views using .environmentObject(gameData). Now, SwiftUI distinguish between environment objects using the class type. It doesn't matter what you name the TestObservable inside ListeningView, it can be @EnvironmentObject var someSillyName: TestObservable for all we care. What's important is that it is of type TestObservable.

2 Likes

Ah, interesting, so you can cut the extra .environmentObject call. Thanks.

Also, apologies about going off-topic. I'll ask future SwiftUI questions in a more appropriate forum.

The second part is how ObservableObject notify SwiftUI. If you check the protocol definition, you'll see that it requires objectWillChange publisher, which can be any publisher, but is defaulted to ObservableObjectPublisher.

You generally don't write this yourself because the combination of ObservableObject and Publish cause the compiler to synthesise the necessary parts for you. Come the second example where we write objectWillChange ourselves (don't forget to import Combine for ObservableObjectPublisher):

class TestObservable: ObservableObject {
  var objectWillChange = ObservableObjectPublisher()
  var notified = 0.0 {
    willSet { objectWillChange.send() }
  }
  var unnotified = 0.0
}

struct ContentView: View {
  @ObservedObject var test: TestObservable
  var body: some View {
    VStack {
      Text("notified: \(test.notified, specifier: "%.2f"), unnotified: \(test.unnotified, specifier: "%.2f")")
      Slider(value: $test.notified)
      Slider(value: $test.unnotified)
    }
  }
}

the only difference is that you make notifiedValue fire the publisher whenever it's updating (note the name objectWillchange, that's why I put it in willSet, not didSet).
Now, the text changes when notified is updated, but not when unnotified is updated. This is because changing unnotified is not firing objectWillChange, so SwiftUI can not know that test is changing. Usually @Published translate to more-or-less notified, so you don't need to worry about anything.

1 Like

Before we put these together, let me stress again that if the classes share all the states, it's better to use the same class, and if the classes share some state, it's better to move those states into a distinct class if possible. It'd be best if you can make it so that GameData, ProblemSpace, and StatsManager are totally unrelated, and compute the interconnected value when needed for display.

With that said, you probably realise one thing. With your original code, ProblemSpace won't trigger objectWillChange, because, well, there's no Published, and @Published var gameData won't help, because it's the gameData's properties that's changing, not the reference. Whenever you change the value, only gameData.objectWillChange will be triggered. Currently, every view is listening to every object, it's probably a little overkill. If you use only ProblemSpace, you probably only want to listen to that. But the problem is that ProblemSpace is not firing objectWillChange when it's changing value. What you can do, is to have ProblemSpace listen to gameData.objectWillChange, and fire its own objectWillChange as needed. For that, I refer back to my original reply:

1 Like

@Latuna Thank you for taking the time to write such a detailed set of responses. I learned some very useful things and I've changed how I used @EnvironmentObject.

One thing about my original code though - StatsManager and ProblemSpace aren't @Published, but if I change one of their properties, because they are just referencing GameData, and that is in the @Environment, everything does update across the hierarchy. You seem to be suggesting that they shouldn't and that I should need to subscribe to GameData and then re-publish within StatsManager and ProblemSpace. I'll definitely watch in the future to see if I need to do that.

Again, thanks so much for your excellent responses!

Hmm, it is indeed different from how I remember Published. I ran a few quick tests and noticed that SwiftUI does detect change in a few more places.

Originally I said the this scenario will fire objectWillChange:

  • If Published object mutates (mutate on value-types, replaced with a new instance on reference-type).

After the test I also found this scenario:

  • If you set values via binding exposed by $ ($value.subvalue...) and subvalue is Published.

There's also the rule about which among the children will be updated when a view is invalidated. If you put Text("\(Int.random(in: 0...100))"), you'll see that SwiftUI is smart enough to skip some unnecessary updates. But that's probably story for another time.