QMLで写真を自由に配置・操作するデスクトップビューア

Qt Widgets から Qt Quick/QML へ移行すると「UI をもっと気軽に作れるはずだが、複雑なインタラクションは Widgets の方が楽では?」と思いがちです。実際に触ってみると、そんな先入観は簡単に崩れます。本稿では、シンプルなコードで写真を並べ、ドラッグ/ピンチ/ホイールで拡大・縮小・回転できるデスクトップアプリケーション「PhotoSurface」を題材に、QML の実装を解説します。

完成イメージ

起動すると Pictures フォルダ内の画像がランダムに重なって表示され、以下の操作が可能です。

  • マウスホイール:縦スクロールで拡大縮小、横スクロールで回転
  • Ctrl+ホイール:回転
  • マルチタッチ:二本指でピンチ/回転、一本指でドラッグ
  • 左上のフォルダアイコン:任意フォルダを開く

図1:PhotoSurface の実行画面(省略)

C++ 側で準備するデータ

QML だけで完結させることもできますが、以下の情報を C++ 側で取得してコンテキストプロパティとして公開することで、プラットフォーム差を吸収します。

サポート画像フォーマットの列挙

static QStringList supportedImagePatterns()
{
    QStringList patterns;
    QMimeDatabase db;
    for (const QByteArray &mime : QImageReader::supportedMimeTypes()) {
        for (const QString &suffix : db.mimeTypeForName(mime).suffixes())
            patterns.append(QStringLiteral("*.") + suffix);
    }
    return patterns;
}

ピクチャフォルダのパス取得

QQmlApplicationEngine engine;
QQmlContext *ctx = engine.rootContext();

QUrl picturesUrl = QUrl::fromLocalFile(QDir::homePath());
const QStringList dirs =
    QStandardPaths::standardLocations(QStandardPaths::PicturesLocation);
if (!dirs.isEmpty()) {
    picturesUrl = QUrl::fromLocalFile(dirs.first());
}

ctx->setContextProperty("picturesPath", picturesUrl);
ctx->setContextProperty("imagePatterns", supportedImagePatterns());

QML 実装のポイント

1. フォルダ選択ダイアログ

FileDialog {
    id: dirPicker
    title: qsTr("画像フォルダを選択")
    selectFolder: true
    folder: picturesPath
    onAccepted: listModel.folder = selectedFolder + "/"
}

2. 初期化処理

Component.onCompleted: {
    listModel.nameFilters = imagePatterns;
    if (picturesPath) {
        listModel.folder = picturesPath + "/";
    } else {
        dirPicker.open();
    }
}

3. フォルダアイコンとツールチップ

Image {
    id: folderIcon
    anchors { top: parent.top; left: parent.left; margins: 10 }
    source: "qrc:/icons/folder.png"

    MouseArea {
        anchors.fill: parent
        onClicked: dirPicker.open()

        hoverEnabled: true
        onEntered: tip.show(mouse.x, mouse.y)
        onExited:  tip.hide()
    }

    Shortcut {
        sequence: StandardKey.Open
        onActivated: dirPicker.open()
    }
}

4. スクロールバーの自作

Flickable の内容サイズに応じて、シンプルな Rectangle でフェードイン/アウトするスクロールバーを配置します。

Rectangle {
    id: vScroll
    anchors { right: parent.right; margins: 2 }
    width: 6; radius: 3
    color: "#33ffffff"
    height: flick.height * (flick.height / flick.contentHeight)
    y: flick.contentY * (flick.height / flick.contentHeight)

    NumberAnimation on opacity { to: 0; duration: 400 }
    onYChanged: { opacity = 1.0; fadeTimer.restart() }
}

5. 写真グリッド(Flickable + Repeater)

Flickable {
    id: flick
    anchors.fill: parent
    contentWidth: width  * 2
    contentHeight: height * 2

    Repeater {
        model: FolderListModel {
            id: listModel
            showDirs: false
            nameFilters: imagePatterns
        }

        delegate: PhotoFrame {
            imageSource: listModel.folder + fileName
        }
    }
}

6. 一枚の写真を表す PhotoFrame.qml

Rectangle {
    id: frame
    property url  imageSource
    property real baseScale: 0.2
    color: "transparent"
    border { width: 2; color: "black" }

    Image {
        id: img
        anchors.centerIn: parent
        source: imageSource
        fillMode: Image.PreserveAspectFit
        antialiasing: true
    }

    Component.onCompleted: {
        scale  = baseScale
        x      = Math.random() * flick.width  - width  / 2
        y      = Math.random() * flick.height - height / 2
        rotation = Math.random() * 20 - 10
    }

    PinchArea {
        anchors.fill: parent
        pinch.target: frame
        pinch.minimumScale: 0.1
        pinch.maximumScale: 10
        pinch.minimumRotation: -360
        pinch.maximumRotation:  360

        onPinchStarted: frame.bringToFront()
        onSmartZoom: frame.smartZoom(pinch.scale)

        MouseArea {
            drag.target: frame
            onPressed: frame.bringToFront()
            onWheel: {
                if (wheel.modifiers & Qt.ControlModifier)
                    frame.rotation += wheel.angleDelta.y / 120 * 5;
                else
                    frame.scale += frame.scale * wheel.angleDelta.y / 1200;
            }
        }
    }

    function bringToFront() {
        frame.z = ++root.topZ;
        frame.border.color = "red";
    }

    function smartZoom(factor) {
        if (factor > 0) {
            frame.rotation = 0;
            frame.scale = Math.min(root.width, root.height) /
                          Math.max(img.sourceSize.width, img.sourceSize.height) * 0.9;
            frame.x = flick.contentX + (flick.width  - frame.width)  / 2;
            frame.y = flick.contentY + (flick.height - frame.height) / 2;
        } else {
            // 元の位置・サイズに戻す
        }
    }
}

まとめ

以上のように、QML の宣言的記述と PinchArea、MouseArea、Flickable などのビルトインタイプを組み合わせることで、ドラッグ/ピンチ/ホイールといった複雑なジェスチャも数十行程度で実装できます。Qt Quick を使えば「UI を変更するたびに C++ をコンパイルし直す」という負荷もなく、プロトタイピングや本番開発の両方で大きなメリットを得られるでしょう。

タグ: QML QtQuick PinchArea Flickable FolderListModel

6月24日 20:45 投稿