Search code examples
iosswiftswiftuicombinepublisher

@Published and .assign not reacting to value update


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
    }
}

Solution

  • 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()
        }
    }