There's something unsettling about how two-way binding works in QML, and I'm hoping someone can help clarify. I already saw this post and the referenced article, and I am aware that there are two ways to do this. The Binding
object never quite works the way I expect, though.
Let's say I have a QML application and I set a context property called vm
to be an instance of a ViewModel
object:
from PySide6.QtCore import QObject, Property, Signal
from .model import Model
class ViewModel(QObject):
current_model_index_changed = Signal()
def __init__(self, parent = None) -> None:
super().__init__(parent)
self._models = [Model("First", False), Model("Second", True), Model("Third", False)]
self._current_model_index = 0
@Property(int, notify=current_model_index_changed)
def current_model_index(self):
return self._current_model_index
@current_model_index.setter
def current_model_index(self, new_value):
if self._current_model_index != new_value:
self._current_model_index = new_value
self.current_model_index_changed.emit()
@Property(QObject, notify=current_model_index_changed)
def current_model(self):
return self._models[self._current_model_index]
As you can see, there is a list of three Model
objects, and a property called current_model
which returns a Model
object based on the currently-selected index (and shares the same signal). The Model
class is simple, it just has two properties:
from PySide6.QtCore import QObject, Signal, Property
class Model(QObject):
some_string_changed = Signal()
some_bool_changed = Signal()
def __init__(self, text='', checked=False, parent=None) -> None:
super().__init__(parent)
self._some_string = text
self._some_bool = checked
@Property(str, notify=some_string_changed)
def some_string(self):
return self._some_string
@Property(bool, notify=some_bool_changed)
def some_bool(self):
return self._some_bool
@some_bool.setter
def some_bool(self, new_value):
if self._some_bool != new_value:
self._some_bool = new_value
self.some_bool_changed.emit()
I want the user to be able to manipulate the properties (or in this case, just the some_bool
property) of the currently-selected model, so in my QML I provide a SpinBox
so they can select the index of the model to view/edit. This is bound to the ViewModel
's current_model_index
:
SpinBox {
id: selector
value: vm.current_model_index
Binding {
target: vm
property: "current_model_index"
value: selector.value
}
}
The Binding
object works fine here.
The some_string
property of the currently-selected model can be bound to a Label
:
Label {text: vm.current_model.some_string}
And the user is allowed to change the some_bool
property of the currently-selected model with a CheckBox
. This can be done three ways.
Option 1: Using the old Binding syntax
This is how I learned to do this a while back, and (as in the case of the SpinBox
above) it generally works fine, though it is a bit verbose. In this case, though, it does not work. When the user changes the SpinBox
value, vm.current_model
gets updated, and then it calls the some_bool
setter, which sets that property for the newly-selected model to whatever the current value of the CheckBox
is (i.e., the value it held from the previous model). As a result, the CheckBox
does not change state when the user changes the selected index. It stores the current checkbox's state in the some_bool
property of the newly-selected model, rather than "refreshing" the checkbox with the new some_bool
property's value.
CheckBox {
id: clickMe
checked: vm.current_model.some_bool
Binding {
target: vm.current_model
property: "some_bool"
value: clickMe.checked
}
}
It seems like a matter of ordering events correctly. When the user selects a new model index, the state of the checkbox needs to get updated, but when it does, the Binding
object calls the some_bool
setter and uses the current state of the checkbox. It seems like the only way to manage that is by clearly distinguishing situations in which the checkbox is clicked from situations in which the checkbox state changes even though it was not clicked.
Option 2: Using a signal handler
This is nice & compact, and it works. When the user changes the SpinBox
value, vm.current_model
gets updated, and the CheckBox
's state updates to the value of the newly-selected model's some_bool
. If the user clicks the checkbox, the some_bool
setter gets called on the selected model, which stores the new state of the checkbox.
CheckBox {
id: clickMe
checked: vm.current_model.some_bool
onClicked: vm.current_model.some_bool = clickMe.checked
}
Perhaps this is how I should've been doing things all along, so I'm trying to understand when to use each method.
Option 3: Using the new Binding syntax
This just doesn't work at all. I like that it's short & readable, and presumably this is valid syntax, but the some_bool
setter never gets called.
CheckBox {
id: clickMe
checked: vm.current_model.some_bool
Binding {vm.current_model.some_bool: clickMe.checked}
}
So, given that this is the case, I don't see a situation in which the Binding
object is ever the right choice to achieve two-way binding. My questions:
- Is a signal handler (option 2) the preferred way of implementing two-way binding? It doesn't actually feel like "declarative property binding," it just feels like "signal handling," but maybe I'm just hung up on that for no reason.
- Is there a reason why option 3 doesn't work at all? Is this a bug or is it user error? It seems like what I'm trying to do is exactly the purpose of this.
- Even if option 3 did work, wouldn't it suffer from the same problems as option 1? Wouldn't I still need to be worried about the difference between "the state of the checkbox changed because the user clicked it" and "the state of the checkbox changed because something happened in the Python application?"
- Is this just a bad architecture? The alternative would be for the
ViewModel
itself (instead of theModel
) to have a string and a boolean property that the QML binds to. When the user clicks the checkbox, the property setter would then handle storing the new value of the checkbox in the actual model object (which would then no longer need to be a QObject because we're not binding directly to its properties).