QML 拖拽篇:看板拖拽与数字拼图

拖拽系列最后两篇的场景都来自2个实用的案例:看板(Trello 式任务管理)和拼图游戏。它们把前面学的 mimeData 传输、DropArea 接收、model 数据操作串起来,做成"拖一张卡片跨列移动"和"拖数字块拼顺序"这样有实用价值的交互。

这篇介绍两个 demo:

  • 看板跨容器拖拽 --- 待办/进行中/已完成三列,卡片可拖到任意列,数据在三个 ListModel 间迁移
  • 网格数字拼图 --- 3×3 九宫格,8 个数字块拖到空格即交换位置,按 1-8 排列获胜

看板跨容器拖拽

经典的三列看板:待办、进行中、已完成。每列是一个独立的 ListModel,卡片是一块带标题和优先级的色块。把任意一张卡片拖到另一列上,卡片就从原列消失、出现在新列,列标题的数字跟着变,拖动时悬停的列会高亮。

演示代码

qml 复制代码
import QtQuick
import QtQuick.Layouts

FadeInAnimation {
    id: root

    // 三个看板列共享的任务数据
    ListModel { id: todoModel }
    ListModel { id: doingModel }
    ListModel { id: doneModel }

    property int moveCount: 0

    Component.onCompleted: {
        todoModel.append({ title: "设计登录页线框图", priority: "高", color: "#FF6B6B" })
        todoModel.append({ title: "编写 API 文档", priority: "中", color: "#FFC107" })
        todoModel.append({ title: "整理测试用例", priority: "低", color: "#4CAF50" })
        doingModel.append({ title: "实现拖拽排序组件", priority: "高", color: "#FF6B6B" })
        doneModel.append({ title: "搭建项目骨架", priority: "中", color: "#FFC107" })
    }

    ColumnLayout {
        anchors.fill: parent
        anchors.margins: 20
        spacing: 12

        // ... 省略标题组件 TitleSeparator ...

        Row {
            Layout.alignment: Qt.AlignHCenter
            spacing: 14

            // ====== 待办列 ======
            Rectangle {
                id: todoCol
                width: 200; height: 320
                radius: 10
                color: todoDrop.containsDrag ? "#EAF4FF" : "#FAFAFA"
                border.color: todoDrop.containsDrag ? "#1296FF" : "#ddd"
                border.width: todoDrop.containsDrag ? 2 : 1

                Behavior on color { ColorAnimation { duration: 120 } }

                ColumnLayout {
                    anchors.fill: parent
                    anchors.margins: 8
                    spacing: 6

                    Text {
                        text: "待办 (" + todoModel.count + ")"
                        font.pointSize: 12; font.bold: true; color: "#333"
                    }

                    ListView {
                        id: todoList
                        Layout.fillWidth: true; Layout.fillHeight: true
                        clip: true
                        spacing: 6
                        model: todoModel
                        displaced: Transition {
                            NumberAnimation { properties: "x,y"; duration: 200; easing.type: Easing.OutQuad }
                        }

                        delegate: cardDelegate

                        DropArea {
                            id: todoDrop
                            anchors.fill: parent
                            onEntered: (drag) => drag.accepted = drag.formats.indexOf("text/source") >= 0
                            onDropped: (drop) => root.moveCard(drop, todoModel, todoList)
                        }
                    }
                }
            }

            // ... 省略"进行中"列 doingCol 与"已完成"列 doneCol(结构与待办列完全一致,
            //     仅高亮配色不同,onDropped 分别指向 doingModel / doneModel)...
        }

        Text {
            id: statusText
            Layout.alignment: Qt.AlignHCenter
            Layout.preferredWidth: 640
            text: "拖拽任一卡片到其他列,卡片数据会在 ListModel 之间迁移"
            font.pointSize: 11
            color: "#666"
            horizontalAlignment: Text.AlignHCenter
            wrapMode: Text.Wrap
        }

        Text {
            Layout.alignment: Qt.AlignHCenter
            text: "累计跨列移动: " + root.moveCount
            font.pointSize: 12
            color: "#1296FF"
            font.bold: true
        }

        Item { Layout.fillHeight: true }
    }

    // 卡片样式:定义在根作用域供三列 ListView 共用
    Component {
        id: cardDelegate

        Rectangle {
            id: card
            width: ListView.view ? ListView.view.width - 4 : 180
            height: 64
            radius: 8
            color: model.color
            opacity: cardDrag.drag.active ? 0.4 : 1.0

            Behavior on opacity { NumberAnimation { duration: 120 } }

            ColumnLayout {
                anchors.fill: parent
                anchors.margins: 8
                spacing: 2

                Text {
                    Layout.fillWidth: true
                    text: model.title
                    color: "white"
                    font.pointSize: 11
                    font.bold: true
                    wrapMode: Text.Wrap
                }
                Text {
                    text: "优先级: " + model.priority
                    color: Qt.rgba(1, 1, 1, 0.85)
                    font.pointSize: 9
                }
            }

            // 标记自己是源 + 记录源模型信息
            Drag.active: cardDrag.drag.active
            Drag.dragType: Drag.Automatic
            Drag.supportedActions: Qt.CopyAction
            Drag.mimeData: {
                "text/source": "card",
                "text/title": model.title,
                "text/priority": model.priority,
                "text/color": model.color,
                "text/index": index.toString()
            }

            MouseArea {
                id: cardDrag
                anchors.fill: parent
                drag.target: parent
                cursorShape: Qt.OpenHandCursor
            }
        }
    }

    // 跨列迁移逻辑:从 drop 数据中取出原卡片,从其源 ListView 的 model 中移除,再追加到目标 model
    function moveCard(drop, targetModel, targetList) {
        if (drop.formats.indexOf("text/source") < 0) {
            drop.accepted = false
            return
        }
        var title = drop.getDataAsString("text/title")
        var priority = drop.getDataAsString("text/priority")
        var color = drop.getDataAsString("text/color")

        // 在三个模型中查找并移除原始卡片
        var removed = false
        if (!removed) removed = removeFromModel(todoModel, title)
        if (!removed) removed = removeFromModel(doingModel, title)
        if (!removed) removed = removeFromModel(doneModel, title)

        // 追加到目标列
        targetModel.append({ title: title, priority: priority, color: color })

        statusText.text = "已将「" + title + "」移动到新列"
        root.moveCount++
        drop.accepted = true
    }

    function removeFromModel(model, title) {
        for (var i = 0; i < model.count; i++) {
            if (model.get(i).title === title) {
                model.remove(i, 1)
                return true
            }
        }
        return false
    }
}

