由于老周的示例代码都是用 VS Code + CMake + Qt 写的,为了不误导人,在标题中还是加上“VS Code”好一些。
上次咱们研究了剪贴板的基本用法,也了解了叫 QMimeData 的重要类。为啥要强调这个类?因为接下来扯到的拖放操作也是和它有关系。哦,对了,咱们先避开一下主题,关于剪贴板,咱们还要说一点:就是如何监听剪贴板内数据的变化并做出响应。这个嘛,就有点像迅雷监听剪贴板的功能,发现你复制的东西里包含有下载地址的话,就自动弹出新下载任务窗口。
QClipboard 类有好几个满足此功能的信号,说这个前咱们要先知道一下 QClipboard 类包含一个 Mode 枚举。这个枚举定义了三个成员:
QClipboard::Clipboard:数据存储在全局剪贴板中。此模式是各系统通用的,尤其是 Windows。
QClipboard::Selection:通过鼠标选取数据。X 窗口系统是 C/S 架构,数据选择后会发送到目标窗口,可用鼠标中键粘贴。
QClipboard::FindBuffer:macOS 专用的粘贴方式。
所以,我们写代码时一般不刻意指定某个 Mode,以保证好的兼容性。现在,咱们回头再看看 QClipboard 类的几个信号。
selectionChanged:当全局鼠标选取的数据改变时发出,这个用在 Linux/X11 窗口系统上。
findBufferChanged:一样道理,只在 macOS 上能用到。
dataChanged:这个比较推荐,不考虑 Mode,只要剪贴板上的数据有变化就会发出,通用性好。
changed:这个最灵活,在发出信号时,会带上一个 Mode 参数,你在代码中处理时可以对 Mode 进行分析。
综上所述,要是只关心剪贴板上的数据变化,连接 dataChanged 信号最合适。下面来个例子。
CMakeLists.txt:
cmake_minimum_required(VERSION 3.0.0) set(CMAKE_AUTOMOC ON) project(myapp VERSION 0.1.0) find_package(Qt6 COMPONENTS Core Gui Widgets) # 头文件与源码文件都在当前目录下,“.”是当前目录 include_directories(.) set(SRC_LIST CustWindow.cpp main.cpp) add_executable(myapp ${SRC_LIST}) target_link_libraries(myapp PRIVATE Qt6::Core Qt6::Gui Qt6::Widgets )
CustWindow.h:
#ifndef CUST_H #define CUST_H #include <QApplication> #include <QWidget> #include <QMimeData> #include <QClipboard> #include <QListWidget> class MyWindow : public QListWidget { Q_OBJECT public: MyWindow(QWidget* parent=nullptr); private: void onDataChanged(); }; #endif
onDataChanged 是私有成员,待会儿用来连接 QClipboard::dataChanged 信号。这个例子中,老周选用的基类是 QListWidget,它是 QListView 的子类,但用起来比 QListView 方便,不需要手动设置 View / Model,直接可以 addItem,很省事。此处老周是想当剪贴板上放入新的文本数据时在 QListWidget 上添加一个子项。
下面是实现代码:
#include "CustWindow.h" MyWindow::MyWindow(QWidget *parent) : QListWidget(parent) { // 获取剪贴板引用 QClipboard* clb = QGuiApplication::clipboard(); // 连接信号 connect(clb, &QClipboard::changed, this, &MyWindow::onDataChanged); } void MyWindow::onDataChanged() { QClipboard* clipbd = QApplication::clipboard(); QString s = clipbd ->text(); // 如果剪贴板中包含文本,那么字符串不为空 if(!s.isEmpty()) { // 显示文本 this->addItem("你复制了:" + s); } }
代码并不复杂,重要事情有二:第一,连接 QClipboard::dataChanged 信号,与 onDataChanged 方法绑定。第二,在 onDataChanged 方法内,读取剪贴板上的文本数据,组成新的字符串,调用 addItem 方法,把字符串添加到 QListWidget (基类)对象中。
main 函数的代码就那样了,先创建应用程序对象,然后初始化、显示窗口,再进入事件循环。都是老套路了。
#include "CustWindow.h" #include <QApplication> int main(int argc, char** argv) { QApplication app(argc, argv); MyWindow win; win.setWindowTitle("监视粘贴板"); win.resize(350, 320); win.show(); return app.exec(); }
顺便说一下,exec 其实是静态成员,但调用时用变量名或类然都可以。变量名就用成员运算符“.”,类名就用成员运算符“::”。
运行程序后,随便找个地方复制一些文本,然后回到程序窗口,你会有惊喜的。
上图表明,程序已经能监听剪贴板的数据变化了。
------------------------------------------------------------- 量子分隔线 ------------------------------------------------------------
好了,下面开始咱们的主题——拖放。这两个动词言简意赅,包含了两个行为:
a、拖(Drag):数据发送者,发起数据共享操作。此行为一般是鼠标(或笔,或手指,或其他)在某个对象上按下并移动特定距离后触发。
b、放(Drop):把拖动的数据放置到目标对象上,数据接收者提取到数据内容,并结束整个共享操作。一般是松开鼠标按键(或笔,或手指,或其他)时结束拖放操作。
由于拖放操作是由鼠标等指针设备引发的,为了减少误操作,通常会附加两个约束条件:
1、鼠标按下后一段时间,这个时间可以很短。可通过 QApplication::setStartDragTime 方法设置你喜欢的值,单位是毫秒。默认 500 ms。
2、鼠标按下后必须移动一定的距离。这个距离可以从 QApplication::startDragDistance 方法获取,也可以通过 setStartDragDistance 方法修改。这距离指的是“曼哈顿”距离,这个距离是两个点在与X轴和Y轴平行的距离之和,就是正东、正西、正南、正北的方向。总之不是直线距离,这是为了避开大量浮点、开平方等复杂运算,提升速度。具体可以查资料。不懂这个也不影响编程,Qt 的 QPoint 类自带 manhattanLength 方法,可以获得两点相减后的曼哈顿距离。
----------------------------------------------------------------------------------------------------
这个类是拖放操作的核心,因为它的 exec 方法会启动一个拖放操作。拖放操作与剪贴板类似,也是使用 QMimeData 类来封送数据的。在调用 QDrag::exec 之前要用 setMimeData 方法设置要传递的数据。
exec 方法返回时,拖放操作已结束。其返回值是 Qt::DropAction 枚举,拖放操作完成时所返回的值可由数据接收者设置。
该枚举定义下面几个值:
1、CopyAction:表示拖放操作将复制数据;
2、MoveAction:表示拖放操作会移动数据;
3、LinkAction:仅仅建立从数据源到数据目标的链接;
4、IgnoreAction:无操作(忽略)。
其实,这些 Action 是反馈给用户看的,在数据传递的过程中毫无干扰。也就是说,不管是 Copy 还是 Move,只不过是一种“语义”,具体怎么处理数据,还是 coder 说了算。
DropAction 的不同取值会改变鼠标指针的图标,所以说这些值是给用户看的。详细可粗略看看下面表格,不需要深挖。
复制 | |
移动 | |
链接 |
“复制”是箭头右下角显示加号(+),“移动”是显示向右的箭头,“链接”是一个“右转”大箭头。如果忽略或禁止拖放,就是大家熟悉的一个圈圈里面一条斜线——。
在调用 QDrag::exec 方法时你可以指定 DropAction 值,通常有两个参数要赋值:
Qt::DropAction exec(Qt::DropActions supportedActions = Qt::MoveAction); Qt::DropAction exec(Qt::DropActions supportedActions, Qt::DropAction defaultAction);
cmake_minimum_required(VERSION 3.20) project(DragDemo LANGUAGES CXX) set(CMAKE_AUTOMOC ON) find_package( Qt6 COMPONENTS Core Gui Widgets REQUIRED ) # 找到项目下所有头文件和源文件 file(GLOB_RECURSE SRC_LIST include/*.h src/*.cpp) include_directories(include) add_executable(DragDemo WIN32 ${SRC_LIST}) target_link_libraries( DragDemo PRIVATE Qt6::Core Qt6::Gui Qt6::Widgets )
代码插件没有 CMake 的,老周用的是 C++ 的插入,因为里面出现了 /*,被识别成了注释,所以上面内容后半部分全绿了。
项目结构是这样的:
下面是头文件。
#pragma once #include <QWidget> #include <QPainter> #include <QMouseEvent> class Demo : public QWidget { Q_OBJECT public: Demo(QWidget* parent=nullptr); protected: void paintEvent(QPaintEvent* event) override; void mousePressEvent(QMouseEvent* event) override; void mouseMoveEvent(QMouseEvent* event) override; private: // 这个私有变量用来临时存储鼠标按下的坐标 QPoint m_curpt; };
这个类没什么特别的,就是一个自定义窗口。其中,重写 paintEvent 方法,在窗口上画提示文字。这个只为了好看,你可以省略。
重点是重写 mousePress 和 mouseMove 两个事件,mousePress 时记下鼠标按下的坐标,然后在 mouseMove 中再次获取鼠标的坐标,和按下时的坐标相减,看看它们的曼哈顿距离是否符合启动拖放的条件。
咱们来看实现代码。
Demo::Demo(QWidget *parent) { this->setWindowTitle("拖动示例"); this->resize(258, 240); this->move(659, 520); } void Demo::paintEvent(QPaintEvent *event) { QRect rect=event->rect(); // 在窗口上绘制文本 QFont font; font.setFamily("华文仿宋"); //字体名称 font.setPointSize(24); //字体大小(点) font.setBold(true); //加粗 QPainter painter(this); // 设置字体 painter.setFont(font); // 计算一下文本所占空间 QString textToDraw = "从此窗口拖动"; QRect textRect = painter.fontMetrics().boundingRect(rect, Qt::AlignCenter, textToDraw); // 移动文本矩形,让它的中心点和窗口矩形的中心点对齐 textRect.moveCenter(rect.center()); // 设置绘制文本的画笔 QPen pen; pen.setColor(QColor("red")); painter.setPen(pen); // 开始涂鸦 painter.drawText(textRect.toRectF(), textToDraw); painter.end(); } void Demo::mousePressEvent(QMouseEvent *event) { // 获取鼠标按下的坐标点 m_curpt = event->pos(); } void Demo::mouseMoveEvent(QMouseEvent *event) { // 获取鼠标现在的位置坐标 QPoint curloc = event->pos(); // 和刚才按下去的坐标比较 if((m_curpt - curloc).manhattanLength() < QApplication::startDragDistance()) { // 距离不够,不启动拖放 return; } // 准备拖放 QString str = "石灰水化死尸可作化肥"; //要传送的数据 QMimeData* mdata = new QMimeData; // 打包 mdata -> setText(str); // 发快递 // QDrag(QObject *dragSource) // dragSource 指的是发起拖放操作的对象 // 这里是当前窗口 QDrag drag(this); // 设置数据 drag.setMimeData(mdata); // 出发 auto result = drag.exec(Qt::CopyAction | Qt::LinkAction, Qt::CopyAction); QString displaymsg = "数据传递完毕,操作结果:"; if(result & Qt::CopyAction) { displaymsg += "复制"; } else if(result & Qt::LinkAction) { displaymsg += "链接"; } else if(result & Qt::IgnoreAction) { displaymsg += "忽略"; } else { displaymsg += "未知"; } QMessageBox::information(this, "提示", displaymsg, QMessageBox::Ok); }
paintEvent 的重写不是重点,不过老周简单说下。
a、创建 QFont 实例,你看名字都知道是什么鬼了,是的,设置字体参数;
b、计算文本”从此窗口拖动“要占多少空间,核心是调用 QFontMetrics 类的 boundingRect 方法。这里要注意,调用的是这个重载:
QRect QFontMetrics::boundingRect(const QRect &r, int flags, const QString &text, int tabstops = 0, int *tabarray = (int *)nullptr) const
也就是说,不能调用只传文本的重载,那个重载计算出来的 rect 宽度会变小,导致绘制出来的字符串少了一个字符(原因不明)。但,调用上面这个有N多参数的重载是没问题。区别就在于给也一个 r 参数,这个参数提供一个矩形区域作为约束。这里老周用整个窗口的空间作为约束。可能是给的空间足够大,所以计算出来的宽度就足够。于是老周厚着脸皮翻了一下 Qt 的源码,这两重载所使用的处理方法不一样,参数比较多的那个里面调用的是 qt_format_text 函数,参数较少的那个里面用的是 QStackTextEngine 类。有兴趣的伙伴可以去翻翻。
moveCenter 是使矩形平移,并且中心点对准窗口矩形区域的中心点。这里可以让绘制的文本处在窗口的中央。
接下来说说 mousePress 事件,这里就很简单了,就是直接记录鼠标的位置。不过,有点不严谨,拖放操作没听说过用鼠标右键操作的吧?所以,此处最好判断一下,是不是左键按下。
void Demo::mousePressEvent(QMouseEvent *event) { if(!(event -> buttons() & Qt::LeftButton)) { return; } // 获取鼠标按下的坐标点 m_curpt = event->pos(); }
mouseMove 事件也是如此。
void Demo::mouseMoveEvent(QMouseEvent *event) { if(!(event -> buttons() & Qt::LeftButton)) { return; } …… }
QDrag::exec 方法是在 mouseMove 事件中启动的,这个就和剪贴板的操作相似了。先创建 QMimeData,设置文本数据,然后创建 QDrag 实例,设置 MimeData,然后就调用 exec 方法。
最后是整个程序的 main 函数。
int main(int argc, char* argv[]) { QApplication app(argc, argv); Demo window; window.show(); return QApplication::exec(); }
运行示例后,打开一个文本编辑器(如记事本),在窗口上按下鼠标左键,拖到文本编辑器,文本就发送到目标窗口了。
然后,咱们来看 drop 操作。
要想让某个组件支持放置行为,你必须调用:
setAcceptDrops(true);
默认是不开启的,所以必须调用一次 setAcceptDrops 方法。
当某个组件(可以是窗口,按钮,标签,文本框等)支持放置行为后,把数据拖到该组件上会引发 dragEnter、dragMove 等事件;释放鼠标时会发生 drop 事件,表示整个拖放操作结束。这个上文已讲过,下面重点看几个事件参数。注意了,这几个厮实际上是有继承关系的。
class QDropEvent : public QEvent class QDragMoveEvent : public QDropEvent class QDragEnterEvent : public QDragMoveEvent // 下面这个是特例 class QDragLeaveEvent : public QEvent
QDragLeaveEvent 是直接派生自 QEvent 的,因为它是在 dragLeave 事件发生时使用,数据被拖出当前对象,一般不需要额外携带什么参数,所以这个事件类比较特殊。
QDropEvent 类用于 drop 事件,因为这时候你得读取数据了,所以它会夹带私货。这些私货分两类:
1、跟鼠标有关的。比如 buttons 返回鼠标按下了哪个键;modifiers 返回值表示用户是否在拖动的同时按下 Ctrl、Alt、Shift 等按键。position 返回鼠标指针的当前坐标。这些参数咱们通常用不上的。
2、和共享的数据相关的。这个是最需要的。mimeData 返回 QMimeData 对象的指针,然后咱们就能读数据了。source 返回发起拖放操作的对象,一般我们的程序不太关注数据源。
不管读不读取数据,作为数据接收者,我们是文明的,有礼貌的。拖放操作完成时咱们应该响应一下发送者—— QDrag::exec 方法(如果数据是从其他程序拖过来的,那么,拖放的发起者就不一定是调用 exec 方法,毕竟人家不见得是用 Qt 写的,说不定是用 WPF 做的)。
扯远了,回到主题,向数据发送者反馈,还是涉及到了 DropActions 的事。DropEvent 提供了这些成员,可以访问 action。
1、possibleActions 方法,对应的是 exec 方法的 supportedActions 参数;
2、proposedAction 方法,对应 exec 方法的 defaultAction 参数。
还记得前文说过的 exec 方法的两个参数吗?嗯,是滴,possibleActions 就是 supportedActions 参数提供的有效范围,你只能在这些值中选一个。proposedAction 是建议的值,也就是 defaultAction 参数提供的默认值。
所以,如果我们的程序比较在意使用什么 action 的话,你得好好分析一下这两个方法返回的值了。不过,多数时候,我们只关心 mimeData 返回的内容,因为那是要提取的数据。
如果你成功接收了数据,那么要调用 acceptProposedAction 方法,表示数据和 defaultAction 你都接受了。
如果你不想用 defaultAction 参数推荐的默认 action,那么,你可以调用 QDropEvent::setDropAction 方法自己设置一个 action,但你设置的 action 必须在 possibleActions 中允许的。如果你调用了 setDropAction 方法,就等于修改了默认 action,所以这时候你只能调用 accept 方法来接受,不能再调用 acceptProposedAction 方法了。不然,acceptProposedAction 方法会还原默认 action 的值。
如果你发现数据不是你想要的,或者数据发送者给的 DropAction 你不接受,那你就调用 ignore 方法忽略,或者你什么都不做也可以(默认会 ignore 掉事件)。
QDragEnterEvent 和 QDragMoveEvent 都是 QDropEvent 的子类,所以成员都是差不多的。就不用老周再废话了。
了解这几个类的关系,你就知道怎么处理接收拖动的过程了。下面我们来个例子,把图片文件拖到咱们的程序,然后会显示该图片。就是拖动打开文件了。
从 QLabel 类派生出一个类,咱们就用它来接收并显示图片。Qt 没有专门显示图片的组件,一般用 QLabel 来显示图片。当然,QPushButton 等按钮组件也可以显示图片,不过通常用作显示小图标。有大伙伴会说,QGraphicsView 什么什么的不用吗?那个就太大动作了,简直是杀小强用牛刀,没有必须,我就想显示个图片而已。
#pragma once #include <QLabel> #include <QDragEnterEvent> #include <QResizeEvent> #include <QDropEvent> class MyLabel : public QLabel { Q_OBJECT public: MyLabel(QWidget* parent=nullptr); protected: void dragEnterEvent(QDragEnterEvent* event) override; void dropEvent(QDropEvent* event) override; void resizeEvent(QResizeEvent* event) override; private: // 用来缓存图像 QPixmap m_image; };
事件不算多,就重写三个事件。另外,还声明了一个私有成员 m_image 用来存图像资源。你可能会问了,QLabel 不是可以设置和获取 QPixmap 对象吗,为什么要特地用一个私有成员来保存?因为 QLabel 上显示的图像,咱们一般会缩小一下再显示。经过缩小后的 QPixmap 对象,再重新放大就变得很模糊了。所以,QLabel::Pixmap 不保存原图。
在构造函数中,让这个标签组件支持放置。
MyLabel::MyLabel(QWidget *parent) : QLabel(parent) { this->setAcceptDrops(true); this->setStyleSheet("background-color: gray"); }
setAcceptDrops 开启 drop 支持。还有一个是 setStyleSheet,这里老周是用 QSS 来设置标签的背景颜色为难看的灰色。这是 Qt 搞的装X玩意儿,用起来有点像 HTML 中的 CSS。
又有伙伴问了,QLabel 不是有个带 text 参数的构造函数吗?对,不过这里不需要,咱们这个自定义组件不显示文本。
然后,实现 resizeEvent,当大小改变时,咱们也调整一下标签上的图像大小(其实是重新加载缩放过的图像)。
void MyLabel::resizeEvent(QResizeEvent *event) { if(!m_image.isNull()) { // 获取当前新调整的大小 QSize labelsize = event->size(); // 缩放图像 auto pixmap = m_image.scaled(labelsize, Qt::KeepAspectRatio, Qt::SmoothTransformation); // 重新设置图像 this->setPixmap(pixmap); } }
最后就是跟drop 有关的两个事件了。
void MyLabel::dragEnterEvent(QDragEnterEvent *event) { // 检查一下是不是所需要的数据 const QMimeData *data = event->mimeData(); if (data->hasUrls()) { event->acceptProposedAction(); } } void MyLabel::dropEvent(QDropEvent *event) { // 再次验证一下数据 const QMimeData *data = event->mimeData(); if (data->hasUrls()) { // 读数据 QList<QUrl> paths = data->urls(); if (paths.size() > 0) { QUrl p = paths.at(0); QString locfile = p.toLocalFile(); m_image.load(locfile); } // 缩放一下 auto pix = m_image.scaled(this->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); this->setPixmap(pix); event->acceptProposedAction(); } }
dragEnter 的时候,只是看看有没有想要的数据,不读。读取是在 drop 事件中完成。但是为了防止概率 0.001% 的灵异事件发生,在 drop 事件处理时还要再检验一下数据是不是有效。
文件拖进来,一般是 URL 类型,获取到的对象是 QUrl 类型,它的格式是 file:///xxxxx,这个路径在 load 方法中加载不了,于是得用 toLocalFile 方法,将 URL 转换为本地文件路径,这样就能在 QPixmap::load 方法中加载图像了。
下面,定义一个窗口,实例化两个 MyLabel 组件,放在网格布局中第一行的两个单元格内。
/* 头文件 */ #pragma once #include <QWidget> #include "custlabel.h" #include <QGridLayout> class MyWindow : public QWidget { Q_OBJECT public: MyWindow(QWidget* parent=nullptr); private: MyLabel *lbImg1, *lbImg2; QLabel *lb1, *lb2; QGridLayout *layout; };
两个 QLabel 组件用来显示普通文本,咱们自己弄的 MyLabel 组件用来显示图片。QGridLayout 是布局用的,以网格形式布局(行、列)。
MyWindow::MyWindow(QWidget *parent) :QWidget(parent) { setWindowTitle("放置图像"); resize(450, 400); // 初始化 lb1 = new QLabel("美琪", this); lb2 = new QLabel("美雪", this); lb1->setSizePolicy(QSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed)); lb2->setSizePolicy(QSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed)); lbImg1 = new MyLabel(this); lbImg2 = new MyLabel(this); layout = new QGridLayout(this); // 布局 layout->addWidget(lbImg1, 0, 0); layout->addWidget(lbImg2, 0, 1); layout->addWidget(lb1, 1, 0); layout->addWidget(lb2, 1, 1); }
setSizePolicy 那两行是为了让 QLabel 组件的高度固定,因为 QGridLayout 这个王八不能设置固定的行高和列宽,所以只能出此下策了。
写上 main 函数。
int main(int argc, char* argv[]) { QApplication app(argc, argv); MyWindow win; win.show(); return QApplication::exec(); }
运行程序后,就可以把图片文件拖到两个 MyLabel 上了。注意左边是美琪,右边是美雪,下面的标签是她俩的名字。
本篇专门扯一下有关 QCheckBox 组件的一个问题。老周不水字数,直接上程序,你看了就明白。 #include #include #include #include #include