Published Computed Properties in Swiftui Model Objects

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

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

Published computed properties in SwiftUI model objects

There are multiple issues here to address.

First, it's important to understand that SwiftUI updates the view's body when it detects a change, either in a @State property, or from an ObservableObject (via @ObservedObject and @EnvironmentObject property wrappers).

In the latter case, this is done either via a @Published property, or manually with objectWillChange.send(). objectWillChange is an ObservableObjectPublisher publisher available on any ObservableObject.

This is a long way of saying that IF the change in a computed property is caused together with a change of any @Published property - for example, when another element is added from somewhere:

elements.append(Talies())

then there's no need to do anything else - SwiftUI will recompute the view that observes it, and will read the new value of the computed property cumulativeCount.


Of course, if the .count property of one of the Tallies objects changes, this would NOT cause a change in elements, because Tallies is a reference-type.

The best approach given your simplified example is actually to make it a value-type - a struct:

struct Tallies: Identifiable {
let id = UUID()
var count = 0
}

Now, a change in any of the Tallies objects would cause a change in elements, which will cause the view that "observes" it to get the now-new value of the computed property. Again, no extra work needed.


If you insist, however, that Tallies cannot be a value-type for whatever reason, then you'd need to listen to any changes in Tallies by subscribing to their .objectWillChange publishers:

class GroupOfTallies: Identifiable, ObservableObject {
let id = UUID()
@Published var elements: [Tallies] = [] {
didSet {
cancellables = [] // cancel the previous subscription
elements.publisher
.flatMap { $0.objectWillChange }
.sink(receiveValue: self.objectWillChange.send)
.store(in: &cancellables)
}
}

private var cancellables = Set<AnyCancellable>

var cumulativeCount: Int {
return elements.reduce(0) { $0 + $1.count } // no changes here
}
}

The above will subscribe a change in the elements array (to account for additions and removals) by:

  • converting the array into a Sequence publisher of each array element
  • then flatMap again each array element, which is a Tallies object, into its objectWillChange publisher
  • then for any output, call objectWillChange.send(), to notify of the view that observes it of its own changes.

Computed property from @Published array of objects not updating SwiftUI view

Sadly, SwiftUI automatically updating views that are displaying computed properties when an @EnvironmentObject/@ObservedObject changes only works in very limited circumstances. Namely, the @Published property itself cannot be a reference type, it needs to be a value type (or if it's a reference type, the whole reference needs to be replaced, simply updating a property of said reference type won't trigger an objectWillChange emission and hence a View reload).

Because of this, you cannot rely on computed properties with SwiftUI. Instead, you need to make all properties that your view needs stored properties and mark them as @Published too. Then you need to set up a subscription on the @Published property, whose value you need for your computed properties and hence update the value of your stored properties each time the value they depend on changes.

class StateController: ObservableObject {

// Array of subjects loaded in init() with StorageController
@Published var subjects: [Subject]

@Published var allTasks: [Task] = []

private let storageController = StorageController()

private var subscriptions = Set<AnyCancellable>()

init() {
self.subjects = storageController.fetchData()
// Closure for calculating the value of `allTasks`
let calculateAllTasks: (([Subject]) -> [Task]) = { subjects in subjects.flatMap { $0.tasks } }
// Subscribe to `$subjects`, so that each time it is updated, pass the new value to `calculateAllTasks` and then assign its output to `allTasks`
self.$subjects.map(calculateAllTasks).assign(to: \.allTasks, on: self).store(in: &subscriptions)
}
}

Swift binding to a computed property

Make it a @Published property and update it when project is changed

class MyViewModel: ObservableObject {
@Published var project: Project {
didSet {
isProjectValid = project.name != "" && project.duration > 0
}
}

@Published var isProjectValid: Bool

//...
}

SwiftUI: ObservableObject with a computed property

No need to declare your computed values as Published, as they are depending on a Published value. When the published value changed, they get recalculated.

class SelectedDate: ObservableObject {
@Published var selectedMonth: Date = Date()

var startDateOfMonth2: Date {
let components = Calendar.current.dateComponents([.year, .month], from: self.selectedMonth)
let startOfMonth = Calendar.current.date(from: components)!

print(startOfMonth)
return startOfMonth
}

var endDateOfMonth2: Date {
var components = Calendar.current.dateComponents([.year, .month], from: self.selectedMonth)
components.month = (components.month ?? 0) + 1
let endOfMonth = Calendar.current.date(from: components)!

print(endOfMonth)
return endOfMonth
}
}

When you print endDateOfMonth2 on click, you will see that it is changing.

Add @Published behaviour for computed property

Updated:
With the EnclosingSelf subscript, one can do it!

Works like a charm!

import Combine
import Foundation

class LocalSettings: ObservableObject {
static var shared = LocalSettings()

@Setting(key: "TabSelection")
var tabSelection: Int = 0
}

@propertyWrapper
struct Setting<T> {
private let key: String
private let defaultValue: T

init(wrappedValue value: T, key: String) {
self.key = key
self.defaultValue = value
}

var wrappedValue: T {
get {
UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
}
set {
UserDefaults.standard.set(newValue, forKey: key)
}
}

public static subscript<EnclosingSelf: ObservableObject>(
_enclosingInstance object: EnclosingSelf,
wrapped wrappedKeyPath: ReferenceWritableKeyPath<EnclosingSelf, T>,
storage storageKeyPath: ReferenceWritableKeyPath<EnclosingSelf, Setting<T>>
) -> T {
get {
return object[keyPath: storageKeyPath].wrappedValue
}
set {
(object.objectWillChange as? ObservableObjectPublisher)?.send()
UserDefaults.standard.set(newValue, forKey: object[keyPath: storageKeyPath].key)
}
}
}

@Published for a computed property (or best workaround)

In this case there's no reason to use @Published for the isInialized property since it's derived from two other Published properties.

    var isInitialized: Bool {
return self.account.initialized && self.project.initialized;
}


Related Topics



Leave a reply



Submit