Observe change on a @Published var in Swift Combine after didSet?
Asked Answered
B

3

7

Let's say that we have a following code written in Swift that uses Combine:

import UIKit
import Combine

class Test {
  @Published var array: [Int] = [] {
    willSet {
      print("willSet \(newValue.count)")
    }
    didSet {
      print("didSet \(array.count)")
    }
  }
}

var test = Test()
var subscriber = test.$array.sink { values in
  print("arrayCount: \(test.array.count) valuesCount: \(values.count)")
}

print("1 arrayCount \(test.array.count)")
test.array = [1, 2, 3]
print("2 arrayCount \(test.array.count)")
test.array = [1]
print("3 arrayCount \(test.array.count)")

This code prints following result on the console (it can be quickly tested in playground):

arrayCount: 0 valuesCount: 0
1 arrayCount 0
willSet 3
arrayCount: 0 valuesCount: 3
didSet 3
2 arrayCount 3
willSet 1
arrayCount: 3 valuesCount: 1
didSet 1
3 arrayCount 1

As we can see the code given to sink method is executed after willSet and before didSet of given property. Now my question is: is there any way to create this publisher or subscribe to it in such way that the code given to sink is executed after didSet and not before it (so that arrayCount and valuesCount would be the same when print from sink is executed in above example)?

Bitterling answered 15/11, 2021 at 16:51 Comment(1)
An @Published property calls send in willSet. You would need to create your own publisher and call send in didSet yourself instead of using @Published.Llano
C
7

Published.Publisher uses willSet to emit values for the wrapped property. Unfortunately you cannot change this behaviour, the only solution is to implement your own property wrapper that uses didSet instead of willSet.

You can also customise whether you want to receive the current value when subscribing to the projectValue publisher (matching the @Published behaviour), using the emitCurrentValue input argument. If it is set to true, the current wrappedValue is sent to new subscribers on subscription.

/// A type that publishes changes about its `wrappedValue` property _after_ the property has changed (using `didSet` semantics).
/// Reimplementation of `Combine.Published`, which uses `willSet` semantics.
@available(iOS 13, *)
@propertyWrapper
public class PostPublished<Value> {
  /// A `Publisher` that emits the new value of `wrappedValue` _after it was_ mutated (using `didSet` semantics).
  public let projectedValue: AnyPublisher<Value, Never>
  /// A `Publisher` that fires whenever `wrappedValue` _was_ mutated. To access the new value of `wrappedValue`, access `wrappedValue` directly, this `Publisher` only signals a change, it doesn't contain the changed value.
  public let valueDidChange: AnyPublisher<Void, Never>
  private let didChangeSubject: any Subject<Value, Never>
  public var wrappedValue: Value {
    didSet {
      didChangeSubject.send(wrappedValue)
    }
  }

  /// - parameter emitCurrentValue: whether to emit the current wrapped value when subscribing to `projectValue`
  public init(wrappedValue: Value, emitCurrentValue: Bool = false) {
    self.wrappedValue = wrappedValue
    let didChangeSubject: any Subject<Value, Never>
    if emitCurrentValue {
      didChangeSubject = CurrentValueSubject(wrappedValue)
    } else {
      didChangeSubject = PassthroughSubject<Value, Never>()
    }
    self.didChangeSubject = didChangeSubject
    self.projectedValue = didChangeSubject.eraseToAnyPublisher()
    self.valueDidChange = didChangeSubject.voidPublisher()
  }
}

public extension Publisher {
  /// Maps the `Output` of its upstream to `Void` and type erases its upstream to `AnyPublisher`.
  func voidPublisher() -> AnyPublisher<Void, Failure> {
    map { _ in Void() }
      .eraseToAnyPublisher()
  }
}

You can observe a @PostPublished the same way you do a @Published.

class UsingPostPublished {
  @PostPublished var dontEmitInitial = 0
  @PostPublished(emitCurrentValue: true) var emitInitial = 0
}

