2

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
    }
}
Guillaume L.
  • 979
  • 8
  • 15
  • Isn’t the problem that `.assign` is not followed by `store`? Without that, the pipeline is torn down immediately after it is constructed. To that extent, this is a duplicate of https://stackoverflow.com/questions/60241335/somehow-combine-with-search-controller-not-working-any-idea and many others. – matt Mar 13 '20 at 12:34

3 Answers3

2

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()
    }
}
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • Thank you very much for your answer. Storing the subscriber fixes indeed the problem of the update not being received. But when the view is printed, we can see that the text in the `Text` is still `test` and not `init` or `Hello world` which means that the `assign` is still not called or used properly ? – Guillaume L. Mar 13 '20 at 13:55
  • @GuillaumeL., dynamic properties (ie. @State) works only in view hierarchy, probably I were not explicit with this. – Asperi Mar 13 '20 at 14:20
  • I tried to put this code in a sample app with just a view containing a label and I have the same issue. So I added more debug code: a `print` in the `didSet` of the `viewModel`. I thought it would help me but now I am even more confused. It seems that the `viewModel` is updated because when I set a new value in the `ReactiveContainer`, `compactMap` is called with the correct value, the `print` in the `didSet` is also called, BUT, when I print the `viewModel` in the `didSet`, the value is always `test` which means the `viewModel` is never updated and of course the `Text` is not update either. – Guillaume L. Mar 13 '20 at 14:52
  • @GuillaumeL., `didSet` does not work for `@State`, because it is property wrapper with external storage. I recommend to use approach based on `ObservableObject` for view model. `@State` is designed only for view-internal temporary states. – Asperi Mar 13 '20 at 15:12
  • I tested the `didSet` on a `@State` in a working single view project and it is working as expected: the correct value is printed, so this is not the issue. Also I tried to print the `newValue` that you get in `willSet` and this one is correct. – Guillaume L. Mar 13 '20 at 15:58
  • So, after watching `Data Flow Through SwiftUI - WWDC 2019` again and reading again your post, I implemented the `onReceive` solution and it's now working. Thx ! – Guillaume L. Mar 19 '20 at 17:55
0

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

naz
  • 1,478
  • 2
  • 21
  • 32
0

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.

malhal
  • 26,330
  • 7
  • 115
  • 133