关键逻辑解析

三列三模型,卡片样式抽出来共用

三列不是三个重复的组件,而是同一个 cardDelegate Component 被三列 ListView 的 delegate 复用。卡片数据存在三个独立的 ListModel 里,每列标题上的 todoModel.count 实时显示卡片数。模型和界面分离,拖动改模型、界面自动更新。

卡片当源:mimeData 一次带全字段

卡片上 Drag.mimeData 一次装进五条信息:text/source 声明"我是一张卡片"、text/title / text/priority / text/color 是完整任务数据、text/index 是所在列内的下标。接收方拿到这些,等于拿到了卡的完整"户口",不需要再去原模型查。

跨列接收:目标列只问一句"你是卡片吗"

每列的 DropArea 在 onEntered 里只校验 text/source 这一个类型------只要拖进来的是卡片就放行,不关心卡片来自哪一列。onDropped 统一交给根作用域的 moveCard() 处理,参数带着目标模型。

moveCard:删旧增新,一个函数完成迁移

moveCard 是跨容器拖拽的核心。第一步,用拖拽数据里的 title 去三个模型里挨个找------removeFromModel 遍历每个模型,找到同标题的项就 remove(i, 1) 删除并返回 true,没找到就返回 false 继续找下一个。因为任务标题唯一,这张卡片只会被删一次。第二步,把 title/priority/color 重新 append 进目标模型。删旧加新之后,原列少一张、目标列多一张,看板就"搬"好了。

网格数字拼图

3×3 的九宫格,八格是 1-8 的数字块,右下角是一个空槽。把任意数字块拖到空槽上,两个格子的数字交换------被拖的数字进空槽,空槽挪到原来数字的位置。目标是按 1-8 顺序排好、空槽回到右下角,顶部状态栏实时提示步数,排好会弹出完成提示。

演示代码

qml 复制代码
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

