rberggreen
rberggreen

Reputation: 1133

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?

Upvotes: 103

Views: 59386

Answers (6)

Cl&#233;ment Cardonnel
Cl&#233;ment Cardonnel

Reputation: 5257

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

Upvotes: 28

oskarko
oskarko

Reputation: 4178

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...
            }
        }
    }
}

Upvotes: 3

Nicola Lauritano
Nicola Lauritano

Reputation: 79

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

Upvotes: 3

lassej
lassej

Reputation: 6494

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")
        }
      }
    }
  }
}

Upvotes: 78

zdravko zdravkin
zdravko zdravkin

Reputation: 2386

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

Upvotes: 0

ytyubox
ytyubox

Reputation: 175

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.

Upvotes: 5

Related Questions