由於老周的示例代碼都是用 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 方法,可以獲得兩點相減後的曼哈頓距離。
----------------------------------------------------------------------------------------------------
QDrag類
這個類是拖放操作的核心,因爲它的 exec 方法會啓動一個拖放操作。拖放操作與剪貼板類似,也是使用 QMimeData 類來封送數據的。在調用 QDrag::exec 之前要用 setMimeData 方法設置要傳遞的數據。
exec 方法返回時,拖放操作已結束。其返回值是 Qt::DropAction 枚舉,拖放操作完成時所返回的值可由數據接收者設置。
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 上了。注意左邊是美琪,右邊是美雪,下面的標籤是她倆的名字。