ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Qt QGraphicsView流程图绘制:图元坐标与连线交互实践

Qt QGraphicsView流程图绘制:图元坐标与连线交互实践 简介围绕 Qt 图形视图框架的流程图绘制实践包面向具备一定 C/Qt 基础、希望掌握图元与视图交互开发的开发者与 UI 设计学习者。内容以图元建模、场景管理、视图事件处理为主线覆盖流程步骤、连接线、缩放旋转、保存加载等常见功能适合课程设计、工具原型和桌面绘图模块参考。压缩包共 36 个文件约 47KB含 12 个 png 界面图标素材、9 个 cpp 与 8 个 h 源码及头文件以及 qrc、pro、user 等 Qt 工程配置与若干 bak 备份文件工程结构较完整便于直接打开梳理实现思路。已有 3999 人学习下载。读者可从中参考自定义图元、自动连线、属性面板和视图交互的组织方式也能借鉴序列化与界面资源管理做法用于快速搭建或完善自己的流程图编辑工具。1. 从一张“连线老对不上”的流程图说起Qt 图元与视图到底怎么分工第一次打开这份 Qt 流程图绘制源码最容易犯的错是把 QGraphicsView 当成一块画布把节点当成 QWidget 往上摆。真跑起来会发现拖拽、缩放、连线吸附、保存回放卡点几乎都集中在图元与视图的坐标分工上。这个资源适合要做桌面流程图、拓扑图、节点编辑器的开发者核心不是画一个矩形而是让场景、图元、连线三套坐标能对得上。先别急着改 paint先把第 2 章的最小骨架跑通再动自定义图元。2. 视图选型QGraphicsView 场景坐标与图元坐标系怎么落地流程图工具的第一道分水岭不是节点画得好不好看而是有没有把场景、视图、图元这三层关系摆正。QGraphicsView 不是“更高级的 QWidget”它把图元管理、坐标变换、命中测试、选择状态拆给了 QGraphicsScene 和 QGraphicsItem视图只负责把场景映射到窗口。这个分工一旦理解错后面拖拽、缩放、连线都会变成玄学偏移。2.1 为什么不用 QWidget 手摆控件几十个节点以内用 QWidget 绝对定位也能做甚至上手更快每个节点是一个 QWidget连线用一个透明覆盖层画。节点一多、缩放一开、框选一加问题就来了。QWidget 的几何变换要自己遍历改父子控件裁剪和焦点管理会互相打架框选和命中测试也得自己写一套。更麻烦的是节点移动时连线要跟着重算而 QWidget 的 update 区域不会自动把跨控件的线算进去残影和闪烁很常见。QGraphics 体系解决的不是“画图”而是“管理大量可交互图元”。场景负责图元索引和事件分发视图负责视图矩阵图元只关心自己的本地坐标和绘制。常见做法是节点、端口、连线都继承 QGraphicsItem 或 QGraphicsObject统一进场景视图只做缩放、平移和框选。代价是坐标换算要写清楚不能把视图坐标直接塞给图元。维度QWidget 绝对布局QGraphicsView 场景节点规模几十个较顺手数百个更适合统一管理缩放平移自己遍历改几何视图矩阵一次变换框选与命中自己写规则内置图元选择与 itemAt连线管理独立覆盖层连线也是图元可统一 z 值坐标复杂度控件坐标为主视图、场景、图元三层选型上我一般会看两个信号一是节点会不会超过一百个二是要不要做缩放后的连线编辑。只要中一个就别用 QWidget 硬摆。QGraphicsView 不是没有成本但它的成本是“学坐标”不是“后期补架构”。2.2 场景、视图、图元三层坐标的换算流程图里最常见的翻车是把鼠标位置、图元位置、端口位置混着用。视图坐标是窗口像素场景坐标是图元世界的统一坐标图元本地坐标是 paint 和 boundingRect 使用的坐标。端口作为节点的子图元它的 pos 是相对父图元的不是场景坐标。连线要拿端口锚点时必须用mapToScene(0, 0)转出去。坐标类型常见获取方式用途视图坐标QMouseEvent::pos()视口内的像素位置场景坐标view-mapToScene(e-pos())图元摆放、连线锚点图元本地坐标item-mapFromScene(scenePos)paint、boundingRect、shape父坐标item-pos()子图元相对父图元的位置在QGraphicsSceneMouseEvent里scenePos()已经是场景坐标不要再mapToScene一次。反过来从视图的mousePressEvent拿到的pos()才是视图坐标必须过一次mapToScene。高 DPI 下也不要手动乘devicePixelRatio视图矩阵已经处理了设备像素比手动再乘一次端口就会偏到怀疑人生。2.3 最小可运行骨架场景初始化与视图参数先把场景和视图跑起来不要一上来就写连线。下面这段是流程图视图的最小骨架重点是场景范围、缩放锚点和更新模式。#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); // 场景范围要比内容大否则图元一移动滚动条就跟着跳 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只重绘脏区域比全视口刷新稳。AnchorUnderMouse让缩放围绕鼠标位置进行不然每次滚轮都往视图中心跑编辑大图时很难受。参数上sceneRect不要设得刚刚好包住内容留出负坐标和扩展空间0.2到4.0是缩放下限和上限具体可以按业务调但一定要有边界否则用户能把图缩成一个点再也找不回来。RubberBandDrag适合框选节点后面如果要做空格拖拽平移再单独切ScrollHandDrag。3. 自定义图元矩形节点、端口与连线的绘制和命中图元是这套源码里最值得拆的部分。节点不是画一个圆角矩形就完事boundingRect 决定重绘区域shape 决定点击命中端口决定连线起点连线的 boundingRect 决定拖动时会不会留下残影。图元写得不干净视图参数调得再漂亮也救不回来。3.1 节点图元的边界矩形与 paint 顺序节点图元优先继承QGraphicsObject这样既有 QGraphicsItem 的坐标能力又能用信号槽和QPointer做生命周期保护。boundingRect 要把画笔宽度算进去画笔宽 2 就外扩 1漏掉这一像素拖动时边缘就会留下细线残影。shape 用圆角矩形路径命中区域和视觉区域保持一致不要用默认的 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用的后面从场景里捞节点、端口都靠它。ItemIsMovable让节点能拖ItemIsSelectable让框选生效ItemSendsGeometryChanges是连线更新的前提。DeviceCoordinateCache适合静态节点能减少重复绘制但节点频繁改大小或高频动画时缓存反而会增加失效开销这一点要在真实场景里测。参数上节点宽高120 x 60、圆角8、画笔宽2都是示例值改尺寸时记得同步改 boundingRect 和 shape不能只改 paint 里的矩形。节点文本用TextWordWrap长文本会换行但不会自动撑高节点要做自适应高度得在文本变化时调prepareGeometryChange()再改 boundingRect。3.2 端口图元用 itemAt 做连线起点端口是节点的子图元位置相对节点视觉上是一个小圆点命中区域要比视觉大。用户不会精准点中 6 像素的圆常见做法是视觉半径 6、热区半径 11。端口图元不需要可移动但要能被场景items()或视图itemAt()找到所以 type 必须稳定。#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点击热区 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()。端口在节点本地坐标里通常是(60, 0)或(-60, 0)连线不能直接拿这个本地坐标必须mapToScene(0, 0)转成场景坐标。shape()比boundingRect()大是为了扩大点击热区但热区不要无限放大否则两个相邻端口会互相抢事件。端口挂到节点下之后节点移动端口自动跟着走这是父子图元最省事的地方。注意端口的zValue要高于节点不然节点边框会盖住圆点连线的zValue要低于节点不然线会压在节点文字上。层级不复杂但顺序一乱视觉上就很脏。3.3 连线图元贝塞尔曲线与箭头收口连线图元要同时解决三件事路径怎么画、点击怎么命中、节点移动后怎么更新。路径用三次贝塞尔控制点按水平距离取一半最少给 40避免短距离连线变成折角。命中区域不能只用线宽 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热区放大不然很难选中 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()里先调prepareGeometryChange()再改路径这是通知场景旧区域失效如果顺序反了旧线可能留在屏幕上。贝塞尔控制点用水平偏移流程图看起来更像工程图不会像蜘蛛网。箭头用路径 92% 位置的切线方向算角度终点固定到端口锚点缩放时不会飘。参数上控制点最小 40 是经验值节点间距很小的时候也不至于把线折成直角stroker.setWidth(8)是点击热区线宽本身还是 2。zValue(-1)让连线在节点下面但端口在节点上面三层顺序是连线、节点、端口。节点移动时节点图元要在itemChange(ItemPositionHasChanged)里遍历自己关联的连线并调用updatePath()否则线不会跟着动。4. 拖拽、缩放与连线交互事件分发链路怎么接图元画出来只是静态图流程图好不好用取决于鼠标按下到松开的那几百毫秒。场景负责图元级事件视图负责视图级变换端口命中用场景坐标临时连线用临时图元松手时再做目标端口校验。链路理顺以后连线就是“按下端口、移动、吸附、松手建线”四步。4.1 鼠标按下到临时连线scene 事件过滤不要在视图里直接判断端口视图拿到的是视图坐标还要转场景而且图元事件会被场景先分发。更稳的做法是继承 QGraphicsScene在mousePressEvent里用items(scenePos)找端口找到就创建临时路径图元吃掉事件找不到再交给基类做节点拖拽和框选。#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 值从上到下遍历命中的第一个端口作为起点移动时不断把临时路径更新到鼠标位置松开时再次命中端口命中就创建正式连线没命中就删除临时图元。参数上临时路径zValue(100)要足够高保证拖动时压在节点和端口上面虚线画笔只是交互提示正式连线用实线。qobject_castNodeItem*要求节点是 QGraphicsObject如果节点不是 QObject就换成dynamic_cast或自己维护节点映射。真实工程里还要加方向校验输出端口只能连输入端口重复连线和自连要拦掉这些校验放在创建 ConnectionItem 之前做。4.2 视图缩放与平移的边界控制流程图视图的滚轮有两种常见约定直接滚轮上下平移Ctrl 加滚轮缩放。缩放一定要限幅不然用户能把图缩到 0.01 倍整个场景变成一粒灰。缩放锚点用AnchorUnderMouse让鼠标下的节点保持位置操作感会好很多。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
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表