2

This is a slightly more abstract version of this question. In the app, these nested Observable Objects are indeed used in a view (so I'd rather use Observable Objects rather than straight Publishers). However, I would like to be able to simply subscribe to the view models in order to test them. The protocol is there so I can mock out Nested in tests.

This is the basic setup:

protocol NestedProtocol: AnyObject {
  var string: String { get set }
}
class Nested: ObservableObject, NestedProtocol {
  @Published var string = ""
}
class Parent: ObservableObject {
  @Published var nested: NestedProtocol
  init(nested: NestedProtocol) {
    self.nested = nested
  }
}

var sinkHole = Set<AnyCancellable>()
let nested = Nested()
let parent = Parent(nested: nested)

parent.$nested.sink { newValue in
  print("NEW VALUE \(newValue.string)")
}.store(in: &sinkHole)

Then this command nested.string = "foo1" outputs "NEW VALUE ", which is expected as the initial value of nested. I would like it to output "NEW VALUE foo1". (TIL published variables seem to be current value publishers.)

  1. Of course I could do
nested.string = "foo1"
parent.nested = nested

and I would get "NEW VALUE foo1", but that's smelly.

  1. I tried
protocol NestedProtocol: ObservableObject {
  var string: String { get set }
}
class Nested<T>: ObservableObject where T: NestedProtocol {
...

But in real life, I would like nested to declare some static constants, which is not allowed in generic types. So that doesn't work.

  1. From the cited question/answer, I also tried combinations of
Parent
init() {
  nested.objectWillChange.sink { [weak self] (_) in
    self?.objectWillChange.send()
  }.store(in: sinkHole)
}

Nested
init() {
  string.sink { [weak self] (_) in
    self?.objectWillChange.send()
  }.store(in: sinkHole)
}

No dice. Those methods were getting called but that outer-level sink was still just returning "NEW VALUE "

  1. I also tried calling
parent.nested.string = "foo1"

So now I'm modifying the parent, and that should work, right? Wrong.

AlexMath
  • 567
  • 1
  • 6
  • 16
  • It's unclear to me what you want to achieve in this question, and where it fails you. Do you want/expect to output `"foo1"` when doing `parent.nested.string = "foo1"`? – New Dev Nov 04 '20 at 18:25
  • That's right. I want to be able to listen to changes on parent.nested. I edited the question a bit to try and clarify the intent. – AlexMath Nov 04 '20 at 18:39
  • Sure.. But then there are few erroneous assumptions here. When you say "this command `nested.string = "foo1"` outputs "NEW VALUE " - that's not what happens. The output is due to the init of `Parent(nested: nested)` - not due to assignment of the `.string` property. This is expected because you're subscribing to `$nested` - so it emits the first value of `nested`. Also unclear is why do you have a protocol here. – New Dev Nov 04 '20 at 19:50
  • No erroneous assumptions, just misleading descriptions. Edited it. The protocol is there so I can mock out Nested in tests. – AlexMath Nov 04 '20 at 20:36
  • This blog suggests "nested ObservableObjects might be code smell, or worst". https://rhonabwy.com/2021/02/13/nested-observable-objects-in-swiftui/?utm_campaign=iOS%2BDev%2BWeekly&utm_medium=email&utm_source=iOS%2BDev%2BWeekly%2BIssue%2B495 TLDR: create subviews that have the nested object as a direct view model. – AlexMath Feb 19 '21 at 21:23

2 Answers2

2

There's a bunch to unpack here.

First, you might know that if a property is a value-type, like a struct or String, then marking it as @Published just works:

class Outer {
   @Published var str: String = "default"
}

let outer = Outer()
outer.$str.sink { print($0) }
outer.str = "changed"

Will output:

default
changed

Your question, however, is about a nested observable object, which is a reference type. So, the above wouldn't work with a reference-type.

But in your example you're using a protocol as an existential (i.e. in place of an eventual instance), and as you noted, without inheriting from AnyObject, then it really behaves like a value-type:

protocol InnerProtocol {
   var str: String { get set }
}
class Inner: InnerProtocol {
   @Published var str: String = "default"
}
class Outer {
   @Published var inner: InnerProtocol
   init(_ inner: InnerProtocol) { self.inner = inner }
}

let inner = Inner()
let outer = Outer(inner)
outer.$inner.sink { print($0.str) }
outer.inner.str = "changed"

This would also output:

default
changed

which looks like what you wanted, but in fact it doesn't really "observe" any changes in the nested object. When you do outer.inner.str, it has value-type semantics, so it's as-if you re-assigned the .inner property. But if you are truly interested in observing changes of the object itself, then this approach wouldn't work at all. For example:

nested.str = "inner changed"

would not cause an output. Neither would there be an output if the inner object changed its own property, e.g.:

init() {
   DisplatchQueue.main.asyncAfter(.now() + 1) {
      self.str = "async changed"
   }
}

So, it's unclear what exactly you're trying to achieve. If you want to observe a reference type property, you'd need to observe it directly.

class Inner: ObservableObject {
   @Published var str: String
   //...
}
class Outer: ObservableObject {
   var inner: Inner
   //...
}
//...
outer.inner.$str.sink { ... }
// or
outer.inner.objectWillChange.sink { ... }

You can achieve this with a protocol too, if you insist:

protocol InnerProtocol: ObservableObject {
   var str: String { get set }
}
class Inner: InnerProtocol {
   @Published var str: String = "default"
}
class Outer<T: InnerProtocol>: ObservableObject {
   var inner: T
   init(_ inner: T) { self.inner = inner }
}

let inner = Inner()
let outer = Outer(inner)
outer.inner.$str.sink { ... }
inner.str = "changed"
New Dev
  • 48,427
  • 12
  • 87
  • 129
  • Thanks for the time and effort you put in my question. I think my question was unclear because I tried to abstract my use-case. My problem probably stems from my architecture not being adequate. In my app, I bind a string to a textfield. This string belongs to an object that gets passed around between view models. This works fine using ObservedObject, but I wanted to write tests for my view model. This is also why I needed the protocol. As I stated in the question, your proposed protocol doesn't work since the inner object has stored properties, which is incompatible with generics. – AlexMath Nov 06 '20 at 16:52
  • In what way is having stored properties incompatible with generic? But my bigger point is what you did doesn't actually observe any changes. And if you want to observe the changes, you'd need to observe them by subscribing to `objectWillChange` (which is what `@ObservedObject` is doing) or the individual `@Published` property publishers – New Dev Nov 06 '20 at 17:20
  • I get this error when adding a static stored property to Outer: "Static stored properties not supported in generic types" How does what I did not observe changes? I think we might be splitting hairs, here, but as long as my closure gets called, I'm happy. As the question states, I did try to subscribe to `objectWillChange`. I couldn't get it to work. – AlexMath Nov 06 '20 at 18:12
  • I explained in my answer what changes you won't detect – New Dev Nov 06 '20 at 18:59
0

This took me hours and I stumbled upon it by accident while trying to modify my protocol in various ways.

Lesson 1:

protocol NestedProtocol: AnyObject {
  var string: String { get set }
}

should be

protocol NestedProtocol {
  var string: String { get set }
}

Why? I'm not sure. Apparently, if the Parent cannot assume that the published object is a class, then it watches modifications on it more closely? My instinct tells me the exact opposite, but it goes to show how much I can trust my instinct.

Lesson 2: Indeed my 4th idea was correct and you need to name the parent in the nested object modification:

nested.string = "foo1"

should be

parent.nested.string = "foo1"

Again, they're all classes so it goes slightly against my understanding, but I don't know all the magic that goes on under @Published.

The final complete version looks like this:

protocol NestedProtocol {
  var string: String { get set }
}
class Nested: ObservableObject, NestedProtocol {
  @Published var string = ""
}
class Parent: ObservableObject {
  @Published var nested: NestedProtocol
  init(nested: NestedProtocol) {
    self.nested = nested
  }
}

var sinkHole = Set<AnyCancellable>()
let nested = Nested()
let parent = Parent(nested: nested)

parent.$nested.sink { newValue in
  print("NEW VALUE \(newValue.string)")
}.store(in: &sinkHole)

and

nested.string = "foo1"
parent.nested.string = "foo2"

returns "NEW VALUE " "NEW VALUE foo2"

AlexMath
  • 567
  • 1
  • 6
  • 16