Qt Quick で双方向のバインディングを実現する

はじめに

Qt Quick でよくハマるところの一つがプロパティのバインディングと代入まわりです。

特に、このオブジェクトのこのプロパティと、べつのオブジェクトのこのプロパティを相互に同期しようと以下のように記載すると

Slider {
    id: slider
    to: 99
    value: spinbox.value
}
SpinBox {
    id: spinbox
    value: slider.value
}

大量に

QML SpinBox: Binding loop detected for property "value"
というエラーが発生します。

これを解決するためのいくつかの方法が Qt World Summit 2019 BerlinQML Component Design: the two-way binding problem というセッションで紹介されていました。

Two way bindings: Component Design in QtQuick

この動画の冒頭では「うまくいかない方法」がたくさん紹介されています。とても勉強になるのでぜひご覧ください。

また、うまく行く方法として KDAB で実際に行っている方法が3パターン紹介されています。

プレゼン資料: Two way bindings: Component Design in QtQuick

一番スマートな方法を試してみた

今回試したのは最後に紹介されている方法です。

CheckBox {
    id: colorCheckbox
    TwoWayBinding on checked {
        backendObject: SomeController
        backendProperty: "isBlue"
    }
}

TwoWayBinding
というタイプを利用し、
colorCheckbox.checked
SomeController.isBlue
の双方向の同期を取っているようです。

ここで、

SomeController
は、バックエンドにアクセスするためのシングルトンタイプを想定しています。

Property Modifier Objects

タイプ on プロパティ名
の記法は、Behavior でよく使われる他、NumberAnimation のようなアニメーションのタイプや、Binding にも登場します。

文法的にはこれは「Property Modifier Objects」と呼ばれるもので、対象のプロパティに初期値を設定したり、何かの状態に応じて値を更新したりする際に用いられます。

Property Value Sources に具体的な作り方と、サンプルコードが記載されているので一度確認してみてください。

これを参考に、PropertySync というエレメントを作成しました。

冒頭のコードは以下のように書くことで、Slider と SpinBox を操作した際に相互の値が同期されるようになります。

Slider {
    id: slider
    to: 99
}
SpinBox {
    PropertySync on value {
        target: slider
        propertyName: 'value'
    }
}
Controls

フロントエンドとバックエンドの同期

Qt Quick でアプリケーションを開発する際は、C++ 側にロジックを書いて、それを Qt Quick から操作できるようにするのが一般的です。その際に、以下のように記載することで、バックエンドの API と、Qt Quick のコントロールのプロパティを同期することができるようになります。

ColumnLayout {
    anchors.fill: parent
    Switch {
        text: 'loading: ' + api.loading
        PropertySync on checked {
            target: api
            propertyName: 'loading'
        }
    }
    Button {
        text: 'Toggle'
        onClicked: api.loading = !api.loading
    }
}
API call

Switch の value が変更された際に、api.loading に反映されるようになっています。

また、api.loading が変更された際には Switch の value に反映されるようになっています。

同様のことを普通にやろうとすると、Connections で、api の loading の変更を捕まえたり、その他にもいくつかやり方はありますが、PropertySync を利用するととてもシンプルに書くことができます。

(もう一歩シンプルにならないかなぁとも思っています)

モデルビューでも使える

Delegate にモデルの値の表示と変更をするコントロールを置いて、変更時にモデルのデータを更新したり、裏でモデルのデータが更新された際には最新の値を表示するようなケースがよくあります。

その場合も以下のように、Delegate で利用可能な特殊な変数 model のプロパティと同期させることで簡単に扱うことができるようになります。

ListView {
    model: ListModel {
        id: model
        ListElement {
            name: "Hello"
        }
        ListElement {
            name: "World"
        }
    }
    delegate: RowLayout {
        width: ListView.view.width
        height: 40
        Text {
            Layout.fillWidth: true
            Layout.preferredWidth: 1
            text: model.name
        }
        TextField {
            Layout.fillWidth: true
            Layout.preferredWidth: 1
            PropertySync on text {
                target: model
                propertyName: "name"
            }
        }
    }
    Button {
        anchors.right: parent.right
        anchors.bottom: parent.bottom
        text: 'Reset World'
        onClicked: model.setProperty(1, 'name', 'World')
    }
}

おわりに

Qt Quick で、2つのオブジェクトのプロパティを相互に同期する方法を紹介しました。

C++ 側での拡張が必要にはなりますが、記述がとても簡潔なので是非試していただければと思います。