Search code examples
iosswiftswiftuiuserdefaultsappstorage

Why doesn't willSet trigger for an @AppStorage variable when it's directly bound to a SwiftUI Toggle?


I'm using @AppStorage along with a SwiftUI Toggle to manage a UserDefaults variable. My expectation is to perform some operations when the variable changes due to the Toggle interaction, so I need to execute some code in the willSet part of my @AppStorage variable.

However, when I directly bind the @AppStorage variable to the Toggle's isOn state, willSet doesn't trigger. In contrast, when I bind a @State variable to the Toggle and modify the @AppStorage variable when the @State variable changes, the code in willSet does get executed.

Why is this the case? Why does the internal modification of the @AppStorage variable by the Toggle seem to bypass my willSet? Is there a more elegant way to achieve my requirements?

Here's the sample code to illustrate the issue:

struct ContentView: View {
    @State var stateVariable = false
    @AppStorage("userDefaultVariable") var userDefaultVariable: Bool = false {
        willSet {
            print("userDefaultVariable will set, newValue: \(newValue)")
        }
    }
    var body: some View {
        VStack{
            Toggle("Directly modify userDefaultVariable",isOn: $userDefaultVariable)    
            Toggle("Indirectly modify userDefaultVariable",isOn: $stateVariable) 
                .onChange(of: stateVariable, perform: { value in
                    userDefaultVariable = value
                })
        }
    }
}

In this code, the "Directly modify userDefaultVariable" Toggle does not trigger the print statement in the willSet of userDefaultVariable. However, the "Indirectly modify userDefaultVariable" Toggle does trigger the print statement when it modifies the @State variable stateVariable, which in turn modifies userDefaultVariable.


Solution

  • Property wrappers like AppStorage and State are compiled into a stored property of the wrapper type, and a computed property that gets and sets the wrappedValue of the stored property. There is also a $-prefixed computed property that gets the projectedValue.

    @AppStorage("userDefaultVariable") var userDefaultVariable: Bool = false
    

    becomes:

    var _userDefaultVariable = AppStorage(wrappedValue: false, "userDefaultVariable")
    
    var userDefaultVariable: Bool {
        get { _userDefaultVariable.wrappedValue }
        set { _userDefaultVariable.wrappedValue = newValue }
    }
    
    var $userDefaultVariable: Binding<Bool> {
        _userDefaultVariable.projectedValue
    }
    

    According to this post here, when you add willSet, it is applied to the computed property userDefaultVariable, not _userDefaultVariable or $userDefaultVariable. So as long as you are not directly setting it with userDefaultVariable = ..., willSet is not triggered.

    To further complicate the matter, Binding.wrappedValue and AppStorage.wrappedValue all have a nonmutating setter.

    I think the only solution here is to make your own Binding and add whatever code you want at the start of the set: argument:

    Toggle("Directly modify userDefaultVariable",isOn: Binding(get: { userDefaultVariable }, set: { newValue in
        // do your willSet things here...
        userDefaultVariable = newValue
    }))
    

    You can refactor this into your own property wrapper:

    @propertyWrapper
    struct Observed<Wrapped> {
        var wrappedValue: AppStorage<Wrapped>
        
        var willSet: (Wrapped) -> Void
        
        var projectedValue: Binding<Wrapped> {
            Binding {
                wrappedValue.wrappedValue
            } set: { newValue in
                willSet(newValue)
                wrappedValue.wrappedValue = newValue
            }
        }
    }
    

    Usage:

    struct ContentView: View {
        @Observed(willSet: { newValue in
            print("WillSet: \(newValue)") 
            // do other things...
        })
        @AppStorage("userDefaultVariable") var userDefaultVariable: Bool = false
        var body: some View {
            VStack{
                Toggle("Directly modify userDefaultVariable",isOn: $userDefaultVariable)
            }
        }
    }