private var cancellables = Set<AnyCancellable>()

let usingPostPublished = UsingPostPublished()
usingPostPublished.$dontEmitInitial.sink {
  print("dontEmitInitial did change to \($0)")
}.store(in: &cancellables)

usingPostPublished.$emitInitial.sink {
  print("emitInitial did change to \($0)")
}.store(in: &cancellables)

usingPostPublished.emitInitial = 1
usingPostPublished.dontEmitInitial = 1
Centenary answered 16/11, 2021 at 10:29 Comment(7)
Thank you. This seems like a surprising design decision. Anyone know of discussion ?Soviet
@orionelenzil since SwiftUI relies on ObservableObject's objectWillChange, which emits whenever any @Published properties emit, @Published using willSet rather than didSet enables SwiftUI to prepare for view updatesCentenary
Doesn't make sense that @publish uses will set, if that is the case, a swiftUI element say Text will no update once it receives the willset signal from the publisher as the published property is not updated during willset.Montagnard
If SwiftUI is observing a @Published value, willSet causing the view to refresh, it will see the old value, not the didSet value. Either that or the documentation isn't very clear on that part.Montagnard
@Montagnard as I've said - you're assuming this, but have you actually tried running the code and checking your assumptions? I have actually tested my arguments and can 100% confirm based on actual testing that @Published uses willSet. As I've said in comments, objectWillChange is used to prepare the View to update - it doesn't necessarily mean that the view immediately updates.Centenary
One important note about your implementation of @PostPublished is that it behaves a bit different from @Published (apart from firing on didset), as it doesn't seem to publish the current value when subscribed to. This can be achieved by changing this line: self.projectedValue = didChangeSubject.prepend(wrappedValue).eraseToAnyPublisher()Waltman
@MikkelSelsøe if you always want that behaviour, simply replace the PassthroughSubject with a CurrentValueSubject, no need to prepend any values. I've also updated my answer with a version of the code where this behaviour is customisable.Centenary
D
9

Well I'll say use a simple hack

var test = Test()
var subscriber = test.$array
.receive(on: DispatchQueue.main)
.sink { values in
print("arrayCount: \(test.array.count) valuesCount: \(values.count)")
}
Dhow answered 3/9, 2022 at 12:6 Comment(2)
Specifying the dispatch queue postpones the sink until after the observed value is set. Why is this answer downgraded?Redshank
Yes, why this answer is so downgraded?Gudgeon
C
7

Published.Publisher uses willSet to emit values for the wrapped property. Unfortunately you cannot change this behaviour, the only solution is to implement your own property wrapper that uses didSet instead of willSet.

You can also customise whether you want to receive the current value when subscribing to the projectValue publisher (matching the @Published behaviour), using the emitCurrentValue input argument. If it is set to true, the current wrappedValue is sent to new subscribers on subscription.

/// A type that publishes changes about its `wrappedValue` property _after_ the property has changed (using `didSet` semantics).
/// Reimplementation of `Combine.Published`, which uses `willSet` semantics.
@available(iOS 13, *)
@propertyWrapper
public class PostPublished<Value> {
  /// A `Publisher` that emits the new value of `wrappedValue` _after it was_ mutated (using `didSet` semantics).
  public let projectedValue: AnyPublisher<Value, Never>
  /// A `Publisher` that fires whenever `wrappedValue` _was_ mutated. To access the new value of `wrappedValue`, access `wrappedValue` directly, this `Publisher` only signals a change, it doesn't contain the changed value.
  public let valueDidChange: AnyPublisher<Void, Never>
  private let didChangeSubject: any Subject<Value, Never>
  public var wrappedValue: Value {
    didSet {
      didChangeSubject.send(wrappedValue)
    }
  }

