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.
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.