1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

アプリ実装で学ぶ!Qt × QMLでMVVMプログラミング入門

Posted at

はじめに

この記事では,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アプリケーションの最小構成は以下のようになります:

main.cpp
#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を定義します:

main.qml
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ファイルの内容は次のとおりです:

qml.qrc
<RCC>
    <qresource prefix="/">
        <file>main.qml</file>
    </qresource>
</RCC>

ビルドと実行

ここまで準備が整ったら,以下のコマンドでビルド・実行してみましょう:

$ mkdir build && cd build
$ qmake ..
$ make -j4
$ ./helloqt

実行すると,中央に「Click Me」ボタンを配置したウィンドウが開き,クリック時にログが出力されるはずです.これがQt/QMLアプリの最小構成です.

image.png

今回は,ビルドにQt専用のビルドツールであるqmakeを使用しましたが,一般的に広く使われているCMakeを使ってビルドすることも可能です.特にQt6以降では,CMakeが公式に推奨されているビルドシステムとなっています.
以下に,同じHello WorldアプリをCMakeで構成する場合のCMakeLists.txtの例を示します.

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
)

筆者はコーディングにVS Codeを使用していますが,Qt公式からはVS Code用の拡張機能「Qt Extension Pack」が提供されています.これを導入することで,QMLファイルに対する入力補完やコードフォーマットなどが利用可能になり,開発がより快適になります.VS CodeでQtを使う場合は,導入を強くおすすめします.
image.png

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にも追記してください:

helloqt.pro
# Input
SOURCES += src/main.cpp src/bar.cpp  # パスを更新・追記
HEADERS += src/bar.h                 # ヘッダファイルを追記

main.cppの修正

QMLファイルの読み込みパスも合わせて更新します:

main.cpp
engine.load(QUrl(QStringLiteral("qrc:/qml/main.qml")));  // パスを修正

qml.qrcの更新

QMLファイルを正しく読み込むため,リソースファイルのパスも更新します:

qml.qrc
<RCC>
    <qresource prefix="/">
        <file>qml/main.qml</file>
    </qresource>
</RCC>

これで今後QMLやC++ファイルが増えても,構造的に整理されたプロジェクトとして管理しやすくなります.

シンプルなデータのバインディング

まずは以下のようなUIを構築していきます.

image.png

この画面では以下のような動作を実現します:

  • HightWidthのスライダーを操作すると,その上に表示されている数値ラベルがリアルタイムに更新されます
  • CALCULATE ボタンをクリックすると,HightWidthの値を掛け合わせて面積(Area)を算出し,その結果が下部の数値ラベルとスライダーに反映されます
  • Continuous calculateのスイッチをONにすると,スライダーを操作するたびにAreaの値がリアルタイムで再計算されて反映されます

MVVMに基づいた設計方針

本サンプルの設計では,以下のようにMVVMアーキテクチャに沿って役割を分けます.

ViewModel / Model(C++側)

  • 各プロパティ(hight, width, continuousCalculateなど)をQ_PROPERTYとして定義し,QMLからアクセスできるようにします
  • areaは読み取り専用プロパティとして公開します(QML側からは直接変更できない)
  • CALCULATEボタン用に面積を再計算するメソッド(Q_INVOKABLE)を実装します
  • 必要に応じて,hightwidthが更新された際に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プロパティの実装は以下のようになります.

CalculatorModel.h
#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は実際の保持値で,setHighthightはそれに対するsetter / getterです.
また,プロパティ変更の通知にはQt独自のsignalsを使います.これは関数のように宣言しますが,実装を書く必要はありません(Qtのビルド時に自動生成されます).
setterの中では,値が変わったときにemitを使ってシグナルを発行します.

CalculatorModel.cpp
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を省略します:

CalculatorModel.h
    Q_PROPERTY(int area READ area NOTIFY areaChanged)

CALCULATEボタン用に,面積を再計算するメソッドは次のように実装し,Q_INVOKABLEマクロでQML側から呼び出せるようにします:

CalculatorModel.h
    Q_INVOKABLE void calculateArea();
CalculatorModel.cpp
void CalculatorModel::calculateArea()
{
    int newArea = m_hight * m_width;
    if (m_area != newArea)
    {
        m_area = newArea;
        emit areaChanged();
    }
}

さらに,continuousCalculateが有効なときは,hightやwidthの変更時に自動で再計算するようにします.そのために,更新用のメソッドupdateAreaIfNeeded()を定義し,各setterから呼び出すようにします.

CalculatorModel.h
    void updateAreaIfNeeded();
CalculatorModel.cpp
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()関数で登録します:

