swiftcocoaswiftuiuikitappkit

NSViewRepresentable.updateNSView is not called unless the modified binding variable gets used inside


Is this expected behaviour for SwiftUI? I might just not know SwiftUI well enough, but it seemed highly counterintuitive, so I thought I'd ask.

Here's a minimal example:

@main
struct SwiftUI_exampleApp: App {
    @State private var myString: String = "Initial value"

    var body: some Scene {
        WindowGroup {
            VStack {
                MySwiftUIView(myString: $myString)
                Text("Click me!")
                    .onTapGesture {
                        myString = "Set from Text.onTapGesture"
                    }
            }
        }
    }
}
struct MySwiftUIView: NSViewRepresentable {

    @Binding var myString: String

    func makeNSView(context: Context) -> MyNSView {
        let myView = MyNSView(myString: $myString)
        return myView
    }

    func updateNSView(_ nsView: MyNSView, context: Context) {
        // this. If something like this line is not included, updateNSView is not called when myString changes
        let _ = myString

        nsView.setNeedsDisplay(NSRect(x: 0, y: 0, width: nsView.frame.width, height: nsView.frame.height))
    }
}
class MyNSView: NSView {
    @Binding var myString: String

    init(myString: Binding<String>) {
        self._myString = myString
        super.init(frame: NSRect.zero)
    }

    override func draw(_ dirtyRect: NSRect) {
        myString.draw(at: CGPoint(x: 0, y: 0))
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

My guess is that SwiftUI "smartly" detects which binding variables are used inside updateNSView(), and only calls it when there is a change to one of those variables; however, in a case like the above example where the variable is indirectly used (ie. inside NSView.draw(), which is called after the nsView.setNeedsDisplay() call), it's not smart enough to detect properly and updateNSView() is skipped.

Do I have this right? That let _ = myString line is kind of horrible and I'd like to believe that's not the best/correct way to do this.


Solution

  • My guess is that SwiftUI "smartly" detects which binding variables are used inside updateNSView(), and only calls it when there is a change to one of those variables

    This is basically correct.

    The mistake here is that you should not have a @Binding in an NSView subclass.

    class MyNSView: NSView {
        var myString: String
    
        init(myString: String) {
            self.myString = myString
            super.init(frame: NSRect.zero)
        }
    
        ...
    

    I assume you used a @Binding in MyNSView because you are hoping that this will keep MyNSView.myString "synchronised" with MySwiftUIView.myString. This is not correct, because @Binding doesn't work outside of the "SwiftUI world".

    Keeping these in sync is exactly the purpose of updateNSView. You should update MyNSView.myString in updateNSView.

    The NSViewRepresentable implementation should look like:

    struct MySwiftUIView: NSViewRepresentable {
    
        @Binding var myString: String
    
        func makeNSView(context: Context) -> MyNSView {
            let myView = MyNSView(myString: $myString)
            return myView
        }
    
        func updateNSView(_ nsView: MyNSView, context: Context) {
            // Note this line:
            nsView.myString = myString
            // This is how you keep the myString in MyNSView synchronised with myString in MySwiftUIView
    
            nsView.setNeedsDisplay(nsView.bounds)
        }
    }
    

    If you are not going to change myString in MySwiftUIView, you don't even need a @Binding in MySwiftUIView. Just declare myString as a let constant.