-2

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!

Benjohn
  • 13,228
  • 9
  • 65
  • 127
  • This sounds like an [XY Problem](http://xyproblem.info/). "They should update when properties it is computed from change" How about making the "properties it is computed from" `@Published` instead? You can then use `combineLatest` to combines all of those properties. – Sweeper Apr 24 '23 at 11:24
  • Alternatively, how about making `SettingsViewData` itself an `ObservableObject`? And it would have all the "properties it is computed from". Assuming that it is/can be made into a class, of course. – Sweeper Apr 24 '23 at 11:29
  • 1
    "it seems like I could manually call `objectWillChange.send()` on the enclosing object if anything `viewData` depends on will change." "...and I won't need to do anything special?" Did you try anything to verify these suspicions? These sounds like they can be easily verified by just writing some code. – Sweeper Apr 24 '23 at 11:35
  • I'm not wildly happy with the tone of your responses here @Sweeper, if you don't have time for this question, please do skip it, and thank you. If you can clearly see I'm confused, which is likely, please try to educate me (and others) gently. Thank you for your time though! In terms of validating, I can write a test to validate this current element of behaviour, but this will only confirm if it happens to work, not whether it is the intended use pattern of the system. I'm after that deeper understanding. – Benjohn Apr 24 '23 at 11:42

3 Answers3

3

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.

Dávid Pásztor
  • 51,403
  • 9
  • 85
  • 116
  • 1
    Thank you! That's extremely helpful, gives a lot of insight to what's going on, and points me to a solution. I prefer option 2 here, I think. My reasoning is: With option 1 I think it is easy to accidentally omit code that should update a dependent property (especially during code evolution). In contrast, with approach 2, update code is not needed and so cannot be forgotten? – Benjohn Apr 24 '23 at 13:27
0

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.

malhal
  • 26,330
  • 7
  • 115
  • 133
  • Could you expand on this answer? It seems like you are suggesting not having a separate model and maintaining this as state of views? – Benjohn Apr 25 '23 at 07:56
  • There is a store object to hold the model data usually environmentObject. View data is in the `View` structs, `let` for read and `@State var` or `@Binding var` for read/write. Computed var to transform. SwiftUI will diff your structs and use the diff to create/update/remove UIView objects for you. – malhal Apr 25 '23 at 08:09
0

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.

julltron
  • 11
  • 2