main.cpp
    CalculatorModel calculatorModel;
    engine.rootContext()->setContextProperty("calculatorModel", &calculatorModel);

コードの全体像は次のようになります.

C++コード全体
main.cpp
#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();
}
CalculatorModel.h
#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();
};
CalculatorModel.cpp
#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要素は以下のようになります:

Calculator.qml
    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のメソッドを呼び出します:

Calculator.qml
    Button {
        text: "Calculate"
        onClicked: calculatorModel.calculateArea()
    }

最後に,main.qmlではLoaderを使ってCalculator.qmlを読み込んで表示します:

main.qml
    Loader {
        source: "Calculator.qml"
        anchors.fill: parent
    }

コードの全体像は次のようになります.

QMLコード全体
main.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
    }
}
Calculator.qml
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を使用してアプリケーション全体のスタイルを指定します:

main.cpp
#include <QQuickStyle>

int main(int argc, char *argv[])
{
    QQuickStyle::setStyle("Material");
}

また,QML側でもテーマやアクセントカラーを個別に設定できます:

main.qml
import QtQuick.Controls.Material 2.15

ApplicationWindow {
    Material.theme: Material.Light
    Material.accent: Material.Pink
}

これにより,アプリ全体の外観を一貫したMaterialデザインに統一することができます.なお,Materialのほかにも,FusionやImagineなど,さまざまなスタイルが用意されています.

リスト形式のデータのバインディング

ここでは,以下のようなToDoアプリ風のUIを構築していきます.

image.png

この画面では,以下のような動作を実現します:

  • 画面上部のテキストボックスに入力して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コンポーネントを配置します
  • ListViewTodoListModelをバインドし,必要なDelegate(1行の見た目)を定義します
  • 入力やチェック状態などのユーザ操作は,直接C++コードを意識せず,ViewModel経由で処理されます

MVVMの効果

この構成により,以下のような利点が得られます:

  • QML側はあくまでUI定義に集中し,データ構造や処理の詳細を気にせず記述できます
  • タスクの状態や表示ロジック(たとえばフィルタ)はすべてC++のViewModelが一元管理するため,責務が明確になります
  • UIの変更(見た目やレイアウト)はQMLだけで完結でき,モデルの再利用性やテストのしやすさも高まります

このように,Viewとロジックを分離し双方向バインディングでつなぐことで,複雑な動作を持つアプリでも保守性の高い設計を実現できます.

コードのポイント

これまでの設計方針に沿って,実際にコードを実装していきます.

ViewModel / Model(C++側)

まず,1つのタスクを表す構造体 TodoItem を定義します.これは通常のC++構造体として定義できます:

TodoListModel.h
struct TodoItem
{
    QString contents;
    bool done;
};

次に,QML側と連携するViewModel(TodoListModel)を実装します.リスト形式のViewModelはQAbstractListModelを継承します.先ほどのTodoItemstd::vectorを内部に保持します:

TodoListModel.h
#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にアクセスするためのもので,今回のケースでは省略しても動作に支障はありません:

TodoListModel.h
    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;
TodoListModel.cpp
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に通知します

以下は一例です:

TodoListModel.h
    Q_INVOKABLE void addItem(const QString &text);
    Q_INVOKABLE void removeItem(int index);
    Q_INVOKABLE void toggleDone(int index);
TodoListModel.cpp
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する必要があります:

TodoListModel.h
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;
TodoListModel.cpp
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()関数内で登録します:

main.cpp
    TodoListModel todoModel;
    engine.rootContext()->setContextProperty("todoModel", &todoModel);

コードの全体像は次のようになります.

C++コード全体
main.cpp
#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();
}
TodoListModel.h
#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;
};
TodoListModel.cpp
#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の追加メソッドを呼び出します:

TodoList.qml
    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 = "";
            }
        }
    }

フィルター切り替えスイッチで「未完了タスクのみ表示」に切り替えます:

TodoList.qml
    Switch {
        text: "Show only incomplete"
        font.bold: true
        checked: todoModel.filterUndoneOnly
        onToggled: todoModel.filterUndoneOnly = checked
    }

ToDoリストの表示にはListViewを使用します.modelにViewModelをバインドし,delegateによって1アイテムの表示内容を定義します.以下のように,表示・操作をすべてViewModelと連携しています:

TodoList.qml
    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コード全体
main.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
    }
}
TodoList.qml
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を使ってそれらを並べて表示しています.スワイプ操作によって画面を切り替えられるようになっています.

main.qml
    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での開発をより効率的で堅牢なものにする重要な設計手法です.ぜひこの機会に習得して,自分のプロジェクトにも取り入れてみてください.

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?