
簡介圍繞 Qt 圖形視圖框架的流程圖繪制實踐包面向具備一定 C/Qt 基礎、希望掌握圖元與視圖交互開發(fā)的開發(fā)者與 UI 設計學習者。內(nèi)容以圖元建模、場景管理、視圖事件處理為主線覆蓋流程步驟、連接線、縮放旋轉(zhuǎn)、保存加載等常見功能適合課程設計、工具原型和桌面繪圖模塊參考。壓縮包共 36 個文件約 47KB含 12 個 png 界面圖標素材、9 個 cpp 與 8 個 h 源碼及頭文件以及 qrc、pro、user 等 Qt 工程配置與若干 bak 備份文件工程結(jié)構較完整便于直接打開梳理實現(xiàn)思路。已有 3999 人學習下載。讀者可從中參考自定義圖元、自動連線、屬性面板和視圖交互的組織方式也能借鑒序列化與界面資源管理做法用于快速搭建或完善自己的流程圖編輯工具。1. 從一張“連線老對不上”的流程圖說起Qt 圖元與視圖到底怎么分工第一次打開這份 Qt 流程圖繪制源碼最容易犯的錯是把 QGraphicsView 當成一塊畫布把節(jié)點當成 QWidget 往上擺。真跑起來會發(fā)現(xiàn)拖拽、縮放、連線吸附、保存回放卡點幾乎都集中在圖元與視圖的坐標分工上。這個資源適合要做桌面流程圖、拓撲圖、節(jié)點編輯器的開發(fā)者核心不是畫一個矩形而是讓場景、圖元、連線三套坐標能對得上。先別急著改 paint先把第 2 章的最小骨架跑通再動自定義圖元。2. 視圖選型QGraphicsView 場景坐標與圖元坐標系怎么落地流程圖工具的第一道分水嶺不是節(jié)點畫得好不好看而是有沒有把場景、視圖、圖元這三層關系擺正。QGraphicsView 不是“更高級的 QWidget”它把圖元管理、坐標變換、命中測試、選擇狀態(tài)拆給了 QGraphicsScene 和 QGraphicsItem視圖只負責把場景映射到窗口。這個分工一旦理解錯后面拖拽、縮放、連線都會變成玄學偏移。2.1 為什么不用 QWidget 手擺控件幾十個節(jié)點以內(nèi)用 QWidget 絕對定位也能做甚至上手更快每個節(jié)點是一個 QWidget連線用一個透明覆蓋層畫。節(jié)點一多、縮放一開、框選一加問題就來了。QWidget 的幾何變換要自己遍歷改父子控件裁剪和焦點管理會互相打架框選和命中測試也得自己寫一套。更麻煩的是節(jié)點移動時連線要跟著重算而 QWidget 的 update 區(qū)域不會自動把跨控件的線算進去殘影和閃爍很常見。QGraphics 體系解決的不是“畫圖”而是“管理大量可交互圖元”。場景負責圖元索引和事件分發(fā)視圖負責視圖矩陣圖元只關心自己的本地坐標和繪制。常見做法是節(jié)點、端口、連線都繼承 QGraphicsItem 或 QGraphicsObject統(tǒng)一進場景視圖只做縮放、平移和框選。代價是坐標換算要寫清楚不能把視圖坐標直接塞給圖元。維度QWidget 絕對布局QGraphicsView 場景節(jié)點規(guī)模幾十個較順手數(shù)百個更適合統(tǒng)一管理縮放平移自己遍歷改幾何視圖矩陣一次變換框選與命中自己寫規(guī)則內(nèi)置圖元選擇與 itemAt連線管理獨立覆蓋層連線也是圖元可統(tǒng)一 z 值坐標復雜度控件坐標為主視圖、場景、圖元三層選型上我一般會看兩個信號一是節(jié)點會不會超過一百個二是要不要做縮放后的連線編輯。只要中一個就別用 QWidget 硬擺。QGraphicsView 不是沒有成本但它的成本是“學坐標”不是“后期補架構”。2.2 場景、視圖、圖元三層坐標的換算流程圖里最常見的翻車是把鼠標位置、圖元位置、端口位置混著用。視圖坐標是窗口像素場景坐標是圖元世界的統(tǒng)一坐標圖元本地坐標是 paint 和 boundingRect 使用的坐標。端口作為節(jié)點的子圖元它的 pos 是相對父圖元的不是場景坐標。連線要拿端口錨點時必須用mapToScene(0, 0)轉(zhuǎn)出去。坐標類型常見獲取方式用途視圖坐標QMouseEvent::pos()視口內(nèi)的像素位置場景坐標view-mapToScene(e-pos())圖元擺放、連線錨點圖元本地坐標item-mapFromScene(scenePos)paint、boundingRect、shape父坐標item-pos()子圖元相對父圖元的位置在QGraphicsSceneMouseEvent里scenePos()已經(jīng)是場景坐標不要再mapToScene一次。反過來從視圖的mousePressEvent拿到的pos()才是視圖坐標必須過一次mapToScene。高 DPI 下也不要手動乘devicePixelRatio視圖矩陣已經(jīng)處理了設備像素比手動再乘一次端口就會偏到懷疑人生。2.3 最小可運行骨架場景初始化與視圖參數(shù)先把場景和視圖跑起來不要一上來就寫連線。下面這段是流程圖視圖的最小骨架重點是場景范圍、縮放錨點和更新模式。#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); // 場景范圍要比內(nèi)容大否則圖元一移動滾動條就跟著跳 scene-setSceneRect(-2000, -2000, 4000, 4000); setScene(scene); setRenderHint(QPainter::Antialiasing, true); setDragMode(QGraphicsView::RubberBandDrag); setViewportUpdateMode(QGraphicsView::BoundingRectViewportUpdate); // 縮放時盡量讓鼠標下的場景點不動 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); } };這段代碼的邏輯是場景先給一個固定大范圍避免圖元移動時滾動條抖動視圖開啟抗鋸齒和橡皮筋框選更新模式用BoundingRectViewportUpdate只重繪臟區(qū)域比全視口刷新穩(wěn)。AnchorUnderMouse讓縮放圍繞鼠標位置進行不然每次滾輪都往視圖中心跑編輯大圖時很難受。參數(shù)上sceneRect不要設得剛剛好包住內(nèi)容留出負坐標和擴展空間0.2到4.0是縮放下限和上限具體可以按業(yè)務調(diào)但一定要有邊界否則用戶能把圖縮成一個點再也找不回來。RubberBandDrag適合框選節(jié)點后面如果要做空格拖拽平移再單獨切ScrollHandDrag。3. 自定義圖元矩形節(jié)點、端口與連線的繪制和命中圖元是這套源碼里最值得拆的部分。節(jié)點不是畫一個圓角矩形就完事boundingRect 決定重繪區(qū)域shape 決定點擊命中端口決定連線起點連線的 boundingRect 決定拖動時會不會留下殘影。圖元寫得不干凈視圖參數(shù)調(diào)得再漂亮也救不回來。3.1 節(jié)點圖元的邊界矩形與 paint 順序節(jié)點圖元優(yōu)先繼承QGraphicsObject這樣既有 QGraphicsItem 的坐標能力又能用信號槽和QPointer做生命周期保護。boundingRect 要把畫筆寬度算進去畫筆寬 2 就外擴 1漏掉這一像素拖動時邊緣就會留下細線殘影。shape 用圓角矩形路徑命中區(qū)域和視覺區(qū)域保持一致不要用默認的 boundingRect 矩形去點圓角外面。#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 { // 畫筆寬度 2boundingRect 要外擴 1漏掉就會留下拖動殘影 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用的后面從場景里撈節(jié)點、端口都靠它。ItemIsMovable讓節(jié)點能拖ItemIsSelectable讓框選生效ItemSendsGeometryChanges是連線更新的前提。DeviceCoordinateCache適合靜態(tài)節(jié)點能減少重復繪制但節(jié)點頻繁改大小或高頻動畫時緩存反而會增加失效開銷這一點要在真實場景里測。參數(shù)上節(jié)點寬高120 x 60、圓角8、畫筆寬2都是示例值改尺寸時記得同步改 boundingRect 和 shape不能只改 paint 里的矩形。節(jié)點文本用TextWordWrap長文本會換行但不會自動撐高節(jié)點要做自適應高度得在文本變化時調(diào)prepareGeometryChange()再改 boundingRect。3.2 端口圖元用 itemAt 做連線起點端口是節(jié)點的子圖元位置相對節(jié)點視覺上是一個小圓點命中區(qū)域要比視覺大。用戶不會精準點中 6 像素的圓常見做法是視覺半徑 6、熱區(qū)半徑 11。端口圖元不需要可移動但要能被場景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; // 視覺 14點擊熱區(qū) 22否則小圓點很難點中 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; };這段的關鍵是anchorScenePos()。端口在節(jié)點本地坐標里通常是(60, 0)或(-60, 0)連線不能直接拿這個本地坐標必須mapToScene(0, 0)轉(zhuǎn)成場景坐標。shape()比boundingRect()大是為了擴大點擊熱區(qū)但熱區(qū)不要無限放大否則兩個相鄰端口會互相搶事件。端口掛到節(jié)點下之后節(jié)點移動端口自動跟著走這是父子圖元最省事的地方。注意端口的zValue要高于節(jié)點不然節(jié)點邊框會蓋住圓點連線的zValue要低于節(jié)點不然線會壓在節(jié)點文字上。層級不復雜但順序一亂視覺上就很臟。3.3 連線圖元貝塞爾曲線與箭頭收口連線圖元要同時解決三件事路徑怎么畫、點擊怎么命中、節(jié)點移動后怎么更新。路徑用三次貝塞爾控制點按水平距離取一半最少給 40避免短距離連線變成折角。命中區(qū)域不能只用線寬 2 的路徑要用QPainterPathStroker擴到 8 左右不然用戶永遠選不中線。#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; };邏輯說明updatePath()里先調(diào)prepareGeometryChange()再改路徑這是通知場景舊區(qū)域失效如果順序反了舊線可能留在屏幕上。貝塞爾控制點用水平偏移流程圖看起來更像工程圖不會像蜘蛛網(wǎng)。箭頭用路徑 92% 位置的切線方向算角度終點固定到端口錨點縮放時不會飄。參數(shù)上控制點最小 40 是經(jīng)驗值節(jié)點間距很小的時候也不至于把線折成直角stroker.setWidth(8)是點擊熱區(qū)線寬本身還是 2。zValue(-1)讓連線在節(jié)點下面但端口在節(jié)點上面三層順序是連線、節(jié)點、端口。節(jié)點移動時節(jié)點圖元要在itemChange(ItemPositionHasChanged)里遍歷自己關聯(lián)的連線并調(diào)用updatePath()否則線不會跟著動。4. 拖拽、縮放與連線交互事件分發(fā)鏈路怎么接圖元畫出來只是靜態(tài)圖流程圖好不好用取決于鼠標按下到松開的那幾百毫秒。場景負責圖元級事件視圖負責視圖級變換端口命中用場景坐標臨時連線用臨時圖元松手時再做目標端口校驗。鏈路理順以后連線就是“按下端口、移動、吸附、松手建線”四步。4.1 鼠標按下到臨時連線scene 事件過濾不要在視圖里直接判斷端口視圖拿到的是視圖坐標還要轉(zhuǎn)場景而且圖元事件會被場景先分發(fā)。更穩(wěn)的做法是繼承 QGraphicsScene在mousePressEvent里用items(scenePos)找端口找到就創(chuàng)建臨時路徑圖元吃掉事件找不到再交給基類做節(jié)點拖拽和框選。#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) { // 真實工程里還要判斷方向、重復連線和自連 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; };這段代碼的邏輯是按下時用items(scenePos)拿到該點下所有圖元按 z 值從上到下遍歷命中的第一個端口作為起點移動時不斷把臨時路徑更新到鼠標位置松開時再次命中端口命中就創(chuàng)建正式連線沒命中就刪除臨時圖元。參數(shù)上臨時路徑zValue(100)要足夠高保證拖動時壓在節(jié)點和端口上面虛線畫筆只是交互提示正式連線用實線。qobject_castNodeItem*要求節(jié)點是 QGraphicsObject如果節(jié)點不是 QObject就換成dynamic_cast或自己維護節(jié)點映射。真實工程里還要加方向校驗輸出端口只能連輸入端口重復連線和自連要攔掉這些校驗放在創(chuàng)建 ConnectionItem 之前做。4.2 視圖縮放與平移的邊界控制流程圖視圖的滾輪有兩種常見約定直接滾輪上下平移Ctrl 加滾輪縮放??s放一定要限幅不然用戶能把圖縮到 0.01 倍整個場景變成一粒灰??s放錨點用AnchorUnderMouse讓鼠標下的節(jié)點保持位置操作感會好很多。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; 本文還有配套的精品資源點擊獲取 /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