FadeInAnimation {
    id: root

    // 9 个格子,第 9 个为空槽(value === 0)
    ListModel {
        id: puzzleModel
        ListElement { value: 1 }
        ListElement { value: 2 }
        ListElement { value: 3 }
        ListElement { value: 4 }
        ListElement { value: 5 }
        ListElement { value: 6 }
        ListElement { value: 7 }
        ListElement { value: 8 }
        ListElement { value: 0 }
    }

    property int moveCount: 0

    function isSolved() {
        for (var i = 0; i < 8; i++) {
            if (puzzleModel.get(i).value !== i + 1) return false
        }
        return puzzleModel.get(8).value === 0
    }

    function checkWin() {
        if (isSolved()) {
            statusText.text = "🎉 拼图完成!共用了 " + root.moveCount + " 步"
            statusText.color = "#4CAF50"
        }
    }

    function shuffle() {
        for (var i = 0; i < 60; i++) {
            var a = Math.floor(Math.random() * 9)
            var b = Math.floor(Math.random() * 9)
            var va = puzzleModel.get(a).value
            puzzleModel.setProperty(a, "value", puzzleModel.get(b).value)
            puzzleModel.setProperty(b, "value", va)
        }
        root.moveCount = 0
        statusText.text = "已打乱拼图,拖动数字块到空格进行移动"
        statusText.color = "#666"
    }

    ColumnLayout {
        anchors.fill: parent
        anchors.margins: 20
        spacing: 15

        // ... 省略标题组件 TitleSeparator ...

        Grid {
            id: board
            Layout.alignment: Qt.AlignHCenter
            columns: 3
            spacing: 8
            property int cellSize: 90

            Repeater {
                model: puzzleModel

                delegate: Rectangle {
                    id: tile
                    width: board.cellSize
                    height: board.cellSize
                    radius: 10
                    // 空槽:浅色描边方块;数字块:主题色
                    color: value === 0
                           ? (emptyDrop.containsDrag ? "#EAF4FF" : "#F0F0F0")
                           : (dragHandler.active ? "#0E7BD9" : "#1296FF")
                    border.color: value === 0
                                  ? (emptyDrop.containsDrag ? "#1296FF" : "#CCCCCC")
                                  : "#0E7BD9"
                    border.width: value === 0 ? 2 : 0

                    Behavior on color { ColorAnimation { duration: 120 } }

                    Text {
                        anchors.centerIn: parent
                        text: value === 0 ? "" : value
                        color: "white"
                        font.pointSize: 32
                        font.bold: true
                        visible: value !== 0
                    }

                    // 数字块作为拖拽源(空槽不可拖)。
                    // 用 DragHandler 驱动 Drag.active(Qt6 推荐,自动处理拖拽阈值,
                    // 不破坏 Grid 布局);源块位置由 Grid 管理,交换后自动吸附到新格子
                    Drag.active: dragHandler.active
                    Drag.dragType: Drag.Automatic
                    Drag.supportedActions: Qt.CopyAction
                    Drag.mimeData: {
                        "text/index": index.toString(),
                        "text/value": value.toString()
                    }

                    DragHandler {
                        id: dragHandler
                        target: null
                        enabled: value !== 0
                        acceptedButtons: Qt.LeftButton
                        cursorShape: value === 0 ? Qt.ArrowCursor : Qt.OpenHandCursor
                    }

                    // 仅空槽作为放置区
                    DropArea {
                        id: emptyDrop
                        anchors.fill: parent
                        enabled: value === 0

                        onEntered: (drag) => {
                            drag.accepted = drag.formats.indexOf("text/index") >= 0
                        }

                        onDropped: (drop) => {
                            var srcIndex = parseInt(drop.getDataAsString("text/index"))
                            if (srcIndex === index) {
                                drop.accepted = false
                                return
                            }
                            // 交换两个格子的数值
                            var srcValue = puzzleModel.get(srcIndex).value
                            puzzleModel.setProperty(srcIndex, "value", value)       // value 为空槽 0
                            puzzleModel.setProperty(index, "value", srcValue)        // 空槽接收数字

                            root.moveCount++
                            statusText.text = "第 " + root.moveCount + " 步:把 " + srcValue + " 移到空格"
                            statusText.color = "#666"
                            root.checkWin()
                            drop.accepted = true
                        }
                    }
                }
            }
        }

        Text {
            id: statusText
            Layout.alignment: Qt.AlignHCenter
            Layout.preferredWidth: 480
            text: "拖动任一数字块到空格进行移动"
            font.pointSize: 12
            color: "#666"
            horizontalAlignment: Text.AlignHCenter
            wrapMode: Text.Wrap
        }

        RowLayout {
            Layout.alignment: Qt.AlignHCenter
            spacing: 14

            Button {
                text: "打乱拼图"
                onClicked: root.shuffle()
            }

            Button {
                text: "恢复顺序"
                onClicked: {
                    for (var i = 0; i < 8; i++) puzzleModel.setProperty(i, "value", i + 1)
                    puzzleModel.setProperty(8, "value", 0)
                    root.moveCount = 0
                    statusText.text = "已恢复初始顺序"
                    statusText.color = "#666"
                }
            }

            Text {
                text: "步数: " + root.moveCount
                font.pointSize: 12
                color: "#1296FF"
                font.bold: true
            }
        }

        Text {
            Layout.alignment: Qt.AlignHCenter
            Layout.preferredWidth: 480
            text: "提示:每块都可自由拖到空格交换,不限相邻。还原 1-8 顺序(空格在右下角)即完成"
            font.pointSize: 10
            color: "#999"
            horizontalAlignment: Text.AlignHCenter
            wrapMode: Text.Wrap
        }

        Item { Layout.fillHeight: true }
    }
}

