0

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:

  1. 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.
  2. 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.
  3. 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?"
  4. Is this just a bad architecture? The alternative would be for the ViewModel itself (instead of the Model) 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).
maldata
  • 385
  • 1
  • 14

1 Answers1

0

Re: Option 1: Using the old Binding syntax

I am not familiar with this one

Re: Option 2: Using a signal handler

I've had issues completing the two-way binding on CheckBox.onCheckedChanged with it sometimes seeing the pre-checked state instead of the post-checked state. I have had more consistent success with CheckBox.onToggled signal instead.

CheckBox {
    id: clickMe
    checked: vm.current_model.some_bool
    onToggled: Qt.callLater( () => { vm.current_model.some_bool = checked } );
}

Option 3: Using the new Binding syntax

You may be still impacted by the onCheckedChanged firing too early. As a workaround you can use a copied property:

CheckBox {
    id: clickMe
    checked: vm.current_model.some_bool
    property bool checkedCopy: checked
    Binding {vm.current_model.some_bool: clickMe.checkedCopy }
}

You may not think it does much, but the copy introduces both the property and a signal. The copied signal handler is slightly delayed from the original and may provide a solution to the CheckBox.onCheckedChanged issue.

For option 2, you may have noticed that I added a Qt.callLater(). This is to help detect/troubleshoot infinite binding loops.

Below is an infinite binding loop demo. I deliberately put an error in the reverse binding formula. You will see that Qt.callLater allows you to see the infinite binding loop in action and that you have the ability to manipulate the UI/UX whilst the binding loop is happening.

Without the Qt.callLater it would be difficult to detect the binding loop and the problematic formula. Also, you run the risk of creating an unresponsive application.

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Page {
    ColumnLayout {
        anchors.centerIn: parent
        Label {
            text: "Celcius: %1 C".arg(celcius.value)
        }
        Slider {
            id: celcius
            from: -40
            to: 200
            value: 20
        }
        Label {
            text: "Fahrenheit: %1 C".arg(fahr.value)
        }
        Slider {
            id: fahr
            from: -40
            to: 392
            value: celcius.value * 9 / 5 + 32
            onValueChanged: Qt.callLater( () => { celcius.value = (fahr.value - 30) * 5 / 9 } ) // BINDING LOOP ERROR
            //onValueChanged: Qt.callLater( () => { celcius.value = (fahr.value - 32) * 5 / 9 } ) // CORRECT FORMULA
        }
    }
}

You can Try it Online!

Stephen Quan
  • 21,481
  • 4
  • 88
  • 75
  • I appreciate your thoughts on this, thank you! What I'm trying to do here (two-way binding) is fundamental to modern graphical applications, so it seems like there should be a fairly clear answer about how this should work. What I gather from your answer is that you've also been in situations where you've had to try a couple different things and seen inconsistent behaviors. Is that correct? – maldata Apr 12 '23 at 13:35
  • @maldata you can build your own library of two-way binding controls, for example https://stackoverflow.com/a/75279714/881441 – Stephen Quan Apr 12 '23 at 20:57