State var's didSet fix or prohibition

the State var's didSet is not getting called. should it be either a) called, or b) prohibited, so it is a compilation error to have didSet on a state var? it is strange that it is allowed but then doesn't do anything.

struct ContentView: View {
    @State var foo: Bool = false {
        didSet {
            print("foo changed")
        }
    }
    var body: some View {
        Toggle("foo", isOn: $foo)
    }
}
2 Likes

The State property wrapper has a non mutating setter. So perhaps you are suggesting that non mutating setters should have a warning if they have a didSet applied?

The real issue here is that the toggle is transacting upon the Binding to the State and the State only reflects what the Binding has. So it is somewhat expected in that perspective for the didSet to only trigger on direct mutations of the property itself (and not through it's projection).

To be fair to SwiftUI: Combine has a similar case where @Published can't actually invoke the didSet when assign(to:) is used. Basically the wrappers don't have enough info to even invoke that if they wanted to.

1 Like

Wow thanks for the info I did not know didSet would not be applied when using assign(to:)!

That seem a major issue though: depending on how you write your code then didSet might or might not be called.

I feel it should be consistent: either we forbid didSet or didSet is called no matter what (direct assign or using projected value). Otherwise it require developers to know about some implementation details

The distinction between mutating vs nonmutating is interface-level knowledge, not implementation details. The caller knows whether the function may mutate its arguments (including self) its signature alone. In fact, calling a mutating function will trigger didSet regardless of whether the function actually mutates self (which is implementation details).

Now, whether we should expand the didSet call to nonmutating functions, I'm very doubtful. That means that didSet needs to be called on essentially every access because there's no other distinction between a nonmutating function with effect (foo.toggle() here) and actual read-only non-effectful computation (foo.description). The situation is a lot worse once we include class into consideration. So the "solution" needs to be much smarter (either design-wise or compiler-implementation-wise).

1 Like

There are two sides to this problem that should be considered; first it is perfectly legitimate to have a projected value and a nonmutating set that do trigger the did/will set, but depending on how the wrapper behaves it may not trigger on lens style access. That basically means that the compiler does not have enough info to accomplish determining if the will/did set is going to happen or not. On the flip side there are no current ways for these types to even attempt triggering the will/did set.

The major issue becomes then that if some how becomes possible, code that previously was not executed will suddenly become something that executes. Which will lead to bugs, binary compatibility is a really hard problem. So the options boil down to breaking existing code, or breaking existing compiled behavior and potentially crashing apps. Neither really seem like good choices.

having a warning would be beneficial even if it works in the specific (but very typical) case of @State variables and not in more generic contexts.

the new behaviour can be an option, on by default in the new projects, off in existing projects. or an explicit opt in. none of these is ideal of course as new projects can use existing sources and opt-ins lead to language/platform fragmentation.

That sounds like adding dialects, which Swift is known to never do, potentially for a reason you already explained.

1 Like

See, I would expect property observers to trigger upon reassignment of the wrapper, not the wrapped value. But no, that doesn't work either.

I don't think it's right to allow observers, because there's no mechanism to choose which level you're working at. But a warning is definitely necessary here.

@propertyWrapper struct Wrapper {
  var wrappedValue: Void { () }
}

struct McDonaldsHalloweenBucket {
  @Wrapper var candy: Void {
    didSet { print("🍬") }
  }

  mutating func ƒ() {
    _candy = .init()
  }
}

var 🎃bucket = McDonaldsHalloweenBucket()
🎃bucket.ƒ() // No 🍬. 😿

didSet/willSet work if an "manual" binding is used:

// instead of this
Toggle("foo", isOn: $foo)
// do this
Toggle("foo", isOn: Binding(get: { foo }, set: { print("🦤"); foo = $0 }))

SwiftUI seems to not call didSet/willSet if the binding is from an @State projected value. However, manual binding, @AppStorage projected value or @Published in an ObservableObject, didSet/willSet are called. Is it treating this @State $binding differently? How does it even tell which kind of binding it is?

Very strange: TextField if the text binding is from an @State projected value, also no call to didSet/willSet, but if it's "manual" binding or ObservableObject/@Published projected value, didSet/willSet are called twice!!! per edit change because the binding's set closure is called twice.

Stranger still: just have this TextField, mutating the same var elsewhere didSet/willSet are called two extra times!

BTW: TextEditor binding from @State projected value also not didSet/wilSet called, but "manual" binding and @Publish and @AppStorage projected value binding work correctly only call didSet/willSet once.

Test showing what I'm talking about:
import SwiftUI

struct ContentView: View {
    @State private var text = "" {
        willSet {
            print("\n👉👉>>> \(#function) willSet is called, text = \(text), newValue = \(newValue)")
        }
        didSet {
            print("\n👉👉>>> \(#function) didSet is called, text = \(text), oldValue = \(oldValue)")
        }
    }
    @State private var showA = false {
        willSet {
            print("\n👉👉>>> \(#function) willSet is called, showA = \(showA), newValue = \(newValue)")
        }
        didSet {
            print("\n👉👉>>> \(#function) didSet is called, showA = \(showA), oldValue = \(oldValue)")
        }
    }

    @State private var showB = false
    @State private var showC = false

    var body: some View {
        VStack {
            Spacer()
            Text("text = \"\(text)\"")
            if showA {
                // didSet is not called on each edit change, not even when committed
                TextField("TextA", text: $text)
                    .frame(height: 80)
                    .background(Color.green)
                    .onSubmit {
                        print("😀 committed")
                    }
            }
            if showB {
                // if this kind of TextField is shown in View will cause trouble with extra set/didSet
                // the set closure is called twice when `text` change, causing didSet called twice!!
                // this happen for both TextField itself did the change or some place else (like the button below) cause the change
                TextField("TextB", text: Binding(get: { text }, set: { print("🦆"); text = $0 }))
            }
            if showC {
                // didSet not called on each edit change
                TextEditor(text: $text)
                    .border(Color.mint)
                // didSet is called this way correctly once per edit change
                TextEditor(text: Binding(get: { text }, set: { print("🐧"); text = $0 }))
                    .border(Color.indigo)
            }
            Button("text +=") {
                text += String("ABCDEFGHIJKLMNOPQRSTUVWXYZ".randomElement() ?? "⁉️")
            }
            Spacer()
            Group {
                // `didSet`/`willSet` on showA not called
                Toggle("Show TextField(\"Text\", text: $text)", isOn: $showA)
                // `didSet`/`willSet` on showA **are** called
                Toggle("Show TextField(\"Text\", text: $text)", isOn: Binding(get: { showA }, set: { print("🦤"); showA = $0 }))
                Toggle("Show TextField(\"Text\", text: Binding(...)", isOn: $showB)
                Toggle("Show TextEditor(text: $text)", isOn: $showC)
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
meanwhile found these didSet alternatives
import SwiftUI

struct ContentView: View {
    @State var foo = false
    @State var bar = false

    var body: some View {
        VStack {
            Toggle("first", isOn: .init { foo } set: { newValue in
                print("[ad hoc binding] will set to \(newValue)")
                foo = newValue
                print("[ad hoc binding] did set to \(newValue)")
                // Note, that with this method normal willSet/didSet
                // on variable also work, as @young just wrote
            })
            
            Toggle("second", isOn: $foo.onSet { newValue in
                print("[onSet] changed to \(newValue)")
            })

            Toggle("third", isOn: $bar)
                .onChange(of: bar) { newValue in
                    print("[onChange] changed to \(newValue)")
                }
        }
    }
}

extension Binding {
    func onSet(_ closure: @escaping (Value) -> Void) -> Binding<Value> {
        Binding { wrappedValue } set: { wrappedValue = $0; closure($0) }
    }
}
1 Like

Could you elaborate it? I can't reproduce it (see code below). Is it possible the behavior has changed?

import Foundation
import Combine

class Foo: ObservableObject {
    @Published var text: String {
        didSet {
            print("Text: \(text)")
        }
    }
    @Published var uppercase: String! {
        didSet {
            print("Uppercase: \(uppercase!)")
        }
    }
    private var cancellables = Set<AnyCancellable>()

    init(text: String) {
        self.text = text

        $text
            .map { text in
                text.uppercased()
            }
            .assign(to: \.uppercase, on: self)
            .store(in: &cancellables)
    }
}

var foo = Foo(text: "hello")
foo.text = "world"

// Output (No "Text: hello" output because it's initialized in init()):
// Uppercase: HELLO
// Uppercase: WORLD
// Text: world

Sorry, I don't understand it. I think what users (including me) would like to have is have the property observer attached to the internal storage, instead of wrappedValue computed property. Did you mean there is technical reason that isn't possible in some situation?

How about introducing property wrapper specific observers, say, willWrappedValueSet and didWrappedValueSet? That wouldn't break source compatibility (I have no idea about binary compatibility though). Just my thoughts.

I finally figured out why. Internal storage is an implementation detail of property wrapper, it's not visible to the compiler. The compiler just sees wrappedValue and projectedValue, so there is no way for it to attach user provided property observer to the internal storage, unless a new interface is added to property wrapper for this purpose.

Please ignore it (it doesn't make sense based on the above understanding).