88

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?

rberggreen
  • 983
  • 1
  • 6
  • 6
  • Possible duplicate of [Updating a @Published variable based on changes in an observed variable](https://stackoverflow.com/questions/58187541/updating-a-published-variable-based-on-changes-in-an-observed-variable) – user28434'mstep Oct 02 '19 at 14:43
  • 1
    **Computed Properties** are the kind of properties which are derived properties. Their values depend on the values of the dependent. For this reason alone, it can be said that they are never meant to be acting like an `ObservableObject`. You inherently assume that an `ObservableObject` object should able to have mutating ability which, by definition, isn't the case for the **Computed Property**. – nayem Oct 03 '19 at 05:02
  • Did you find a solution to this? I'm in the exact same situation, I want to avoid state and still be able to publish – erotsppa Mar 09 '20 at 04:43
  • 1
    Thanks for the `private(set)` solution. Helped me a lot. – Yonathan Goriachnick Apr 05 '21 at 12:40
  • Keeping all `subscribers` in a great idea! I'll adopt it – Ariel Steiner Aug 29 '21 at 01:47

6 Answers6

71

You don't need to do anything for computed properties that are based on @Published properties. You can just use it like this:

class UserManager: ObservableObject {
  @Published
  var currentUser: User?

  var userIsLoggedIn: Bool {
    currentUser != nil
  }
}

What happens in the @Published property wrapper of currentUser is that it will call objectWillChange.send() of the ObservedObject on changes. SwiftUI views don't care about which properties of @ObservedObjects have changed, it will just recalculate the view and redraw if necessary.

Working example:

class UserManager: ObservableObject {
  @Published
  var currentUser: String?

  var userIsLoggedIn: Bool {
    currentUser != nil
  }

  func logOut() {
    currentUser = nil
  }

  func logIn() {
    currentUser = "Demo"
  }
}

And a SwiftUI demo view:

struct ContentView: View {

  @ObservedObject
  var userManager = UserManager()

  var body: some View {
    VStack( spacing: 50) {
      if userManager.userIsLoggedIn {
        Text( "Logged in")
        Button(action: userManager.logOut) {
          Text("Log out")
        }
      } else {
        Text( "Logged out")
        Button(action: userManager.logIn) {
          Text("Log in")
        }
      }
    }
  }
}
lassej
  • 6,256
  • 6
  • 26
  • 34
  • with your solution (without the `private (set)`) the currentUser is accessible from outside the user manager class and is not a good practice in an OOP aspect. – Yonathan Goriachnick Apr 05 '21 at 12:39
  • 3
    This is a good answer for the specific context where the observable object is observed by a SwiftUI view. But if you want all features of `Published` – a property that can be both read and subscribed to in an arbitrary context – you need to create a mapped publisher in addition to the computed property. – rberggreen May 27 '21 at 16:20
  • rberggreen, yes, this is an answer specifically for cases where `@Published` is used in conjunction with `ObservableObject`. For cases without `ObservableObject` you need to create mappings. – lassej Sep 03 '21 at 11:31
27

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()
}()
Clément Cardonnel
  • 4,232
  • 3
  • 29
  • 36
  • 2
    I believe it should be `sourceOfTruthObject.canTimeTravel` not `sourceOfTruthObject.$canTimeTravel` in your second snippet. Maybe it was different at the time of writing. – Martin Jun 22 '20 at 08:19
  • Trying to understand why this has been made `lazy` – Sergio Bost Mar 29 '21 at 21:06
  • 1
    @SergioBost I believe you can't access `threshold` at initialization time. Hence, `lazy` is there to tell the compiler this can't happen and still declare it the way you want to (i.e: not in `init()`) – Clément Cardonnel Mar 30 '21 at 09:42
5

How about using downstream?

lazy var userIsLoggedInPublisher: AnyPublisher = $currentUser
                                          .map{$0 != nil}
                                          .eraseToAnyPublisher()

In this way, the subscription will get element from upstream, then you can use sink or assign to do the didSet idea.

ytyubox
  • 165
  • 1
  • 15
3

You could declare a PassthroughSubject in your ObservableObject:

class ReactiveUserManager1: ObservableObject {

    //The PassthroughSubject provides a convenient way to adapt existing imperative code to the Combine model.
    var objectWillChange = PassthroughSubject<Void,Never>()

    [...]
}

And in the didSet (willSet could be better) of your @Published var you will use a method called send()

class ReactiveUserManager1: ObservableObject {

    //The PassthroughSubject provides a convenient way to adapt existing imperative code to the Combine model.
    var objectWillChange = PassthroughSubject<Void,Never>()

    @Published private(set) var currentUser: User? {
    willSet {
        userIsLoggedIn = currentUser != nil
        objectWillChange.send()
    }

    [...]
}

You can check it in the WWDC Data Flow Talk

lewis
  • 2,936
  • 2
  • 37
  • 72
2

A simple workaround:

@Published private(set) var hiddenSelectedName: String = ""
var selectedName: String {
    get {
        return hiddenSelectedName
    }
    set(newVal) {
        if hiddenSelectedName != newVal {
            hiddenSelectedName = newVal
            // call methods and other stuff you need here...
            }
        }
    }
}
oskarko
  • 3,382
  • 1
  • 26
  • 26
0

scan(::) Transforms elements from the upstream publisher by providing the current element to a closure along with the last value returned by the closure.

You can use scan() to get the latest and current value. Example:

@Published var loading: Bool = false

init() {
// subscriber connection

 $loading
        .scan(false) { latest, current in
                if latest == false, current == true {
                    NotificationCenter.default.post(name: NSNotification.Name("userStateDidChange"), object: nil) 
        }
                return current
        }
         .sink(receiveValue: { _ in })
         .store(in: &subscriptions)

}

Above code is equivalent to this: (less Combine)

  @Published var loading: Bool = false {
            didSet {
                if oldValue == false, loading == true {
                    NotificationCenter.default.post(name: NSNotification.Name("userStateDidChange"), object: nil)
                }
            }
        }
zdravko zdravkin
  • 2,090
  • 19
  • 21