Search code examples
iosswiftswiftuireactivecombine

An equivalent to computed properties using @Published in Swift Combine?


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 Publishers, 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?


Solution

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