First, let's be clear about what "re-render" means. If you mean "creates an entirely new view", then this will only happen if the identity of the view changes. If your view's identity does not depend on ModalObservable, then how you change ModelObservable is not relevant. This can be experimentally detected by onAppear.
If you mean "updates an existing view", then this depends on the view's dependencies. SwiftUI creates a dependency graph from your views and the states/bindings/environments etc that the views use, and updates the view if any of the dependencies change. This can be experimentally detected by having body produce a side effect (though body is also called when the view is first created).
Here is some code that you can use to experiment:
struct ContentView: View {
@State var model = ModalObservable()
var body: some View {
let _ = print("ContentView updates")
UpdateDetector(observable: model)
Button("Change Open") {
model.changeOpen()
}
Button("Change View") {
model.changeView()
}
}
}
struct UpdateDetector: View {
let observable: ModalObservable
var body: some View {
let _ = print("UpdateDetector updates")
Text(observable.state.open ? "Open" : "Close")
}
}
@Observable class ModalObservable {
var state = ModelState()
func changeView() {
self.state.view = UUID().uuidString
}
func changeOpen() {
self.state.open.toggle()
}
}
struct ModelState {
var view = "Foo"
var open = false
}
Here SwiftUI has determined that UpdateDetector depends on observable.state, so it updates whenever it observes a change in observable.state, i.e. when either of the buttons is pressed. On the other hand, ContentView does not update, because it does not depend on any observable property.
Note that observable.state.open and observable.state.view does not count as two separate observable properties, because the @Observable macro only adds @ObservationTracked to state.
Here's another example, this time with Bindings.
struct ContentView: View {
@State var model = ModalObservable()
var body: some View {
let _ = print("ContentView updates")
UpdateDetector(open: $model.state.open)
Button("Change Open") {
model.changeOpen()
}
Button("Change View") {
model.changeView()
}
}
}
struct UpdateDetector: View {
@Binding var open: Bool
var body: some View {
let _ = print("UpdateDetector updates")
Text(open ? "Open" : "Close")
}
}
Here, UpdateDetector only depends on the open binding, so pressing "Change View" does not update it, but "Change Open" does. On the other hand, ContentView now also depends on the open binding, since a binding is two-way. Pressing "Change Open" updates ContentView too.
If you do want observable.state.open and observable.state.view to be tracked separately, you can change ModelState to also be an @Observable class, though this also changes semantics.
@Observable
class ModelState {
var view = "Foo"
var open = false
}