拖拽系列最后两篇的场景都来自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/index 和 text/value。onDropped 取出源块下标 srcIndex,先拒绝"自己拖自己",然后两次 setProperty 完成交换:把源块的值改成空槽的 0(源块变成新空槽),把空槽的值改成源块数字。格子位置由 Grid 托管不动,变的只是 model 里的 value,界面自动刷新。
判赢与步数,一套状态机
moveCount 每步自增并显示。每次移动后调 checkWin(),它调用 isSolved() 遍历前 8 格检查是否依次为 1-8、且第 9 格是 0------全对就把状态文字改成绿色完成提示。shuffle() 随机交换 60 次来打乱,同时把步数清零。这样"打乱---移动---判赢---恢复"是一个完整闭环。
配色高亮传达状态
空槽平时是浅灰、被拖拽悬停时变蓝底加粗描边(emptyDrop.containsDrag),数字块按住时变深蓝。颜色变化都是绑定在 dragHandler.active / containsDrag 上的,同样不用写事件代码,交互反馈即时又直观。
运行验证
- Qt Creator 打开示例的 CMakeLists.txt;
- 按 Ctrl+R 运行;
- 看板:把"待办"里的一张卡片拖到"已完成",看它从原列消失、出现在新列、列计数变化;
- 拼图:点"打乱拼图",把数字块拖到空格逐个交换,拼出 1-8 顺序看完成提示。
已验证环境:
- Qt 版本:Qt 6.11.1
- 操作系统:Windows 11
- GitHub:QML-Minimal-Demos/qml_drag_drop