  /// - parameter emitCurrentValue: whether to emit the current wrapped value when subscribing to `projectValue`
  public init(wrappedValue: Value, emitCurrentValue: Bool = false) {
    self.wrappedValue = wrappedValue
    let didChangeSubject: any Subject<Value, Never>
    if emitCurrentValue {
      didChangeSubject = CurrentValueSubject(wrappedValue)
    } else {
      didChangeSubject = PassthroughSubject<Value, Never>()
    }
    self.didChangeSubject = didChangeSubject
    self.projectedValue = didChangeSubject.eraseToAnyPublisher()
    self.valueDidChange = didChangeSubject.voidPublisher()
  }
}

public extension Publisher {
  /// Maps the `Output` of its upstream to `Void` and type erases its upstream to `AnyPublisher`.
  func voidPublisher() -> AnyPublisher<Void, Failure> {
    map { _ in Void() }
      .eraseToAnyPublisher()
  }
}

You can observe a @PostPublished the same way you do a @Published.

class UsingPostPublished {
  @PostPublished var dontEmitInitial = 0
  @PostPublished(emitCurrentValue: true) var emitInitial = 0
}

private var cancellables = Set<AnyCancellable>()

let usingPostPublished = UsingPostPublished()
usingPostPublished.$dontEmitInitial.sink {
  print("dontEmitInitial did change to \($0)")
}.store(in: &cancellables)

usingPostPublished.$emitInitial.sink {
  print("emitInitial did change to \($0)")
}.store(in: &cancellables)

usingPostPublished.emitInitial = 1
usingPostPublished.dontEmitInitial = 1
Centenary answered 16/11, 2021 at 10:29 Comment(7)
Thank you. This seems like a surprising design decision. Anyone know of discussion ?Soviet
@orionelenzil since SwiftUI relies on ObservableObject's objectWillChange, which emits whenever any @Published properties emit, @Published using willSet rather than didSet enables SwiftUI to prepare for view updatesCentenary
Doesn't make sense that @publish uses will set, if that is the case, a swiftUI element say Text will no update once it receives the willset signal from the publisher as the published property is not updated during willset.Montagnard
If SwiftUI is observing a @Published value, willSet causing the view to refresh, it will see the old value, not the didSet value. Either that or the documentation isn't very clear on that part.Montagnard
@Montagnard as I've said - you're assuming this, but have you actually tried running the code and checking your assumptions? I have actually tested my arguments and can 100% confirm based on actual testing that @Published uses willSet. As I've said in comments, objectWillChange is used to prepare the View to update - it doesn't necessarily mean that the view immediately updates.Centenary
One important note about your implementation of @PostPublished is that it behaves a bit different from @Published (apart from firing on didset), as it doesn't seem to publish the current value when subscribed to. This can be achieved by changing this line: self.projectedValue = didChangeSubject.prepend(wrappedValue).eraseToAnyPublisher()Waltman
@MikkelSelsøe if you always want that behaviour, simply replace the PassthroughSubject with a CurrentValueSubject, no need to prepend any values. I've also updated my answer with a version of the code where this behaviour is customisable.Centenary
S
7

First, if the purpose is to have a publisher that emits events on didSet, then you can simply use CurrentValueSubject. Its syntax isn't as nifty as @Published, but it does the trick:

var array = CurrentValueSubject<[Int], Never>([])
// ...
let second = array.value[1]
// ...
array.sink {
    print("array count: $0.count")
}
array.send([1, 2, 3])

If you already have a @Published variable that you wish to keep (for example, your SwiftUI view uses it), but you also wish to observe changes also after they took place, then you can add a CurrentValueSubject and update it in didSet

class Test {
  @Published var array: [Int] = [] {
    didSet {
        arrayChangedEvent.send(array)
    }
  }
    var arrayChangedEvent = CurrentValueSubject<[Int], Never>([])
}

and then observe the array in much the same way: arrayChangedEvent.sink { /* ... */ }

Storms answered 21/12, 2021 at 14:10 Comment(1)
Clever and best solution. Also, a PassthroughSubject may be enough for most.Contusion

© 2022 - 2024 — McMap. All rights reserved.