24

I would like to use Combine's @Published attribute to respond to changes in a property, but it seems that it signals before the change to the property has taken place, like a willSet observer. The following code:

import Combine

class A {
    @Published var foo = false
}

let a = A()
let fooSink = a.$foo.dropFirst().sink { _ in // `dropFirst()` is to ignore the initial value
    print("foo is now \(a.foo)")
}

a.foo = true

outputs:

foo is now false

I'd like the sink to run after the property has changed like a didSet observer so that foo would be true at that point. Is there an alternative publisher that signals then, or a way of making @Published work like that?

Jon Colverson
  • 2,986
  • 1
  • 24
  • 24

5 Answers5

13

There is a thread on the Swift forums for this issue. Reasons of why they made the decision to fire signals on "willSet" and not "didSet" explained by Tony_Parker

We (and SwiftUI) chose willChange because it has some advantages over didChange:

  • It enables snapshotting the state of the object (since you have access to both the old and new value, via the current value of the property and the value you receive). This is important for SwiftUI's performance, but has other applications.
  • "will" notifications are easier to coalesce at a low level, because you can skip further notifications until some other event (e.g., a run loop spin). Combine makes this coalescing straightforward with operators like removeDuplicates, although I do think we need a few more grouping operators to help with things like run loop integration.
  • It's easier to make the mistake of getting a half-modified object with did, because one change is finished but another may not be done yet.

I do not intuitively understand that I'm getting willSend event instead of didSet, when I receive a value. It does not seem like a convenient solution for me. For example, what do you do, when in ViewController you receiving a "new items event" from ViewModel, and should reload your table/collection? In table view's numberOfRowsInSection and cellForRowAt methods you can't access new items with self.viewModel.item[x] because it's not set yet. In this case, you have to create a redundant state variable just for the caching of the new values within receiveValue: block.

Maybe it's good for SwiftUI inner mechanisms, but IMHO, not so obvious and convenient for other usecases.

User clayellis in the thread above proposed solution which I'm using:

Publisher+didSet.swift

extension Published.Publisher {
    var didSet: AnyPublisher<Value, Never> {
        self.receive(on: RunLoop.main).eraseToAnyPublisher()
    }
}

Now I can use it like this and get didSet value:

    self.viewModel.$items.didSet.sink { [weak self] (models) in
        self?.updateData()
    }.store(in: &self.subscriptions)

I'm not sure if it is stable for future Combine updates, though.

UPD: Worth to mention that it can possibly cause bugs (races) if you set value from a different thread than the main.

Original topic link: https://forums.swift.org/t/is-this-a-bug-in-published/31292/37?page=2

surfrider
  • 1,376
  • 14
  • 29
11

You can write your own custom property wrapper:

import Combine


@propertyWrapper
class DidSet<Value> {
    private var val: Value
    private let subject: CurrentValueSubject<Value, Never>

    init(wrappedValue value: Value) {
        val = value
        subject = CurrentValueSubject(value)
        wrappedValue = value
    }

    var wrappedValue: Value {
        set {
            val = newValue
            subject.send(val)
        }
        get { val }
    }

    public var projectedValue: CurrentValueSubject<Value, Never> {
      get { subject }
    }
}
Adam Różyński
  • 451
  • 3
  • 10
  • Does this really need to use a `CurrentValueSubject`? It looks to me like the CurrentValueSubject will always have the same value as the `wrappedValue` property. Why not use `PassthroughSubject`, just like the `objectWillChange`? – Peter Schorn Sep 14 '20 at 01:31
  • Both are fine. CurrentValueSubject is a little bit more universal as a generic solution. – Adam Różyński Sep 15 '20 at 10:59
8

Further to Eluss's good explanation, I'll add some code that works. You need to create your own PassthroughSubject to make a publisher, and use the property observer didSet to send changes after the change has taken place.

import Combine

class A {
    public var fooDidChange = PassthroughSubject<Void, Never>()

    var foo = false { didSet { fooDidChange.send() } }
}

let a = A()
let fooSink = a.fooDidChange.sink { _ in
    print("foo is now \(a.foo)")
}

a.foo = true
mxt533
  • 225
  • 2
  • 6
  • Thank you, that’s exactly what I ended up doing. Presumably it would possible to encapsulate that pattern in a custom property wrapper (perhaps using a custom publisher, but perhaps it could be done using a PassthroughSubject). – Jon Colverson Oct 18 '19 at 13:58
  • Great, I understand that and have used instead of @Publish – DavidS Aug 13 '21 at 14:12
7

Before the introduction of ObservableObject SwiftUI used to work the way that you specify - it would notify you after the change has been made. The change to willChange was made intentionally and is probably caused by some optimizations, so using ObservableObjsect with @Published will always notify you before the changed by design. Of course you could decide not to use the @Published property wrapper and implement the notifications yourself in a didChange callback and send them via objectWillChange property, but this would be against the convention and might cause issues with updating views. (https://developer.apple.com/documentation/combine/observableobject/3362556-objectwillchange) and it's done automatically when used with @Published. If you need the sink for something else than ui updates, then I would implement another publisher and not go agains the ObservableObject convention.

Eluss
  • 512
  • 3
  • 5
3

Another alternative is to just use a CurrentValueSubject instead of a member variable with the @Published attribute. So for example, the following:

@Published public var foo: Int = 10 

would become:

public let foo: CurrentValueSubject<Int, Never> = CurrentValueSubject(10)

This obviously has some disadvantages, not least of which is that you need to access the value as object.foo.value instead of just object.foo. It does give you the behavior you're looking for, however.

Chris Vig
  • 8,552
  • 2
  • 27
  • 35