swiftswiftuiuiviewrepresentable

Observing @State var changes from UIViewRepresentable in SwiftUI View


I have the following Swift view:

struct GameView: View {
    @State private var userWon: Bool = false {
        didSet {
            print("userWon: \(userWon)") // not called
            // TODO: Change game state here
        }
    }
    
var body: some View {
    ZStack {
        VStack {
            GameViewRepresentable(userWon: $userWon)
                .onChange(of: userWon) { newValue in
                    print("GameViewRepresentable: \(newValue)") // called
                    // TODO: or change game state here?
                }
        }
        .clipped()
    }
   }
}

The view representable is like so:

struct GameViewRepresentable: UIViewRepresentable {
    typealias UIViewType = GameView
    @Binding var userWon: Bool {
        willSet { newValue
            print("willSet userWon: \(newValue)")
        }
        didSet {
            print("didSet userWon: \(userWon)")
        }
    }
    
    func makeUIView(context: Context) -> GameView {
        let gameView = GameView()
        gameView.delegate = context.coordinator
        return gameView
    }

    func updateUIView(_ uiView: GameView, context: Context) {
    }

    func makeCoordinator() -> GameViewCoordinator {
        GameViewCoordinator(self)
    }

    class GameViewCoordinator: GameViewDelegate  {
        var parent: GameViewRepresentable
        
        init(_ parent: GameViewRepresentable) {
            self.parent = parent
        }

        func userWon() {
            self.parent.userWon = true
        }
    }
}

I can see that the userWon method is correctly getting called inside the coordinator which correspondingly calls willSet and didSet in GameViewRepresentable, but the didSet in the GameView for the binding userWon property is never called. However the onChange in GameView does get called. So is that the right way to respond to changes to the state property? I was expecting the didSet inside the GameView to get called instead.


Solution

  • Yes, onChange is how you detect @State changes.

    It is perfectly normal for willSet and didSet to not be triggered on a @State or @Binding. Swift inserts calls to willSet and didSet in the setter of the userWon property, so if userWon changes not by calling userWon's setter, then willSet and didSet will not be called.

    Your userWon property lowers to something like this:

    private var _userWon = State(initialValue: false)
    private var userWon: Bool {
        get { _userWon.wrappedValue }
        set { 
            // <willSet is called here>
            _userWon.wrappedValue = newValue 
            // <didSet is called here>
        }
    }
    private var $userWon: Binding<Bool> {
        _userWon.projectedValue
    }
    

    Consider this really simple example:

    @State private var userWon: Bool = false {
        willSet {
            print("willSet")
        }
        didSet {
            print("didSet")
        }
    }
    
    var body: some View {
        Toggle(isOn: $userWon) {
            Text("Foo")
        }
    }
    

    No matter how many times I toggle the toggle, willSet and didSet are not called. This is because the Toggle doesn't actually change the value of userWon by literally saying e.g. userWon = true (which would have called its setter). Toggle doesn't know that userWon exists at all.

    What actually happens is you passed a Binding<Bool> to the Toggle, which was the projectedValue of the State. $userWon is just a short way of saying _userWon.projectedValue. The Toggle will at some point set the wrappedValue property of the Binding<Bool>. This will then cause the wrappedValue of the State to be set, and the view gets updated.

    Notice that everything is done through the wrappedValue properties of State and Binding, not your userWon property.