はじめに
この記事では,Qtを使ったMVVMアーキテクチャに基づくプログラミング手法の一例を紹介します.
以下のような読者を対象としています:
- Qt/QMLを少し触ったことがある,または興味がある
- UIのコードと内部のデータ処理をきれいに分離して整理したい
- MVVMは聞いたことがあるけれど,Qtではどう書けばよいのか知りたい
Qt/QMLアプリにおいて,C++とQMLをMVVMアーキテクチャに従って分離し,保守性や拡張性の高いコードを書くための考え方と実装パターンを学んでいきましょう.
Qtとは?
Qt(キュート)は,C++をベースとしたクロスプラットフォームのアプリケーションフレームワークです.GUIアプリケーションの開発を得意としており,Windows,Linux,macOS,さらには組込みデバイスまで,幅広いプラットフォームで動作します.
Qtには,UIを簡潔に記述できるQML(Qt Modeling Language)という専用言語が用意されており,QMLとC++を組み合わせることで,リッチで反応の良いUIを持つアプリケーションを効率的に開発することができます.
GUIアプリだけでなく,ネットワーク,データベース,OpenGL,マルチスレッド処理など,幅広い機能をカバーする統合フレームワークであることも,Qtの大きな特徴です.
MVVM アーキテクチャとは?
MVVM(Model–View–ViewModel)は,UIを持つソフトウェア向けの設計アーキテクチャの一つです.アプリケーションの構造を次の3層に分けて設計します:
-
Model
データの保持や計算ロジックを担当します.Qtの場合は,純粋なC++クラス,またはQObject
を継承したクラスで実装するのが一般的です -
View
UIの描画やユーザー入力の処理を担当します.Qtでは主にQMLで記述されます -
ViewModel
ModelとViewの橋渡しを行う層です.QtではQ_PROPERTY
を用いた実装が重要なポイントになります
基本的に,ModelとViewは直接関連しないように設計し,ViewModelを介して間接的に連携させます.ViewModelは,ModelのデータをViewが扱いやすい形に変換して提供し,またViewからの入力をModelに伝える役割を担います.
QtにおけるMVVMでは,ModelとViewModelを厳密に分ける設計はあまり一般的でなく感じます.特に簡単なサンプルコードでは,ViewModelの役割をModelクラスにまとめて実装するスタイルがよく見られます.本記事でも,ModelとViewModelの機能を一つのクラスに統合した実装を紹介します.
Qt(QML)アプリの Hello World ハンズオン
MVVMアーキテクチャに基づいた実装の前に,まずはQtアプリのHello World的なコードの書き方と実行方法を確認しておきましょう.
開発環境
この記事執筆時点の筆者の開発環境は以下のとおりです.WSL上のDebianを使用していますが,Qtはクロスプラットフォーム対応なので,他の環境でも同様に開発できます.
- ホスト OS
Windows 11 Pro(24H2) - WSL ディストリビューション
Debian 12 bookworm - Qt バージョン
5.15.8
Qtはapt
パッケージでインストールしています.もちろん,Qt公式のインストーラを利用してセットアップすることも可能です.
$ sudo apt install qtbase5-dev qtchooser qt5-qmake qtbase5-dev-tools qtbase5-examples \
qtdeclarative5-dev qml-module-qtquick-controls qml-module-qtquick-controls2 qtquickcontrols2-5-dev
Hello world の実装
Qtには公式IDE「Qt Creator」もありますが,今回は任意のテキストエディタとコマンドラインを使って,最小構成のサンプルアプリを作成します.
ディレクトリ構成
以下のような構成でファイルを用意します:
helloqt/
├── main.cpp
├── main.qml
├── qml.qrc
└── helloqt.pro # qmake用のプロジェクトファイル
.pro
ファイル以外は手動で作成しますが,以下のコマンドでひな形を生成することができます:
$ touch main.cpp main.qml qml.qrc
$ qmake -project -o helloqt
生成された.pro
ファイルに,以下の行を追記してください:
QT += quick qml
ファイル全体の内容は次のようになります:
######################################################################
# Automatically generated by qmake (3.1) Mon Jul 14 01:38:06 2025
######################################################################
TEMPLATE = app
TARGET = helloqt
INCLUDEPATH += .
# You can make your code fail to compile if you use deprecated APIs.
# In order to do so, uncomment the following line.
# Please consult the documentation of the deprecated API in order to know
# how to port your code away from it.
# You can also select to disable deprecated APIs only up to a certain version of Qt.
#DEFINES += QT_DISABLE_DEPRECATED_BEFORE=0x060000 # disables all the APIs deprecated before Qt 6.0.0
QT += quick qml
# Input
SOURCES += main.cpp
RESOURCES += qml.qrc
main.cppの実装
main.cpp
ではQMLファイルを読み込んでアプリケーションを起動します.Qt/QMLアプリケーションの最小構成は以下のようになります:
#include <QGuiApplication>
#include <QQmlApplicationEngine>
int main(int argc, char *argv[])
{
QGuiApplication app(argc, argv);
QQmlApplicationEngine engine;
engine.load(QUrl(QStringLiteral("qrc:/main.qml")));
if (engine.rootObjects().isEmpty())
{
return -1;
}
return app.exec();
}
main.qmlの実装
ここでは中央にボタンを配置したウィンドウを表示し,クリック時にコンソールへログを出力するだけの簡単なUIを定義します:
import QtQuick 2.15
import QtQuick.Controls 2.15
ApplicationWindow {
visible: true
width: 400
height: 300
title: qsTr("Hello from QML")
Button {
text: "Click Me"
anchors.centerIn: parent
onClicked: console.log("Button clicked!")
}
}
qml.qrc(リソースファイル)
QMLファイルをリソースとして登録するための.qrc
ファイルの内容は次のとおりです:
<RCC>
<qresource prefix="/">
<file>main.qml</file>
</qresource>
</RCC>
ビルドと実行
ここまで準備が整ったら,以下のコマンドでビルド・実行してみましょう:
$ mkdir build && cd build
$ qmake ..
$ make -j4
$ ./helloqt
実行すると,中央に「Click Me」ボタンを配置したウィンドウが開き,クリック時にログが出力されるはずです.これがQt/QMLアプリの最小構成です.
今回は,ビルドにQt専用のビルドツールであるqmake
を使用しましたが,一般的に広く使われているCMakeを使ってビルドすることも可能です.特にQt6以降では,CMakeが公式に推奨されているビルドシステムとなっています.
以下に,同じHello WorldアプリをCMakeで構成する場合のCMakeLists.txt
の例を示します.
cmake_minimum_required(VERSION 3.14)
project(helloqt VERSION 1.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)
find_package(Qt5 REQUIRED COMPONENTS Quick)
add_executable(${PROJECT_NAME}
main.cpp
qml.qrc
)
target_link_libraries(${PROJECT_NAME}
Qt5::Quick
)
MVVM サンプルの実装解説
ここからは,MVVMアーキテクチャに基づいた具体的な実装に取り組んでいきます.
本記事では,次の2パターンを例に取りながら,MVVMの基本的な考え方と実装方法を解説します.
- シンプルなデータのバインディング
- リスト形式のデータのバインディング
MVVM(Model–View–ViewModel)において最も重要な要素のひとつが,「データバインディング」です.View(QML)とViewModel(C++)が相互に状態を同期することで,明確な責務分離とリアクティブなUIを実現できます.本記事では,この「データバインディング」の観点を中心にMVVMを読み解きながら,実装を進めていきます.
ディレクトリ構成の整理
これから複数のソース・ヘッダファイルや QML ファイルを追加していくことになるため,コードの管理をしやすくするために,以下のようなディレクトリ構成に整理して作業を進めていきます:
helloqt/
├── qml
│ ├── foo.qml # 新規のQMLファイルはこちらに追加
│ └── main.qml
├── src
│ ├── bar.cpp # 新規のソース・ヘッダファイルはこちらに追加
│ ├── bar.h
│ └── main.cpp
├── qml.qrc
└── helloqt.pro
.proファイルの更新
.pro
ファイル(helloqt.pro
)のSOURCES
にファイルのパスを更新します.ヘッダファイルを追加した場合はHEADERS
にも追記してください:
# Input
SOURCES += src/main.cpp src/bar.cpp # パスを更新・追記
HEADERS += src/bar.h # ヘッダファイルを追記
main.cppの修正
QMLファイルの読み込みパスも合わせて更新します:
engine.load(QUrl(QStringLiteral("qrc:/qml/main.qml"))); // パスを修正
qml.qrcの更新
QMLファイルを正しく読み込むため,リソースファイルのパスも更新します:
<RCC>
<qresource prefix="/">
<file>qml/main.qml</file>
</qresource>
</RCC>
これで今後QMLやC++ファイルが増えても,構造的に整理されたプロジェクトとして管理しやすくなります.
シンプルなデータのバインディング
まずは以下のようなUIを構築していきます.
この画面では以下のような動作を実現します:
-
Hight
とWidth
のスライダーを操作すると,その上に表示されている数値ラベルがリアルタイムに更新されます -
CALCULATE
ボタンをクリックすると,Hight
とWidth
の値を掛け合わせて面積(Area
)を算出し,その結果が下部の数値ラベルとスライダーに反映されます -
Continuous calculate
のスイッチをONにすると,スライダーを操作するたびにArea
の値がリアルタイムで再計算されて反映されます
MVVMに基づいた設計方針
本サンプルの設計では,以下のようにMVVMアーキテクチャに沿って役割を分けます.
ViewModel / Model(C++側)
- 各プロパティ(
hight
,width
,continuousCalculate
など)をQ_PROPERTY
として定義し,QMLからアクセスできるようにします -
area
は読み取り専用プロパティとして公開します(QML側からは直接変更できない) -
CALCULATE
ボタン用に面積を再計算するメソッド(Q_INVOKABLE
)を実装します - 必要に応じて,
hight
やwidth
が更新された際にarea
も再計算します
View(QML側)
- スライダーやスイッチ,ボタンなどのUIコンポーネントを配置
- それぞれのUI要素とViewModelのプロパティをバインディングします(双方向)
MVVMの効果
このように適切なViewModelを構成することで,次のような双方向バインディングが自然に行えます:
-
Hight
のスライダーを動かすとViewModelのhight
プロパティが更新される(View → ViewModel) - その変更が
Hight
ラベルの表示にも反映される(ViewModel → View)
さらに,hight
またはwidth
が変更されると,必要に応じてarea
を再計算し,それもQ_PROPERTY
経由でViewに通知されます.
コードのポイント
ここまでの設計方針に沿って,実際にコードを実装していきます.
ViewModel / Model(C++側)
ViewModel(Model)のコードはQObject
を継承したクラスとして実装します.
たとえばhight
プロパティの実装は以下のようになります.
#pragma once
#include <QObject>
class CalculatorModel : public QObject
{
Q_OBJECT
Q_PROPERTY(int hight READ hight WRITE setHight NOTIFY hightChanged)
public:
int hight() const;
void setHight(int value);
signals:
void hightChanged();
private:
int m_hight = 0;
};
m_hight
は実際の保持値で,setHight
とhight
はそれに対するsetter / getterです.
また,プロパティ変更の通知にはQt独自のsignals
を使います.これは関数のように宣言しますが,実装を書く必要はありません(Qtのビルド時に自動生成されます).
setterの中では,値が変わったときにemit
を使ってシグナルを発行します.
int CalculatorModel::hight() const
{
return m_hight;
}
void CalculatorModel::setHight(int value)
{
if (m_hight != value)
{
m_hight = value;
emit hightChanged();
}
}
このようにQ_PROPERTY
マクロを使ってプロパティを宣言することで,QML側からアクセス・バインディングが可能になります.
他のプロパティも同様に定義しますが,area
プロパティは読み取り専用とするため,WRITE
を省略します:
Q_PROPERTY(int area READ area NOTIFY areaChanged)
CALCULATE
ボタン用に,面積を再計算するメソッドは次のように実装し,Q_INVOKABLE
マクロでQML側から呼び出せるようにします:
Q_INVOKABLE void calculateArea();
void CalculatorModel::calculateArea()
{
int newArea = m_hight * m_width;
if (m_area != newArea)
{
m_area = newArea;
emit areaChanged();
}
}
さらに,continuousCalculate
が有効なときは,hightやwidthの変更時に自動で再計算するようにします.そのために,更新用のメソッドupdateAreaIfNeeded()
を定義し,各setterから呼び出すようにします.
void updateAreaIfNeeded();
void CalculatorModel::setHight(int value)
{
if (m_hight != value)
{
m_hight = value;
emit hightChanged();
updateAreaIfNeeded();
}
}
void CalculatorModel::updateAreaIfNeeded()
{
if (m_continuousCalculate)
{
calculateArea();
}
}
最後に,このViewModelをQML側で使用できるよう,main()
関数で登録します:
CalculatorModel calculatorModel;
engine.rootContext()->setContextProperty("calculatorModel", &calculatorModel);
コードの全体像は次のようになります.
C++コード全体
#include "CalculatorModel.h"
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QQuickStyle>
int main(int argc, char *argv[])
{
QQuickStyle::setStyle("Material");
QGuiApplication app(argc, argv);
CalculatorModel calculatorModel;
QQmlApplicationEngine engine;
engine.rootContext()->setContextProperty("calculatorModel", &calculatorModel);
engine.load(QUrl(QStringLiteral("qrc:/main.qml")));
if (engine.rootObjects().isEmpty())
{
return -1;
}
return app.exec();
}
#pragma once
#include <QObject>
class CalculatorModel : public QObject
{
Q_OBJECT
Q_PROPERTY(int hight READ hight WRITE setHight NOTIFY hightChanged)
Q_PROPERTY(int width READ width WRITE setWidth NOTIFY widthChanged)
Q_PROPERTY(int area READ area NOTIFY areaChanged)
Q_PROPERTY(bool continuousCalculate READ continuousCalculate WRITE setContinuousCalculate NOTIFY continuousCalculateChanged)
public:
explicit CalculatorModel(QObject *parent = nullptr);
int hight() const;
void setHight(int value);
int width() const;
void setWidth(int value);
int area() const;
bool continuousCalculate() const;
void setContinuousCalculate(bool value);
Q_INVOKABLE void calculateArea();
signals:
void hightChanged();
void widthChanged();
void areaChanged();
void continuousCalculateChanged();
private:
int m_hight = 0;
int m_width = 0;
int m_area = 0;
bool m_continuousCalculate = false;
void updateAreaIfNeeded();
};
#include "CalculatorModel.h"
CalculatorModel::CalculatorModel(QObject *parent) : QObject(parent)
{
}
int CalculatorModel::hight() const
{
return m_hight;
}
void CalculatorModel::setHight(int value)
{
if (m_hight != value)
{
m_hight = value;
emit hightChanged();
updateAreaIfNeeded();
}
}
int CalculatorModel::width() const
{
return m_width;
}
void CalculatorModel::setWidth(int value)
{
if (m_width != value)
{
m_width = value;
emit widthChanged();
updateAreaIfNeeded();
}
}
int CalculatorModel::area() const
{
return m_area;
}
bool CalculatorModel::continuousCalculate() const
{
return m_continuousCalculate;
}
void CalculatorModel::setContinuousCalculate(bool value)
{
if (m_continuousCalculate != value)
{
m_continuousCalculate = value;
emit continuousCalculateChanged();
updateAreaIfNeeded();
}
}
void CalculatorModel::calculateArea()
{
int newArea = m_hight * m_width;
if (m_area != newArea)
{
m_area = newArea;
emit areaChanged();
}
}
void CalculatorModel::updateAreaIfNeeded()
{
if (m_continuousCalculate)
{
calculateArea();
}
}
View(QML側)
この画面は,新しく作成したCalculator.qml
に実装します.
QML側では,UIコンポーネントとViewModelのプロパティをバインディングする形で構成します.ViewModelは先ほど登録したcalculatorModel
という名前でアクセスできます.
たとえば,hight
に関するUI要素は以下のようになります:
Label {
text: "Hight"
font.pointSize: 11
font.bold: true
}
Label {
text: calculatorModel.hight.toString()
}
Slider {
from: 0
to: 100
value: calculatorModel.hight
onValueChanged: calculatorModel.hight = value
}
CALCULATE
ボタンを押したときは,以下のようにViewModelのメソッドを呼び出します:
Button {
text: "Calculate"
onClicked: calculatorModel.calculateArea()
}
最後に,main.qml
ではLoader
を使ってCalculator.qml
を読み込んで表示します:
Loader {
source: "Calculator.qml"
anchors.fill: parent
}
コードの全体像は次のようになります.
QMLコード全体
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Controls.Material 2.15
ApplicationWindow {
width: 600
height: 480
visible: true
title: qsTr("qtgarden 🌱")
font.family: "Segoe UI"
font.pointSize: 12
Material.theme: Material.Light
Material.accent: Material.Pink
Loader {
source: "Calculator.qml"
anchors.fill: parent
}
}
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
Item {
anchors.fill: parent
ColumnLayout {
spacing: 20
anchors.centerIn: parent
RowLayout {
spacing: 40
ColumnLayout {
spacing: 8
Label {
text: "Hight"
font.pointSize: 11
font.bold: true
}
Label {
text: calculatorModel.hight.toString()
}
Slider {
from: 0
to: 100
value: calculatorModel.hight
onValueChanged: calculatorModel.hight = value
}
}
ColumnLayout {
spacing: 8
Label {
text: "Width"
font.pointSize: 11
font.bold: true
}
Label {
text: calculatorModel.width.toString()
}
Slider {
from: 0
to: 100
value: calculatorModel.width
onValueChanged: calculatorModel.width = value
}
}
}
Switch {
text: "Continuous calculate"
font.bold: true
checked: calculatorModel.continuousCalculate
onToggled: calculatorModel.continuousCalculate = checked
}
Button {
text: "Calculate"
width: 120
hoverEnabled: true
onClicked: calculatorModel.calculateArea()
}
ColumnLayout {
spacing: 8
Label {
text: "Area"
font.bold: true
}
Label {
text: calculatorModel.area.toString()
}
Slider {
from: 0
to: 10000
value: calculatorModel.area
enabled: false
}
}
}
}
これでViewModel / Model(C++ 側)とView(QML 側)が双方向にバインディングされ,UIの状態とデータが自然に連動するようになります.
上記のコードでは,Materialデザインのスタイルを指定して標準の見た目をカスタマイズしています.
C++側のコードでは,QQuickStyle
を使用してアプリケーション全体のスタイルを指定します:
#include <QQuickStyle>
int main(int argc, char *argv[])
{
QQuickStyle::setStyle("Material");
}
また,QML側でもテーマやアクセントカラーを個別に設定できます:
import QtQuick.Controls.Material 2.15
ApplicationWindow {
Material.theme: Material.Light
Material.accent: Material.Pink
}
これにより,アプリ全体の外観を一貫したMaterialデザインに統一することができます.なお,Materialのほかにも,FusionやImagineなど,さまざまなスタイルが用意されています.
リスト形式のデータのバインディング
ここでは,以下のようなToDoアプリ風のUIを構築していきます.
この画面では,以下のような動作を実現します:
- 画面上部のテキストボックスに入力して
ADD
ボタンを押すと,下部のリストに内容が追加されます - リスト内のチェックボックスをONにすると,タスクが「完了済み」として表示されます
- リスト内の❌ボタンをクリックすると,該当のタスクが削除されます
- 「Show only incomplete」スイッチをONにすると,未完了のタスクのみが表示されます
MVVMに基づいた設計方針
このTodoアプリのサンプルでは,前章よりも複雑な構造になっているため,Model-View-ViewModel(MVVM)アーキテクチャの意義がより明確になります.
各層の役割を整理すると次のようになります.
ViewModel / Model(C++側)
-
TodoItem
構造体は,1つのタスク(タイトルと完了状態)を表現します -
TodoListModel
は,タスクのリスト(vector<TodoItem>
)を保持し,QMLに公開可能なQAbstractListModel
を継承し実装します - タスクの追加・削除・完了状態の変更などのロジックは,すべてViewModelに集約されています
- 表示フィルタの状態も
filterUndoneOnly
プロパティで管理し,表示対象のリストを自動的に切り替えます
View(QML側)
- テキスト入力,スイッチ,チェックボックス,削除ボタンなどのUIコンポーネントを配置します
-
ListView
とTodoListModel
をバインドし,必要なDelegate(1行の見た目)を定義します - 入力やチェック状態などのユーザ操作は,直接C++コードを意識せず,ViewModel経由で処理されます
MVVMの効果
この構成により,以下のような利点が得られます:
- QML側はあくまでUI定義に集中し,データ構造や処理の詳細を気にせず記述できます
- タスクの状態や表示ロジック(たとえばフィルタ)はすべてC++のViewModelが一元管理するため,責務が明確になります
- UIの変更(見た目やレイアウト)はQMLだけで完結でき,モデルの再利用性やテストのしやすさも高まります
このように,Viewとロジックを分離し双方向バインディングでつなぐことで,複雑な動作を持つアプリでも保守性の高い設計を実現できます.
コードのポイント
これまでの設計方針に沿って,実際にコードを実装していきます.
ViewModel / Model(C++側)
まず,1つのタスクを表す構造体 TodoItem
を定義します.これは通常のC++構造体として定義できます:
struct TodoItem
{
QString contents;
bool done;
};
次に,QML側と連携するViewModel(TodoListModel
)を実装します.リスト形式のViewModelはQAbstractListModel
を継承します.先ほどのTodoItem
のstd::vector
を内部に保持します:
#pragma once
#include <QAbstractListModel>
#include <vector>
class TodoListModel : public QAbstractListModel
{
Q_OBJECT
public:
explicit TodoListModel(QObject *parent = nullptr);
private:
std::vector<TodoItem> m_items;
};
TodoListModel
では以下のメソッドをオーバーライドします:
- rowCount()
モデルに含まれる要素数(行数)を返します - data()
指定された行とロール(列)に対応するデータを返します - roleNames()
QML側でアクセス可能なロール名のマッピングを返します
ロールは次のようにenum
で定義し,Qt::UserRole + n
という形で番号を割り当てます.Q_ENUM
マクロはQML側からenumにアクセスするためのもので,今回のケースでは省略しても動作に支障はありません:
enum Roles
{
ContentsRole = Qt::UserRole + 1,
DoneRole
};
Q_ENUM(Roles)
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role) const override;
QHash<int, QByteArray> roleNames() const override;
int TodoListModel::rowCount(const QModelIndex &) const
{
return static_cast<int>(m_items.size());
}
QVariant TodoListModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid())
{
return {};
}
int realIndex = index.row();
if (realIndex >= static_cast<int>(m_items.size()))
{
return {};
}
switch (role)
{
case ContentsRole:
return item.contents;
case DoneRole:
return item.done;
default:
return {};
}
}
QHash<int, QByteArray> TodoListModel::roleNames() const
{
return {
{ContentsRole, "contents"},
{DoneRole, "done"},
};
}
上記は最小限の実装ですが,UI側で「Show only incomplete」スイッチが有効なときには,完了していないタスクだけを表示する必要があります.したがって,実際のrowCount()
やdata()
ではその状態を考慮した実装が必要です.
タスクの追加・削除・完了状態の切り替えには,以下のようなメソッドを定義します.Q_INVOKABLE
マクロを付けることで,QML側から呼び出せるようになります.
- データの追加時には
beginInsertRows()
/endInsertRows()
で挟み,変更範囲のindexを渡します - 削除時は
beginRemoveRows()
/endRemoveRows()
を使います - データ内容の変更時には
dataChanged()
シグナルでViewに通知します
以下は一例です:
Q_INVOKABLE void addItem(const QString &text);
Q_INVOKABLE void removeItem(int index);
Q_INVOKABLE void toggleDone(int index);
void TodoListModel::addItem(const QString &text)
{
beginInsertRows(QModelIndex(), rowCount(), rowCount());
m_items.push_back({text, false});
endInsertRows();
}
void TodoListModel::removeItem(int index)
{
if (index < 0 || index >= rowCount())
{
return;
}
beginRemoveRows(QModelIndex(), index, index);
m_items.erase(m_items.begin() + index);
endRemoveRows();
}
void TodoListModel::toggleDone(int index)
{
if (index < 0 || index >= rowCount())
{
return;
}
m_items[index].done = !m_items[index].done;
emit dataChanged(this->index(index), this->index(index));
}
上記のindex
は「表示上の行番号」であるため,UI側で「Show only incomplete」スイッチが有効なときには,実際のm_items
上のインデックスとのズレを解消するロジックが別途必要です.
完了タスクの表示・非表示の切り替えには,以下のようなbool型のプロパティを用意します.状態フラグm_filterUndoneOnly
を更新する際は,その前後をbeginResetModel()
/ endResetModel()
で囲むことで,View側にリスト全体の再描画を正しく通知できます.
また,プロパティfilterUndoneOnly
が変更されたことをQML側に通知するために,filterUndoneOnlyChanged()
のシグナルも忘れずにemit
する必要があります:
public:
Q_PROPERTY(bool filterUndoneOnly READ filterUndoneOnly WRITE setFilterUndoneOnly NOTIFY filterUndoneOnlyChanged)
bool filterUndoneOnly() const;
void setFilterUndoneOnly(bool value);
signals:
void filterUndoneOnlyChanged();
private:
bool m_filterUndoneOnly = false;
bool TodoListModel::filterUndoneOnly() const
{
return m_filterUndoneOnly;
}
void TodoListModel::setFilterUndoneOnly(bool value)
{
if (m_filterUndoneOnly != value)
{
beginResetModel();
m_filterUndoneOnly = value;
endResetModel();
emit filterUndoneOnlyChanged();
}
}
最後に,このViewModelも前章と同様に,QML側から使用できるようmain()
関数内で登録します:
TodoListModel todoModel;
engine.rootContext()->setContextProperty("todoModel", &todoModel);
コードの全体像は次のようになります.
C++コード全体
#include "CalculatorModel.h"
#include "TodoListModel.h"
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QQuickStyle>
int main(int argc, char *argv[])
{
QQuickStyle::setStyle("Material");
QGuiApplication app(argc, argv);
CalculatorModel calculatorModel;
TodoListModel todoModel;
QQmlApplicationEngine engine;
engine.rootContext()->setContextProperty("calculatorModel", &calculatorModel);
engine.rootContext()->setContextProperty("todoModel", &todoModel);
engine.load(QUrl(QStringLiteral("qrc:/qml/main.qml")));
if (engine.rootObjects().isEmpty())
{
return -1;
}
return app.exec();
}
#pragma once
#include <QAbstractListModel>
#include <vector>
struct TodoItem
{
QString contents;
bool done;
};
class TodoListModel : public QAbstractListModel
{
Q_OBJECT
Q_PROPERTY(bool filterUndoneOnly READ filterUndoneOnly WRITE setFilterUndoneOnly NOTIFY filterUndoneOnlyChanged)
public:
enum Roles
{
ContentsRole = Qt::UserRole + 1,
DoneRole
};
Q_ENUM(Roles)
explicit TodoListModel(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role) const override;
QHash<int, QByteArray> roleNames() const override;
bool filterUndoneOnly() const;
void setFilterUndoneOnly(bool value);
Q_INVOKABLE void addItem(const QString &text);
Q_INVOKABLE void removeItem(int index);
Q_INVOKABLE void toggleDone(int index);
signals:
void filterUndoneOnlyChanged();
private:
std::vector<TodoItem> m_items;
bool m_filterUndoneOnly = false;
int visibleIndexToRealIndex(int visibleIndex) const;
};
#include "TodoListModel.h"
TodoListModel::TodoListModel(QObject *parent) : QAbstractListModel(parent)
{
}
int TodoListModel::rowCount(const QModelIndex &) const
{
if (!m_filterUndoneOnly)
{
return static_cast<int>(m_items.size());
}
return std::count_if(m_items.begin(), m_items.end(),
[](const TodoItem &item)
{
return !item.done;
});
}
QVariant TodoListModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid())
{
return {};
}
int realIndex = visibleIndexToRealIndex(index.row());
if (realIndex < 0 || realIndex >= static_cast<int>(m_items.size()))
{
return {};
}
const TodoItem &item = m_items[realIndex];
switch (role)
{
case ContentsRole:
return item.contents;
case DoneRole:
return item.done;
default:
return {};
}
}
QHash<int, QByteArray> TodoListModel::roleNames() const
{
return {
{ContentsRole, "contents"},
{DoneRole, "done"},
};
}
bool TodoListModel::filterUndoneOnly() const
{
return m_filterUndoneOnly;
}
void TodoListModel::setFilterUndoneOnly(bool value)
{
if (m_filterUndoneOnly != value)
{
beginResetModel();
m_filterUndoneOnly = value;
endResetModel();
emit filterUndoneOnlyChanged();
}
}
void TodoListModel::addItem(const QString &text)
{
beginInsertRows(QModelIndex(), rowCount(), rowCount());
m_items.push_back({text, false});
endInsertRows();
}
void TodoListModel::removeItem(int index)
{
if (index < 0 || index >= rowCount())
{
return;
}
int realIndex = visibleIndexToRealIndex(index);
if (realIndex < 0 || realIndex >= static_cast<int>(m_items.size()))
{
return;
}
beginRemoveRows(QModelIndex(), index, index);
m_items.erase(m_items.begin() + realIndex);
endRemoveRows();
}
void TodoListModel::toggleDone(int index)
{
if (index < 0 || index >= rowCount())
{
return;
}
int realIndex = visibleIndexToRealIndex(index);
if (realIndex < 0 || realIndex >= static_cast<int>(m_items.size()))
{
return;
}
if (m_filterUndoneOnly)
{
beginResetModel();
m_items[realIndex].done = !m_items[realIndex].done;
endResetModel();
}
else
{
m_items[realIndex].done = !m_items[realIndex].done;
emit dataChanged(this->index(index), this->index(index));
}
}
int TodoListModel::visibleIndexToRealIndex(int visibleIndex) const
{
if (!m_filterUndoneOnly)
{
return visibleIndex;
}
int count = -1;
for (int i = 0; i < static_cast<int>(m_items.size()); i++)
{
if (!m_items[i].done)
{
count++;
}
if (count == visibleIndex)
{
return i;
}
}
return -1;
}
View(QML側)
この画面用に新たにQMLファイル(TodoList.qml
)を作成してUIを実装します.
まずは新しいタスクを追加するUIを用意します.TextField
に入力し,Button
からViewModelの追加メソッドを呼び出します:
TextField {
id: inputField
placeholderText: "Add new item"
Layout.preferredWidth: 300
}
Button {
text: "Add"
onClicked: {
if (inputField.text.length > 0) {
todoModel.addItem(inputField.text);
inputField.text = "";
}
}
}
フィルター切り替えスイッチで「未完了タスクのみ表示」に切り替えます:
Switch {
text: "Show only incomplete"
font.bold: true
checked: todoModel.filterUndoneOnly
onToggled: todoModel.filterUndoneOnly = checked
}
ToDoリストの表示にはListView
を使用します.model
にViewModelをバインドし,delegate
によって1アイテムの表示内容を定義します.以下のように,表示・操作をすべてViewModelと連携しています:
ListView {
model: todoModel
delegate: Rectangle {
width: parent ? parent.width : 0
height: 40
color: done ? "lavenderblush" : "ghostwhite"
border.width: 1
border.color: "gainsboro"
RowLayout {
spacing: 10
anchors.verticalCenter: parent.verticalCenter
anchors.fill: parent
anchors.topMargin: -5
CheckBox {
id: checkbox
checked: done
onToggled: todoModel.toggleDone(index)
}
Label {
text: contents
font.strikeout: done
font.pixelSize: 18
Layout.fillWidth: true
}
Button {
text: "❌"
onClicked: todoModel.removeItem(index)
background.visible: false
}
}
}
}
コードの全体像は次のようになります.
QMLコード全体
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Controls.Material 2.15
ApplicationWindow {
width: 600
height: 480
visible: true
title: qsTr("qtgarden 🌱")
font.family: "Segoe UI"
font.pointSize: 12
Material.theme: Material.Light
Material.accent: Material.Pink
SwipeView {
id: view
anchors.fill: parent
interactive: true
Loader {
source: "Calculator.qml"
}
Loader {
source: "TodoList.qml"
}
}
PageIndicator {
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
currentIndex: view.currentIndex
count: view.count
}
}
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
Item {
anchors.fill: parent
ColumnLayout {
anchors.centerIn: parent
spacing: 20
RowLayout {
spacing: 10
TextField {
id: inputField
placeholderText: "Add new item"
Layout.preferredWidth: 300
}
Button {
text: "Add"
onClicked: {
if (inputField.text.length > 0) {
todoModel.addItem(inputField.text);
inputField.text = "";
}
}
}
}
Switch {
text: "Show only incomplete"
font.bold: true
checked: todoModel.filterUndoneOnly
onToggled: todoModel.filterUndoneOnly = checked
}
ListView {
Layout.preferredWidth: 400
Layout.preferredHeight: 300
model: todoModel
delegate: Rectangle {
width: parent ? parent.width : 0
height: 40
color: done ? "lavenderblush" : "ghostwhite"
border.width: 1
border.color: "gainsboro"
RowLayout {
spacing: 10
anchors.verticalCenter: parent.verticalCenter
anchors.fill: parent
anchors.topMargin: -5
CheckBox {
id: checkbox
checked: done
onToggled: todoModel.toggleDone(index)
}
Label {
text: contents
font.strikeout: done
font.pixelSize: 18
Layout.fillWidth: true
}
Button {
text: "❌"
onClicked: todoModel.removeItem(index)
background.visible: false
}
}
}
}
}
}
このようにして,Model(C++)とView(QML)を双方向に接続することで,タスクの追加・削除・状態変更がUIに対して即座に反映される,自然なデータ連動型のToDoアプリが構築できます.
上記のコードでは,前の章で作成した画面に加えて,ToDoアプリの画面をmain.qml
に追加し,SwipeView
を使ってそれらを並べて表示しています.スワイプ操作によって画面を切り替えられるようになっています.
SwipeView {
id: view
anchors.fill: parent
interactive: true
Loader {
source: "Calculator.qml"
}
Loader {
source: "TodoList.qml"
}
}
PageIndicator {
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
currentIndex: view.currentIndex
count: view.count
}
おわりに
本記事では,QtのQMLとC++を組み合わせたMVVMアーキテクチャによるアプリの実装を通じて,Model・View・ViewModelの役割分担と,それぞれの連携方法について紹介しました.
シンプルなUIであれば,従来の実装スタイルと比べてMVVMは少し回りくどく感じるかもしれません.しかし,UIが複雑になったり状態管理が増えてくると,MVVMの恩恵は非常に大きくなります.責務の分離によって,UIの修正がモデルロジックに影響を与えにくくなり,保守性や拡張性が飛躍的に向上します.
最初はやや取っつきにくいかもしれませんが,MVVMはQt/QMLでの開発をより効率的で堅牢なものにする重要な設計手法です.ぜひこの機会に習得して,自分のプロジェクトにも取り入れてみてください.