In imperative Swift, it is common to use computed properties to provide convenient access to data without duplicating state.
Let's say I have this class made for imperative MVC use:
class ImperativeUserManager {
private(set) var currentUser: User? {
didSet {
if oldValue != currentUser {
NotificationCenter.default.post(name: NSNotification.Name("userStateDidChange"), object: nil)
// Observers that receive this notification might then check either currentUser or userIsLoggedIn for the latest state
}
}
}
var userIsLoggedIn: Bool {
currentUser != nil
}
// ...
}
If I want to create a reactive equivalent with Combine, e.g. for use with SwiftUI, I can easily add @Published
to stored properties to generate Publisher
s, but not for computed properties.
@Published var userIsLoggedIn: Bool { // Error: Property wrapper cannot be applied to a computed property
currentUser != nil
}
There are various workarounds I could think of. I could make my computed property stored instead and keep it updated.
Option 1: Using a property observer:
class ReactiveUserManager1: ObservableObject {
@Published private(set) var currentUser: User? {
didSet {
userIsLoggedIn = currentUser != nil
}
}
@Published private(set) var userIsLoggedIn: Bool = false
// ...
}
Option 2: Using a Subscriber
in my own class:
class ReactiveUserManager2: ObservableObject {
@Published private(set) var currentUser: User?
@Published private(set) var userIsLoggedIn: Bool = false
private var subscribers = Set<AnyCancellable>()
init() {
$currentUser
.map { $0 != nil }
.assign(to: \.userIsLoggedIn, on: self)
.store(in: &subscribers)
}
// ...
}
However, these workarounds are not as elegant as computed properties. They duplicate state and they do not update both properties simultaneously.
What would be a proper equivalent to adding a Publisher
to a computed property in Combine?
EDIT:
Although I think this answer has merits, nowadays I never use it and instead use the same technique that @lassej described in their answer.
I'd advise considering it first, and then check for other answers.
Create a new publisher subscribed to the property you want to track.
@Published var speed: Double = 88
lazy var canTimeTravel: AnyPublisher<Bool,Never> = {
$speed
.map({ $0 >= 88 })
.eraseToAnyPublisher()
}()
You will then be able to observe it much like your @Published
property.
private var subscriptions = Set<AnyCancellable>()
override func viewDidLoad() {
super.viewDidLoad()
sourceOfTruthObject.$canTimeTravel.sink { [weak self] (canTimeTravel) in
// Do something…
})
.store(in: &subscriptions)
}
Not directly related but useful nonetheless, you can track multiple properties that way with combineLatest
.
@Published var threshold: Int = 60
@Published var heartData = [Int]()
/** This publisher "observes" both `threshold` and `heartData`
and derives a value from them.
It should be updated whenever one of those values changes. */
lazy var status: AnyPublisher<Status,Never> = {
$threshold
.combineLatest($heartData)
.map({ threshold, heartData in
// Computing a "status" with the two values
Status.status(heartData: heartData, threshold: threshold)
})
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}()