6

I'd like to extend ObservableObject behavior in SwiftUI to nested classes, and I'm looking for the proper way to do it. It can be done "manually" with Combine, but I imagine there's a much cleaner way to do it using SwiftUI, and I'm hoping you can point me in the right direction. Here's what I mean…

Below is a typical application of ObservableObject to make a View dynamically respond to changes to a reference type. Tapping the button toggles the showText value, which makes the text appear/disappear on the screen:

import SwiftUI

class MyClass: ObservableObject {
    @Published var showText = false
}


struct ContentView: View {

    @ObservedObject var instance = MyClass()

    var body: some View {
        VStack(spacing: 10) {
            Button(action: {
                print(self.instance.showText)
                self.instance.showText.toggle()
            }) {
                Text("BUTTON").bold().padding()
                    .foregroundColor(.white)
                    .background(Color.red)
            }
            if instance.showText {
                Text("Hello, World!")
            }
        }
    }
}

This works fine.

But what about the modification below, where the class containing showText is an InnerClass, itself contained in an OuterClass? The button toggles showText just fine, but the notification of the value change no longer propagates through the OuterClass instance to the View, so the View no longer displays the Text at all.

import SwiftUI

class OuterClass: ObservableObject {
    @Published var innerInstance = InnerClass()
}

class InnerClass: ObservableObject {
    @Published var showText = false
}

struct ContentView: View {

    @ObservedObject var outerInstance = OuterClass()

    var body: some View {
        VStack(spacing: 10) {
            Button(action: {
                self.outerInstance.innerInstance.showText.toggle()
            }) {
                Text("BUTTON").bold().padding()
                    .foregroundColor(.white)
                    .background(Color.red)
            }
            if outerInstance.innerInstance.showText {
                Text("Hello, World!")
            }
        }
    }
}

What is the elegant fix for this?

Anton
  • 2,512
  • 2
  • 20
  • 36

2 Answers2

3

Call the publisher explicitly and you'll get notified:

struct ContentView: View {   
  @ObservedObject var outerInstance = OuterClass()
  var body: some View {
    VStack(spacing: 10) {
      Button(action: {
        self.outerInstance.innerInstance.showText.toggle()
        // Call the publisher
        self.outerInstance.objectWillChange.send()
      }) {
        Text("BUTTON").bold().padding()
          .foregroundColor(.white)
          .background(Color.red)
      }
      if outerInstance.innerInstance.showText {
        Text("Hello, World!")
      }
    }
  }
}
nine stones
  • 3,264
  • 1
  • 24
  • 36
  • Thank you, @nine-stones - that certainly works! Though I'd love it if there were a Swifty way to set up the classes themselves (or their properties) so that the change messages propagated without having to be manually send them whenever needed. – Anton Feb 08 '20 at 01:49
  • 1
    Agreed. same applies to modifying array contents of an observable object or published array ivar. – nine stones Feb 08 '20 at 01:59
  • 1
    it could be contra-productive. innerInstance is reference and in your model it should be most likely constant. so why to use @Published property wrapper for it? See my answer ... – user3441734 Feb 08 '20 at 13:02
  • @user3441734 I agree that in my second example, strictly speaking there is no sense in putting a Published wrapper around the innerInstance, since it's a reference type. But I wrote that example not to actually function (I knew it wouldn't) but rather to show intent, and my intent was for changes in innerInstance properties to flag change in the outerInstance, so I added that in a pseudo-code spirit. Please ignore if it bugs you. :) – Anton Feb 09 '20 at 16:36
3

it could be done in your model

import Combine // required for AnyCancelable

class OuterClass: ObservableObject {
    private let _inner: InnerClass
    var innerInstance: InnerClass {
        return _inner
    }
    var store = Set<AnyCancellable>()
    init(_ inner: InnerClass) {
        _inner = inner
        inner.$showText.sink { [weak self] _ in
            self?.objectWillChange.send()
        }.store(in: &store)
    }
}

and how to use in in your example

import SwiftUI
import Combine

class OuterClass: ObservableObject {
    private let _inner: InnerClass
    var innerInstance: InnerClass {
        return _inner
    }
    var store = Set<AnyCancellable>()
    init(_ inner: InnerClass) {
        _inner = inner
        inner.$showText.sink { [weak self] _ in
            self?.objectWillChange.send()
        }.store(in: &store)
    }
}

class InnerClass: ObservableObject {
    @Published var showText = false
}

let inner = InnerClass()
let outer = OuterClass(inner)

struct ContentView: View {

    @ObservedObject var outerInstance = outer

    var body: some View {
        VStack(spacing: 10) {
            Button(action: {
                self.outerInstance.innerInstance.showText.toggle()
            }) {
                Text("BUTTON").bold().padding()
                    .foregroundColor(.white)
                    .background(Color.red)
            }
            if outerInstance.innerInstance.showText {
                Text("Hello, World!")
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

if you like to observe any change in your inner object, just do it!

class OuterClass: ObservableObject {
    private let _inner: InnerClass
    var innerInstance: InnerClass {
        return _inner
    }
    var store = Set<AnyCancellable>()
    init(_ inner: InnerClass) {
        _inner = inner
        inner.objectWillChange.sink { [weak self] _ in
            self?.objectWillChange.send()
        }.store(in: &store)
    }
}

UPDATE: based on the discussion below

class OuterClass: Combine.ObservableObject {
    private let _inner: InnerClass
    var innerInstance: InnerClass {
        return _inner
    }
    var store = Set<AnyCancellable>()
    init(_ inner: InnerClass) {
        _inner = inner
        inner.objectWillChange.sink { [weak self] _ in
            self?.objectWillChange.send()
        }.store(in: &store)
    }
}
user3441734
  • 16,722
  • 2
  • 40
  • 59
  • While surely a nice answer, the OP explicitly asked to do it w/o Combine. – nine stones Feb 08 '20 at 15:57
  • 1
    @ninestones w/o Combine? all publishers are defined there, ObservableObject is part of it ... import Combine is only because AnyCancelable – user3441734 Feb 08 '20 at 16:01
  • rest assured I know that ObservableObject uses Combine to achieve its means (still, technically ObservableObject is defined in SwiftUI, which in turn imports Combine). I am just quoting the OP who stated they knew how to do it using combine directly, seeking a simple way to update their state. And I personally - depending on the complexity of the case - would probably often choose the path suggested by you. – nine stones Feb 08 '20 at 16:21
  • 2
    observableObject is part of Combine, ObservedObject is part of SwiftUI. That are unimportant details (wrong SDK headers), your answer seems for me OK. I just don't see what is the idea to use @Published wrapper on reference type :-). It is really redundant and that makes a lot of confusion, how it works "in the background" – user3441734 Feb 08 '20 at 16:28