欢迎光临
我们一直在努力

QML 拖拽篇:跨列拖拽与数字拼图

目录

    • 看板跨容器拖拽
      • 演示代码
      • 关键逻辑解析
    • 网格数字拼图
      • 演示代码
      • 关键逻辑解析
    • 运行验证
    • 小结
    • 系列文章

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

看板跨容器拖拽

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

在这里插入图片描述

演示代码

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 顺序排好、空槽回到右下角,顶部状态栏实时提示步数,排好会弹出完成提示。

在这里插入图片描述

演示代码

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 顺序看完成提示。
  • 小结

    看板的跨列迁移稍作改动即可支持多卡片批量移动、拖拽到空白区域新建卡片。把"三列"抽象成可配置的列数组,用循环生成列和 DropArea,列数任意扩展。拼图的交换规则若改成"只允许与相邻空格交换",就能做成经典的 15-puzzle 游戏。给卡片加 text/uri-list 支持,可以接收系统文件拖入看板生成任务项。

    系列文章

  • QML 拖拽篇:自由移动与单轴约束
  • QML 拖拽篇:接收拖拽与数据交换
  • QML 拖拽篇:拖拽吸附与列表排序
  • QML 拖拽篇:跨列拖拽与数字拼图(本篇)

  • 已验证环境:

    • Qt 版本:Qt 6.11.1
    • 操作系统:Windows 11
    • 项目地址:QML 示例合集V2.0 – qml_drag_drop
    赞(0)
    未经允许不得转载:171主机测评 » QML 拖拽篇:跨列拖拽与数字拼图
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址