與連線交互實(shí)踐)
簡(jiǎn)介圍繞 Qt 圖形視圖框架的流程圖繪制實(shí)踐包面向具備一定 C/Qt 基礎(chǔ)、希望掌握?qǐng)D元與視圖交互開(kāi)發(fā)的開(kāi)發(fā)者與 UI 設(shè)計(jì)學(xué)習(xí)者。內(nèi)容以圖元建模、場(chǎng)景管理、視圖事件處理為主線覆蓋流程步驟、連接線、縮放旋轉(zhuǎn)、保存加載等常見(jiàn)功能適合課程設(shè)計(jì)、工具原型和桌面繪圖模塊參考。壓縮包共 36 個(gè)文件約 47KB含 12 個(gè) png 界面圖標(biāo)素材、9 個(gè) cpp 與 8 個(gè) h 源碼及頭文件以及 qrc、pro、user 等 Qt 工程配置與若干 bak 備份文件工程結(jié)構(gòu)較完整便于直接打開(kāi)梳理實(shí)現(xiàn)思路。已有 3999 人學(xué)習(xí)下載。讀者可從中參考自定義圖元、自動(dòng)連線、屬性面板和視圖交互的組織方式也能借鑒序列化與界面資源管理做法用于快速搭建或完善自己的流程圖編輯工具。1. 從一張“連線老對(duì)不上”的流程圖說(shuō)起Qt 圖元與視圖到底怎么分工第一次打開(kāi)這份 Qt 流程圖繪制源碼最容易犯的錯(cuò)是把 QGraphicsView 當(dāng)成一塊畫(huà)布把節(jié)點(diǎn)當(dāng)成 QWidget 往上擺。真跑起來(lái)會(huì)發(fā)現(xiàn)拖拽、縮放、連線吸附、保存回放卡點(diǎn)幾乎都集中在圖元與視圖的坐標(biāo)分工上。這個(gè)資源適合要做桌面流程圖、拓?fù)鋱D、節(jié)點(diǎn)編輯器的開(kāi)發(fā)者核心不是畫(huà)一個(gè)矩形而是讓場(chǎng)景、圖元、連線三套坐標(biāo)能對(duì)得上。先別急著改 paint先把第 2 章的最小骨架跑通再動(dòng)自定義圖元。2. 視圖選型QGraphicsView 場(chǎng)景坐標(biāo)與圖元坐標(biāo)系怎么落地流程圖工具的第一道分水嶺不是節(jié)點(diǎn)畫(huà)得好不好看而是有沒(méi)有把場(chǎng)景、視圖、圖元這三層關(guān)系擺正。QGraphicsView 不是“更高級(jí)的 QWidget”它把圖元管理、坐標(biāo)變換、命中測(cè)試、選擇狀態(tài)拆給了 QGraphicsScene 和 QGraphicsItem視圖只負(fù)責(zé)把場(chǎng)景映射到窗口。這個(gè)分工一旦理解錯(cuò)后面拖拽、縮放、連線都會(huì)變成玄學(xué)偏移。2.1 為什么不用 QWidget 手?jǐn)[控件幾十個(gè)節(jié)點(diǎn)以?xún)?nèi)用 QWidget 絕對(duì)定位也能做甚至上手更快每個(gè)節(jié)點(diǎn)是一個(gè) QWidget連線用一個(gè)透明覆蓋層畫(huà)。節(jié)點(diǎn)一多、縮放一開(kāi)、框選一加問(wèn)題就來(lái)了。QWidget 的幾何變換要自己遍歷改父子控件裁剪和焦點(diǎn)管理會(huì)互相打架框選和命中測(cè)試也得自己寫(xiě)一套。更麻煩的是節(jié)點(diǎn)移動(dòng)時(shí)連線要跟著重算而 QWidget 的 update 區(qū)域不會(huì)自動(dòng)把跨控件的線算進(jìn)去殘影和閃爍很常見(jiàn)。QGraphics 體系解決的不是“畫(huà)圖”而是“管理大量可交互圖元”。場(chǎng)景負(fù)責(zé)圖元索引和事件分發(fā)視圖負(fù)責(zé)視圖矩陣圖元只關(guān)心自己的本地坐標(biāo)和繪制。常見(jiàn)做法是節(jié)點(diǎn)、端口、連線都繼承 QGraphicsItem 或 QGraphicsObject統(tǒng)一進(jìn)場(chǎng)景視圖只做縮放、平移和框選。代價(jià)是坐標(biāo)換算要寫(xiě)清楚不能把視圖坐標(biāo)直接塞給圖元。維度QWidget 絕對(duì)布局QGraphicsView 場(chǎng)景節(jié)點(diǎn)規(guī)模幾十個(gè)較順手?jǐn)?shù)百個(gè)更適合統(tǒng)一管理縮放平移自己遍歷改幾何視圖矩陣一次變換框選與命中自己寫(xiě)規(guī)則內(nèi)置圖元選擇與 itemAt連線管理獨(dú)立覆蓋層連線也是圖元可統(tǒng)一 z 值坐標(biāo)復(fù)雜度控件坐標(biāo)為主視圖、場(chǎng)景、圖元三層選型上我一般會(huì)看兩個(gè)信號(hào)一是節(jié)點(diǎn)會(huì)不會(huì)超過(guò)一百個(gè)二是要不要做縮放后的連線編輯。只要中一個(gè)就別用 QWidget 硬擺。QGraphicsView 不是沒(méi)有成本但它的成本是“學(xué)坐標(biāo)”不是“后期補(bǔ)架構(gòu)”。2.2 場(chǎng)景、視圖、圖元三層坐標(biāo)的換算流程圖里最常見(jiàn)的翻車(chē)是把鼠標(biāo)位置、圖元位置、端口位置混著用。視圖坐標(biāo)是窗口像素場(chǎng)景坐標(biāo)是圖元世界的統(tǒng)一坐標(biāo)圖元本地坐標(biāo)是 paint 和 boundingRect 使用的坐標(biāo)。端口作為節(jié)點(diǎn)的子圖元它的 pos 是相對(duì)父圖元的不是場(chǎng)景坐標(biāo)。連線要拿端口錨點(diǎn)時(shí)必須用mapToScene(0, 0)轉(zhuǎn)出去。坐標(biāo)類(lèi)型常見(jiàn)獲取方式用途視圖坐標(biāo)QMouseEvent::pos()視口內(nèi)的像素位置場(chǎng)景坐標(biāo)view-mapToScene(e-pos())圖元擺放、連線錨點(diǎn)圖元本地坐標(biāo)item-mapFromScene(scenePos)paint、boundingRect、shape父坐標(biāo)item-pos()子圖元相對(duì)父圖元的位置在QGraphicsSceneMouseEvent里scenePos()已經(jīng)是場(chǎng)景坐標(biāo)不要再mapToScene一次。反過(guò)來(lái)從視圖的mousePressEvent拿到的pos()才是視圖坐標(biāo)必須過(guò)一次mapToScene。高 DPI 下也不要手動(dòng)乘devicePixelRatio視圖矩陣已經(jīng)處理了設(shè)備像素比手動(dòng)再乘一次端口就會(huì)偏到懷疑人生。2.3 最小可運(yùn)行骨架場(chǎng)景初始化與視圖參數(shù)先把場(chǎng)景和視圖跑起來(lái)不要一上來(lái)就寫(xiě)連線。下面這段是流程圖視圖的最小骨架重點(diǎn)是場(chǎng)景范圍、縮放錨點(diǎn)和更新模式。#include QGraphicsView #include QGraphicsScene #include QWheelEvent #include QPainter class FlowView : public QGraphicsView { public: explicit FlowView(QWidget* parent nullptr) : QGraphicsView(parent) { auto* scene new QGraphicsScene(this); // 場(chǎng)景范圍要比內(nèi)容大否則圖元一移動(dòng)滾動(dòng)條就跟著跳 scene-setSceneRect(-2000, -2000, 4000, 4000); setScene(scene); setRenderHint(QPainter::Antialiasing, true); setDragMode(QGraphicsView::RubberBandDrag); setViewportUpdateMode(QGraphicsView::BoundingRectViewportUpdate); // 縮放時(shí)盡量讓鼠標(biāo)下的場(chǎng)景點(diǎn)不動(dòng) setTransformationAnchor(QGraphicsView::AnchorUnderMouse); setResizeAnchor(QGraphicsView::AnchorViewCenter); setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOn); setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn); } void zoomBy(qreal factor) { const qreal next transform().m11() * factor; if (next 0.2 || next 4.0) return; scale(factor, factor); } };這段代碼的邏輯是場(chǎng)景先給一個(gè)固定大范圍避免圖元移動(dòng)時(shí)滾動(dòng)條抖動(dòng)視圖開(kāi)啟抗鋸齒和橡皮筋框選更新模式用BoundingRectViewportUpdate只重繪臟區(qū)域比全視口刷新穩(wěn)。AnchorUnderMouse讓縮放圍繞鼠標(biāo)位置進(jìn)行不然每次滾輪都往視圖中心跑編輯大圖時(shí)很難受。參數(shù)上sceneRect不要設(shè)得剛剛好包住內(nèi)容留出負(fù)坐標(biāo)和擴(kuò)展空間0.2到4.0是縮放下限和上限具體可以按業(yè)務(wù)調(diào)但一定要有邊界否則用戶(hù)能把圖縮成一個(gè)點(diǎn)再也找不回來(lái)。RubberBandDrag適合框選節(jié)點(diǎn)后面如果要做空格拖拽平移再單獨(dú)切ScrollHandDrag。3. 自定義圖元矩形節(jié)點(diǎn)、端口與連線的繪制和命中圖元是這套源碼里最值得拆的部分。節(jié)點(diǎn)不是畫(huà)一個(gè)圓角矩形就完事boundingRect 決定重繪區(qū)域shape 決定點(diǎn)擊命中端口決定連線起點(diǎn)連線的 boundingRect 決定拖動(dòng)時(shí)會(huì)不會(huì)留下殘影。圖元寫(xiě)得不干凈視圖參數(shù)調(diào)得再漂亮也救不回來(lái)。3.1 節(jié)點(diǎn)圖元的邊界矩形與 paint 順序節(jié)點(diǎn)圖元優(yōu)先繼承QGraphicsObject這樣既有 QGraphicsItem 的坐標(biāo)能力又能用信號(hào)槽和QPointer做生命周期保護(hù)。boundingRect 要把畫(huà)筆寬度算進(jìn)去畫(huà)筆寬 2 就外擴(kuò) 1漏掉這一像素拖動(dòng)時(shí)邊緣就會(huì)留下細(xì)線殘影。shape 用圓角矩形路徑命中區(qū)域和視覺(jué)區(qū)域保持一致不要用默認(rèn)的 boundingRect 矩形去點(diǎn)圓角外面。#include QGraphicsObject #include QPainter #include QPainterPath class NodeItem : public QGraphicsObject { public: enum { Type UserType 1 }; explicit NodeItem(const QString text, QGraphicsItem* parent nullptr) : QGraphicsObject(parent), m_text(text) { setFlags(ItemIsMovable | ItemIsSelectable | ItemSendsGeometryChanges); setCacheMode(DeviceCoordinateCache); setZValue(0); } int type() const override { return Type; } QRectF boundingRect() const override { // 畫(huà)筆寬度 2boundingRect 要外擴(kuò) 1漏掉就會(huì)留下拖動(dòng)殘影 return QRectF(-60, -30, 120, 60).adjusted(-1, -1, 1, 1); } QPainterPath shape() const override { QPainterPath p; p.addRoundedRect(QRectF(-60, -30, 120, 60), 8, 8); return p; } void paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) override { Q_UNUSED(option); Q_UNUSED(widget); painter-setRenderHint(QPainter::Antialiasing, true); QPen pen(isSelected() ? QColor(0, 120, 215) : QColor(80, 80, 80), 2); painter-setPen(pen); painter-setBrush(QColor(250, 250, 250)); painter-drawRoundedRect(QRectF(-60, -30, 120, 60), 8, 8); painter-setPen(QColor(30, 30, 30)); painter-drawText(QRectF(-52, -22, 104, 44), Qt::AlignCenter | Qt::TextWordWrap, m_text); } private: QString m_text; };邏輯上Type是給qgraphicsitem_cast用的后面從場(chǎng)景里撈節(jié)點(diǎn)、端口都靠它。ItemIsMovable讓節(jié)點(diǎn)能拖ItemIsSelectable讓框選生效ItemSendsGeometryChanges是連線更新的前提。DeviceCoordinateCache適合靜態(tài)節(jié)點(diǎn)能減少重復(fù)繪制但節(jié)點(diǎn)頻繁改大小或高頻動(dòng)畫(huà)時(shí)緩存反而會(huì)增加失效開(kāi)銷(xiāo)這一點(diǎn)要在真實(shí)場(chǎng)景里測(cè)。參數(shù)上節(jié)點(diǎn)寬高120 x 60、圓角8、畫(huà)筆寬2都是示例值改尺寸時(shí)記得同步改 boundingRect 和 shape不能只改 paint 里的矩形。節(jié)點(diǎn)文本用TextWordWrap長(zhǎng)文本會(huì)換行但不會(huì)自動(dòng)撐高節(jié)點(diǎn)要做自適應(yīng)高度得在文本變化時(shí)調(diào)prepareGeometryChange()再改 boundingRect。3.2 端口圖元用 itemAt 做連線起點(diǎn)端口是節(jié)點(diǎn)的子圖元位置相對(duì)節(jié)點(diǎn)視覺(jué)上是一個(gè)小圓點(diǎn)命中區(qū)域要比視覺(jué)大。用戶(hù)不會(huì)精準(zhǔn)點(diǎn)中 6 像素的圓常見(jiàn)做法是視覺(jué)半徑 6、熱區(qū)半徑 11。端口圖元不需要可移動(dòng)但要能被場(chǎng)景items()或視圖itemAt()找到所以 type 必須穩(wěn)定。#include QGraphicsItem #include QPainter class PortItem : public QGraphicsItem { public: enum { Type UserType 2 }; explicit PortItem(const QString id, bool output, QGraphicsItem* parent nullptr) : QGraphicsItem(parent), m_id(id), m_output(output) { setFlag(ItemSendsGeometryChanges, true); setZValue(2); } int type() const override { return Type; } QRectF boundingRect() const override { return QRectF(-7, -7, 14, 14); } QPainterPath shape() const override { QPainterPath p; // 視覺(jué) 14點(diǎn)擊熱區(qū) 22否則小圓點(diǎn)很難點(diǎn)中 p.addEllipse(QRectF(-11, -11, 22, 22)); return p; } void paint(QPainter* painter, const QStyleOptionGraphicsItem*, QWidget*) override { painter-setRenderHint(QPainter::Antialiasing, true); painter-setPen(QPen(QColor(60, 60, 60), 1.5)); painter-setBrush(m_highlighted ? QColor(255, 170, 40) : (m_output ? QColor(80, 170, 255) : QColor(120, 200, 120))); painter-drawEllipse(QPointF(0, 0), 6, 6); } QPointF anchorScenePos() const { return mapToScene(0, 0); } QString id() const { return m_id; } bool isOutput() const { return m_output; } void setHighlighted(bool on) { if (m_highlighted on) return; m_highlighted on; update(); } private: QString m_id; bool m_output false; bool m_highlighted false; };這段的關(guān)鍵是anchorScenePos()。端口在節(jié)點(diǎn)本地坐標(biāo)里通常是(60, 0)或(-60, 0)連線不能直接拿這個(gè)本地坐標(biāo)必須mapToScene(0, 0)轉(zhuǎn)成場(chǎng)景坐標(biāo)。shape()比boundingRect()大是為了擴(kuò)大點(diǎn)擊熱區(qū)但熱區(qū)不要無(wú)限放大否則兩個(gè)相鄰端口會(huì)互相搶事件。端口掛到節(jié)點(diǎn)下之后節(jié)點(diǎn)移動(dòng)端口自動(dòng)跟著走這是父子圖元最省事的地方。注意端口的zValue要高于節(jié)點(diǎn)不然節(jié)點(diǎn)邊框會(huì)蓋住圓點(diǎn)連線的zValue要低于節(jié)點(diǎn)不然線會(huì)壓在節(jié)點(diǎn)文字上。層級(jí)不復(fù)雜但順序一亂視覺(jué)上就很臟。3.3 連線圖元貝塞爾曲線與箭頭收口連線圖元要同時(shí)解決三件事路徑怎么畫(huà)、點(diǎn)擊怎么命中、節(jié)點(diǎn)移動(dòng)后怎么更新。路徑用三次貝塞爾控制點(diǎn)按水平距離取一半最少給 40避免短距離連線變成折角。命中區(qū)域不能只用線寬 2 的路徑要用QPainterPathStroker擴(kuò)到 8 左右不然用戶(hù)永遠(yuǎn)選不中線。#include QGraphicsObject #include QPainterPathStroker #include QLineF #include QPolygonF class ConnectionItem : public QGraphicsObject { public: ConnectionItem(NodeItem* fromNode, PortItem* fromPort, NodeItem* toNode, PortItem* toPort, QGraphicsItem* parent nullptr) : QGraphicsObject(parent), m_fromNode(fromNode), m_fromPort(fromPort), m_toNode(toNode), m_toPort(toPort) { setZValue(-1); setFlag(ItemIsSelectable, true); updatePath(); } QRectF boundingRect() const override { return m_path.boundingRect().adjusted(-12, -12, 12, 12); } QPainterPath shape() const override { QPainterPathStroker stroker; stroker.setWidth(8); // 線寬 2熱區(qū)放大不然很難選中 return stroker.createStroke(m_path); } void paint(QPainter* painter, const QStyleOptionGraphicsItem*, QWidget*) override { painter-setRenderHint(QPainter::Antialiasing, true); QPen pen(isSelected() ? QColor(0, 120, 215) : QColor(70, 70, 70), 2); painter-setPen(pen); painter-setBrush(Qt::NoBrush); painter-drawPath(m_path); const QPointF end m_toPort-anchorScenePos(); const QPointF prev m_path.pointAtPercent(0.92); const qreal angle QLineF(prev, end).angle(); painter-save(); painter-translate(end); painter-rotate(-angle); QPolygonF arrow; arrow QPointF(0, 0) QPointF(-10, -4) QPointF(-10, 4); painter-setBrush(pen.color()); painter-drawPolygon(arrow); painter-restore(); } void updatePath() { prepareGeometryChange(); const QPointF a m_fromPort-anchorScenePos(); const QPointF b m_toPort-anchorScenePos(); QPainterPath p(a); const qreal dx qMax(40.0, qAbs(b.x() - a.x()) * 0.5); p.cubicTo(a QPointF(dx, 0), b - QPointF(dx, 0), b); m_path p; update(); } private: NodeItem* m_fromNode nullptr; NodeItem* m_toNode nullptr; PortItem* m_fromPort nullptr; PortItem* m_toPort nullptr; QPainterPath m_path; };邏輯說(shuō)明updatePath()里先調(diào)prepareGeometryChange()再改路徑這是通知場(chǎng)景舊區(qū)域失效如果順序反了舊線可能留在屏幕上。貝塞爾控制點(diǎn)用水平偏移流程圖看起來(lái)更像工程圖不會(huì)像蜘蛛網(wǎng)。箭頭用路徑 92% 位置的切線方向算角度終點(diǎn)固定到端口錨點(diǎn)縮放時(shí)不會(huì)飄。參數(shù)上控制點(diǎn)最小 40 是經(jīng)驗(yàn)值節(jié)點(diǎn)間距很小的時(shí)候也不至于把線折成直角stroker.setWidth(8)是點(diǎn)擊熱區(qū)線寬本身還是 2。zValue(-1)讓連線在節(jié)點(diǎn)下面但端口在節(jié)點(diǎn)上面三層順序是連線、節(jié)點(diǎn)、端口。節(jié)點(diǎn)移動(dòng)時(shí)節(jié)點(diǎn)圖元要在itemChange(ItemPositionHasChanged)里遍歷自己關(guān)聯(lián)的連線并調(diào)用updatePath()否則線不會(huì)跟著動(dòng)。4. 拖拽、縮放與連線交互事件分發(fā)鏈路怎么接圖元畫(huà)出來(lái)只是靜態(tài)圖流程圖好不好用取決于鼠標(biāo)按下到松開(kāi)的那幾百毫秒。場(chǎng)景負(fù)責(zé)圖元級(jí)事件視圖負(fù)責(zé)視圖級(jí)變換端口命中用場(chǎng)景坐標(biāo)臨時(shí)連線用臨時(shí)圖元松手時(shí)再做目標(biāo)端口校驗(yàn)。鏈路理順以后連線就是“按下端口、移動(dòng)、吸附、松手建線”四步。4.1 鼠標(biāo)按下到臨時(shí)連線scene 事件過(guò)濾不要在視圖里直接判斷端口視圖拿到的是視圖坐標(biāo)還要轉(zhuǎn)場(chǎng)景而且圖元事件會(huì)被場(chǎng)景先分發(fā)。更穩(wěn)的做法是繼承 QGraphicsScene在mousePressEvent里用items(scenePos)找端口找到就創(chuàng)建臨時(shí)路徑圖元吃掉事件找不到再交給基類(lèi)做節(jié)點(diǎn)拖拽和框選。#include QGraphicsScene #include QGraphicsSceneMouseEvent #include QGraphicsPathItem #include QPen class FlowScene : public QGraphicsScene { public: explicit FlowScene(QObject* parent nullptr) : QGraphicsScene(parent) {} protected: void mousePressEvent(QGraphicsSceneMouseEvent* e) override { if (e-button() Qt::LeftButton) { for (QGraphicsItem* item : items(e-scenePos())) { if (auto* port qgraphicsitem_castPortItem*(item)) { m_startPort port; m_tempPath new QGraphicsPathItem; m_tempPath-setPen(QPen(QColor(0, 120, 215), 2, Qt::DashLine)); m_tempPath-setZValue(100); addItem(m_tempPath); e-accept(); return; } } } QGraphicsScene::mousePressEvent(e); } void mouseMoveEvent(QGraphicsSceneMouseEvent* e) override { if (m_startPort m_tempPath) { const QPointF a m_startPort-anchorScenePos(); const QPointF b e-scenePos(); QPainterPath p(a); const qreal dx qMax(40.0, qAbs(b.x() - a.x()) * 0.5); p.cubicTo(a QPointF(dx, 0), b - QPointF(dx, 0), b); m_tempPath-setPath(p); e-accept(); return; } QGraphicsScene::mouseMoveEvent(e); } void mouseReleaseEvent(QGraphicsSceneMouseEvent* e) override { if (m_startPort m_tempPath) { PortItem* target nullptr; for (QGraphicsItem* item : items(e-scenePos())) { if (auto* port qgraphicsitem_castPortItem*(item)) { if (port ! m_startPort) { target port; break; } } } if (target) { // 真實(shí)工程里還要判斷方向、重復(fù)連線和自連 auto* conn new ConnectionItem( qobject_castNodeItem*(m_startPort-parentItem()), m_startPort, qobject_castNodeItem*(target-parentItem()), target); addItem(conn); } delete m_tempPath; m_tempPath nullptr; m_startPort nullptr; e-accept(); return; } QGraphicsScene::mouseReleaseEvent(e); } private: PortItem* m_startPort nullptr; QGraphicsPathItem* m_tempPath nullptr; };這段代碼的邏輯是按下時(shí)用items(scenePos)拿到該點(diǎn)下所有圖元按 z 值從上到下遍歷命中的第一個(gè)端口作為起點(diǎn)移動(dòng)時(shí)不斷把臨時(shí)路徑更新到鼠標(biāo)位置松開(kāi)時(shí)再次命中端口命中就創(chuàng)建正式連線沒(méi)命中就刪除臨時(shí)圖元。參數(shù)上臨時(shí)路徑zValue(100)要足夠高保證拖動(dòng)時(shí)壓在節(jié)點(diǎn)和端口上面虛線畫(huà)筆只是交互提示正式連線用實(shí)線。qobject_castNodeItem*要求節(jié)點(diǎn)是 QGraphicsObject如果節(jié)點(diǎn)不是 QObject就換成dynamic_cast或自己維護(hù)節(jié)點(diǎn)映射。真實(shí)工程里還要加方向校驗(yàn)輸出端口只能連輸入端口重復(fù)連線和自連要攔掉這些校驗(yàn)放在創(chuàng)建 ConnectionItem 之前做。4.2 視圖縮放與平移的邊界控制流程圖視圖的滾輪有兩種常見(jiàn)約定直接滾輪上下平移Ctrl 加滾輪縮放。縮放一定要限幅不然用戶(hù)能把圖縮到 0.01 倍整個(gè)場(chǎng)景變成一?;摇?s放錨點(diǎn)用AnchorUnderMouse讓鼠標(biāo)下的節(jié)點(diǎn)保持位置操作感會(huì)好很多。void FlowView::wheelEvent(QWheelEvent* e) { if (!(e-modifiers() Qt::ControlModifier)) { QGraphicsView::wheelEvent(e p a hrefhttps://download.csdn.net/download/u010177010/9526463 stylecolor:#ec7500;font-size:14px; 本文還有配套的精品資源點(diǎn)擊獲取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p