@Published and .assign not reacting to value update

Viewed 2579

SwiftUI and Combine noob here, I isolated in a playground the problem I am having. Here is the playground.

final class ReactiveContainer<T: Equatable> {
    @Published var containedValue: T?
}

class AppContainer {
    static let shared = AppContainer()

    let text = ReactiveContainer<String>()
}

struct TestSwiftUIView: View {

    @State private var viewModel = "test"

    var body: some View {
        Text("\(viewModel)")
    }

    init(textContainer: ReactiveContainer<String>) {

        textContainer.$containedValue.compactMap {
            print("compact map \($0)")
            return $0
        }.assign(to: \.viewModel, on: self)
    }
}

AppContainer.shared.text.containedValue = "init"


var testView = TestSwiftUIView(textContainer: AppContainer.shared.text)
print(testView)

print("Executing network request")
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
    AppContainer.shared.text.containedValue = "Hello world"
    print(testView)
}

When I run the playground this is what's happening:

compact map Optional("init")
TestSwiftUIView(_viewModel: SwiftUI.State<Swift.String>(_value: "test", _location: nil))
Executing network request
TestSwiftUIView(_viewModel: SwiftUI.State<Swift.String>(_value: "test", _location: nil))

So as you can see, two problems there:

  • The compact map closure is only called once, on subscription but not when the dispatch is ran

  • The assign operator is never called

I have been trying to solve this these past few hours without any success. Maybe someone with a top knowledge in SwiftUI/Combine could help me, thx !

EDIT

Here is the working solution:

struct ContentView: View {

    @State private var viewModel = "test"
    let textContainer: ReactiveContainer<String>

    var body: some View {
        Text(viewModel).onReceive(textContainer.$containedValue) { (newContainedValue) in
            self.viewModel = newContainedValue ?? ""
        }
    }

    init(textContainer: ReactiveContainer<String>) {
        self.textContainer = textContainer
    }
}
3 Answers

I would prefer to use ObservableObject/ObservedObject pattern, right below, but other variants also possible (as provided further)

All tested with Xcode 11.2 / iOS 13.2

final class ReactiveContainer<T: Equatable>: ObservableObject {
    @Published var containedValue: T?
}

struct TestSwiftUIView: View {

    @ObservedObject var vm: ReactiveContainer<String>

    var body: some View {
        Text("\(vm.containedValue ?? "<none>")")
    }

    init(textContainer: ReactiveContainer<String>) {
        self._vm = ObservedObject(initialValue: textContainer)
    }
}

Alternates:

The following fixes your case (if you don't store subscriber the publisher is canceled immediately)

private var subscriber: AnyCancellable?
init(textContainer: ReactiveContainer<String>) {

    subscriber = textContainer.$containedValue.compactMap {
        print("compact map \($0)")
        return $0
    }.assign(to: \.viewModel, on: self)
}

Please note, view's state is linked only being in view hierarchy, in Playground like you did it holds only initial value.

Another possible approach, that fits better for SwiftUI hierarchy is

struct TestSwiftUIView: View {

    @State private var viewModel: String = "test"

    var body: some View {
        Text("\(viewModel)")
            .onReceive(publisher) { value in
                self.viewModel = value
            }
    }

    let publisher: AnyPublisher<String, Never>
    init(textContainer: ReactiveContainer<String>) {

        publisher = textContainer.$containedValue.compactMap {
            print("compact map \($0)")
            return $0
        }.eraseToAnyPublisher()
    }
}

I would save a reference to AppContainer.

struct TestSwiftUIView: View {

    @State private var viewModel = "test"

    ///I just added this
    var textContainer: AnyCancellable?

    var body: some View {
        Text("\(viewModel)")
    }

    init(textContainer: ReactiveContainer<String>) {

        self.textContainer = textContainer.$containedValue.compactMap {
            print("compact map \(String(describing: $0))")
            return $0
        }.assign(to: \.viewModel, on: self)
    }
}

compact map Optional("init")
TestSwiftUIView(_viewModel: SwiftUI.State<Swift.String>(_value: "test", _location: nil), textContainer: Optional(Combine.AnyCancellable))
Executing network request
compact map Optional("Hello")
TestSwiftUIView(_viewModel: SwiftUI.State<Swift.String>(_value: "test", _location: nil), textContainer: Optional(Combine.AnyCancellable))

We don't use Combine for moving data between Views, SwiftUI already has built-in support for this. The main problem is you are treating the TestSwiftUIView as if it is a class but it is a struct, i.e. a value. It's best to think of the View simply as the data to be displayed. SwiftUI creates these data structs over and over again when data changes. So the solution is simply:

struct ContentView: View {

    let text: String

    var body: some View { // only called if text is different from last time ContentView was created in a parent View's body.
        Text(text)
    }
}

The parent body method can call ContentView(text:"Test") over and over again but the ContentView body method is only called by SwiftUI when the let text is different from last time, e.g. ContentView(text:"Test2"). I think this is what you tried to recreate with Combine but it is unnecessary because SwiftUI already does it.

Related