Changing a `Published` property of an `ObservableObject` to a computed var

1.9k Views Asked by At

I have an ObservableObject with @Published properties:

class SettingsViewState: ObservableObject {
    @Published var viewData: SettingsViewData = .init()
    …

I would like to change viewData to a computed var based on other sources of truth rather than allowing it to be directly modified. However, I still want views looking at viewData to automatically update as it changes. They should update when properties it is computed from change.

I'm really not very certain about how @Published actually works though. I think it has its willSet perform objectWillChange.send() on the enclosing ObservableObject before a change occurs, but I'm not sure!

If my suspicion is correct, it seems like I could manually call objectWillChange.send() on the enclosing object if anything viewData depends on will change.

Alternatively, if properties viewData is computed from are themself @Published, when I change one, presumably an equivalent objectWillChange.send() will occur automatically, and I won't need to do anything special? This should work even if these properties are private and a watching view doesn't have access to them: it should still see the objectWillChange being emitted?

However, It's entirely possible I've got this horribly garbled or mostly backwards! Eg, perhaps the @Published properties have their own independent change publisher, rather than simply making use of the enclosing ObservableObject's? Or both of them publish prior to a change?

Clarification will be gratefully received. Thank you!

3

There are 3 best solutions below

1
Dávid Pásztor On BEST ANSWER

I'm really not very certain about how @Published actually works though. I think it has its willSet perform objectWillChange.send() on the enclosing ObservableObject before a change occurs, but I'm not sure!

You are correct, that is how @Published and ObservableObject work together, however, these are 2 independent things.

@Published is a property wrapper, which adds a Publisher to the wrapped property, which emits the new value from the willSet of the property. So the Published.Publisher emits the value that is about to be set before it is actually set.

ObservableObject is a protocol, which has an objectWillChange publisher, whose value is autosynthesised by the compiler. The synthesised implementation emits a value when any of the @Published property's publishers emits. So objectWillChange emits a value whenever an @Published property on the ObservableObject conformant type is about to change.

If you store an ObservableObject conformant type on a SwiftUI View as @StateObject, @ObservedObject or @EnvironmentObject, the view updates (and hence recalculates its body) whenever the objectWillChange of the ObservableObject emits.

If you have a property, which needs to be recalculated whenever other properties are updated and you want to update your view with these changes, you have several options to achieve that.

  1. Declare all properties as stored @Published properties and set up the dependant property to be updated whenever any of the properties it depends on are updated.

This 100% guarantees that your view will always be updated with the correct values and you don't need to call objectWillChange.send() manually.

This solution also works even if the properties that your "computed" property depends on are declared on other types, since your "computed" property is @Published so whenever it is updated, it will trigger a view update.

However, you do need to set up the observation of your dependant properties to update the property that depends on them.

@Published var height: Int
@Published var width: Int

@Published private(set) var size: Int

init(height: Int, width: Int) {
  self.height = height
  self.width = width
  self.size = height * width
  $height.combineLatest($width).map { height, width in
    height * width
  }.assign(to: &$size)
}
  1. If all all properties that your computed property depend on are @Published and are declared on the same object as your computed property, simply declaring the property that depends on them as computed should work, since the properties that you depend on will trigger a view update themselves whenever they are updated.

This doesn't work though if your @Published properties are declared on another object, since that object won't trigger a view update.

2
malhal On

Use @State for view data (values or structs with mutating funcs). ObservableObject was originally designed for model data lifetime.

In the View struct use a computed property to transform the data as you pass it down to child View structs. Usually we transform from rich model types to simple values that get passed into previewable Views.

The more recent addition of @StateObject is for when you need a reference type in an @State which is uncommon and doesn't seem to be the need here.

0
julltron On

I was trying to answer this same question for myself and landed here. I know it's been "solved" for a couple of months now, but I found another way that works for my scenario, at least.

Instead of making the public property computed, you can put the work on the private properties to update the public one whenever they are updated using their didSet.

import Foundation
    
class ThemeList: ObservableObject {
    // this is the property I wanted to be computed
    @Published public private(set) var themes: [String]
        
    // these are the "sources of truth"
    private let internalThemes: [String]
    private var customThemes: [String] {
        // this is where the magic happens!
        didSet {
            bundleThemes()
        }        
    }
    
    init(themes: [String], customThemes: [String] = []) {
        self.internalThemes = themes
        self.customThemes = customThemes
        self.themes = []
        bundleThemes()
    }
    
    // set the (no-longer computed) @Published property
    // triggering the 
    func bundleThemes() {
        var allThemes: [String]
        allThemes = internalThemes
        allThemes.append(contentsOf: customThemes)
        allThemes.sort()
        themes = allThemes
    }
    
    func addTheme(_ theme: String) {
        customThemes.append(theme)
    }
}
    
let themeList = ThemeList(themes: ["Hi", "Hello"])
let cancellable = themeList.$themes
    .sink() {
        print ($0)
}
    
themeList.addTheme("Howdy!")

You can run that as-is in a playground and see that the new value ["Hi", "Hello", "Howdy!"] gets published.

Originally, I was trying to make themes a computed property that would combine and sort two private properties (internal and custom themes). Of course you can't use @Published on a computed property. So instead you make it public get, private set property and use didSet to update it. This has the effect of keeping the property updated as if it were computed and also emitting the change notification.