关键逻辑解析

空槽当放置区,数字块当源

九宫格里每个 delegate 都既是源又是目标,但通过 enabled 做了角色区分:数字块(value !== 0)的 DragHandler enabled 为 true,可拖;空槽(value === 0)的 DragHandler 禁用,且它的 DropArea enabled: value === 0 才开启------只有空槽能接收。于是"只有空格能接、只有数字能拖",规则在声明里就写死了。

mimeData 带下标,交换只看数据

Drag.mimeData 里装 text/indextext/value。onDropped 取出源块下标 srcIndex,先拒绝"自己拖自己",然后两次 setProperty 完成交换:把源块的值改成空槽的 0(源块变成新空槽),把空槽的值改成源块数字。格子位置由 Grid 托管不动,变的只是 model 里的 value,界面自动刷新。

判赢与步数,一套状态机

moveCount 每步自增并显示。每次移动后调 checkWin(),它调用 isSolved() 遍历前 8 格检查是否依次为 1-8、且第 9 格是 0------全对就把状态文字改成绿色完成提示。shuffle() 随机交换 60 次来打乱,同时把步数清零。这样"打乱---移动---判赢---恢复"是一个完整闭环。

配色高亮传达状态

空槽平时是浅灰、被拖拽悬停时变蓝底加粗描边(emptyDrop.containsDrag),数字块按住时变深蓝。颜色变化都是绑定在 dragHandler.active / containsDrag 上的,同样不用写事件代码,交互反馈即时又直观。

运行验证

  1. Qt Creator 打开示例的 CMakeLists.txt;
  2. 按 Ctrl+R 运行;
  3. 看板:把"待办"里的一张卡片拖到"已完成",看它从原列消失、出现在新列、列计数变化;
  4. 拼图:点"打乱拼图",把数字块拖到空格逐个交换,拼出 1-8 顺序看完成提示。

已验证环境

相关推荐
长友cy1 小时前
Qt官方示例Application源码阅读
开发语言·qt
程与留17 小时前
15_国际化和本地化:tr()、ts 文件、QM 文件、多语言切换
c++·qt
程与留18 小时前
14_Qt 样式表(QSS)入门(语法、选择器、美化实战)
c++·qt
Quz1 天前
QML 拖拽篇:接收拖拽与数据交换
qt
张人玉2 天前
基于 Python 机器学习 与 PyQt5 桌面框架开发的可视化分析系统——基于大数据的网上购物消费行为分析可视化大屏
python·qt·机器学习·echarts·three.js
「QT(C++)开发工程师」2 天前
C++11 std::unique_ptr — 独占所有权的智能指针
c语言·开发语言·c++·qt
小镇敲码人2 天前
【深入浅出】之Qt 事件系统实战:鼠标、键盘、滚轮、窗口与定时器事件
数据库·qt·网络协议·mysql·http
小小龙学IT2 天前
Qt 数据库编程(Qt SQL 模块)深度解析
数据库·sql·qt
Qt历险记2 天前
C++11 std::unique_ptr — 独占所有权的智能指针
qt