Why does custom Binding not update UI

I have a class that I cannot change to ObservableObject with Published members.

I tried getting around this by writing my own Binding. Although the value is updated correctly, the UI is not. Why is this.

Below is a simple demo view. When it is run and the toggle is clicked, it will print out correctly that the value is changed, but the UI does not update. Why?

import SwiftUI

class BoolWrapper {
  public var value = false {
    didSet {
      print("Value changed to \(value)")
    }
  }
}

let boolWrapper = BoolWrapper()

struct ContentView: View {
  var body: some View {
    Toggle(isOn: Binding(get: {
      return boolWrapper.value
    }, set: { value in
      boolWrapper.value = value
    }), label: { Text("Toggle") })
  }
}

struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    ContentView()
  }
}

Accepted Reply

Why can't you add the ObservableObject protocol conformance? It only really affects the properties that are marked with the @Published property wrapper, i.e. the properties that update the UI. This is what allows SwiftUI to update the UI based on properties in a class – it is the recommended way.

class BoolWrapper: ObservableObject {
    @Published var value = false {
        didSet {
            print("Value changed to \(value)")
        }
    }
}

struct ContentView: View {
    @StateObject private var boolWrapper = BoolWrapper()

    var body: some View {
        Toggle("Toggle", isOn: $boolWrapper.value)
    }
}



The UI can't update if it doesn't watch for changes from inside a class. That is why ObservableObject exists in SwiftUI.

If it was a struct, you would only need to use an @State property due to the nature of structs (vs classes).

  • Even though this does not answer the question about why custom binding does not update the UI it seems that the only way to reliably update the UI is to using the @Published property wrapper in an ObservableObject. It will use combine to notify the UI about the change.

Add a Comment

Replies

Why can't you add the ObservableObject protocol conformance? It only really affects the properties that are marked with the @Published property wrapper, i.e. the properties that update the UI. This is what allows SwiftUI to update the UI based on properties in a class – it is the recommended way.

class BoolWrapper: ObservableObject {
    @Published var value = false {
        didSet {
            print("Value changed to \(value)")
        }
    }
}

struct ContentView: View {
    @StateObject private var boolWrapper = BoolWrapper()

    var body: some View {
        Toggle("Toggle", isOn: $boolWrapper.value)
    }
}



The UI can't update if it doesn't watch for changes from inside a class. That is why ObservableObject exists in SwiftUI.

If it was a struct, you would only need to use an @State property due to the nature of structs (vs classes).

  • Even though this does not answer the question about why custom binding does not update the UI it seems that the only way to reliably update the UI is to using the @Published property wrapper in an ObservableObject. It will use combine to notify the UI about the change.

Add a Comment

I cannot change the code because It is in a 3rd party library for which I do not have the code.

The other alternative would be to write a wrapper for the 3rd party class but that felt less elegant that the custom Binding. Seeing that the Binding works, I was wondering what it was missing and why it is not working. Is @Published adding some other functionality that could be simulated somehow.

Thank you for mentioning that.


I guess a different approach to this would be storing the boolean value as a local @State variable and manually changing this.

Something like this will work:

@State private var boolValue = false

init() {
    _boolValue = State(initialValue: boolWrapper.value)
}

var body: some View {
    Toggle(boolValue ? "Toggle On" : "Toggle Off", isOn: $boolValue)
        .onChange(of: boolValue) { newValue in
            boolWrapper.value = newValue
        }
}

Thanks. That is a step in the right direction and could work.

Sorry for the piecewise info but I did not think it was relevant given the original question (I thought I just missed a notification in my Binding).

There is a slight catch: This leaves me with two sources of truth. The bool value in the class may be changed by something other than the UI. I have a callback for when this happens, so I could notify/publish when it does. In the case of your example, I could use Binding instead of State so that the value could be changed externally as well, but that brings me back to an original concept that just felt clumsy.

I could also add an Update button to my UI to refresh the values that may have changed externally, but again, it would be preferable to have just one source of truth.

  • The problem is that because you can't modify the contents of the class, you can't tell it to send change notifications which is what SwiftUI needs. I currently trying a way round this, possibly using Combine, but it's difficult without access to the original class (an extension can't do much). I'll update when I get somewhere but it'll either be ugly or not possible.

Add a Comment

I have a similar problem. The root cause is not the custom binding, but is related to observation mechanism. While my understanding is the same as the accepted reply above - non-observable object won't trigger UI updates correctly, the behavior is unexpected on iOS vs Mac Catalyst.

Consider the code snippet below

import SwiftUI

class BoolWrapper {
    var value = false {
        didSet {
            print("Value changed to \(value)")
        }
    }
}

struct ContentView: View {
    @State private var wrapper = BoolWrapper()
    
    var body: some View {
        Toggle("Toggle example", isOn: $wrapper.value)
    }
}

#Preview {
    ContentView()
}

The expected behavior for this snippet is not to change the UI when the toggle is turned on/off, because the BoolWrapper object is not observable. However…

  • On iOS, the toggle work normally. When it turns on, the UI correctly shows the tinted control, and the value is set to true
  • On Mac Catalyst, the toggle checkbox's UI doesn't update, whereas the value still changes between true/false when I click the checkbox.

My question is: Is the behavior on iOS unexpected, or "by accident"? It shouldn't work on iOS, but somehow it works.

FB13698944 is logged for this issue.


The code with correct behavior is like below, where the ObservableObject protocol is used.


import SwiftUI

class BoolWrapper: ObservableObject {
    @Published var value = false {
        didSet {
            print("Value changed to \(value)")
        }
    }
}

struct ContentView: View {
    @StateObject private var wrapper = BoolWrapper()

    var body: some View {
        Toggle("Toggle example", isOn: $wrapper.value)
    }
}

#Preview {
    ContentView()
}
  • FB13698944 is logged for this issue.

Add a Comment