ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

QT图形交互实战:QGraphicsView鼠标画矩形全解析

QT图形交互实战:QGraphicsView鼠标画矩形全解析 简介这是一份基于QT 5.15.2与VS2019编写的鼠标绘制矩形Demo面向具有一定C和Qt基础、希望掌握QGraphicsView交互开发的开发者。项目利用QPainter与QGraphicsRectItem演示在QGraphicsView上绘制一个或多个矩形并支持鼠标悬停拖动、边缘拉伸调整宽高、按住Ctrl滚动滚轮缩放以及右键删除和保存。核心逻辑封装清晰可轻松扩展到多边形、椭圆等自定义图元适合学习事件过滤、图元重绘与坐标变换等知识点。压缩包共16个文件包含5个.h头文件与5个.cpp源文件另外还有ui界面文件、资源文件、工程配置与筛选文件结构完整便于在VS2019中直接打开编译。包体仅18KB轻量小巧代码量控制得当。目前已有2300余人学习下载是一份快速上手Qt图形视图框架绘图交互的实用参考。 做图像标注工具那段时间我卡得最久的功能不是算法调参而是最不起眼的用鼠标画矩形。听起来像入门Demo但真动起手来QT里沾边的类一长串QGraphicsView、QGraphicsScene、QPainter、QGraphicsRectItem还有各种mouse事件。它们各自都能做这需求的一部分混着用又经常互相干扰很多人花了一两周还在原地打转。这篇文章就把这个需求彻底拆开坐标体系怎么处理、事件状态机怎么搭、动态预览怎么写、图元怎么升级成可编辑组件。无论你是做图像标注、截图工具、框选裁剪还是CAD类软件这套交互逻辑都能直接搬过去。1. 先选路线QGraphicsRectItem 图元方案和 QPainter 直绘方案到底怎么取舍1.1 Scene / View / Item 的分工舞台、演员与取景框先说框架。QGraphicsView这个词很多教程一上来就让用但真正理解它的人不多。你可以把QGraphicsScene想象成一个无限大的世界坐标系QGraphicsRectItem是这个世界上的一张纸片QGraphicsView则是你举在眼前的相机取景框。取景框能拉近拉远、左右平移但它看到的是真实世界的投影不是真实世界本身。这就是后面第4章坐标转换容易出问题的根源。很多人在没理解这套架构时碰见画矩形第一个反应是用QPainter在view上直接画线。这当然也能实现但写法完全不同。QPainter更接近一支笔它可以在任何QPaintDevice上涂画而QGraphicsRectItem则是往场景世界里放置一个对象。这决定了两种方案后续的天壤之别用QPainter画完屏幕上只是一摊像素用QGraphicsRectItem画完场景里多了一个可以被捡起来、拖动、命中的实体。1.2 两种方案怎么选核心是画完还要不要编辑选型标准很简单画完之后需不需要单独操作这个矩形QGraphicsRectItem方案的优势是真的创建了一个图元对象挂进scene之后它能被选中、移动、缩放、排序后来想做删除、修改颜色这类操作非常顺手。缺点也明显必须管理对象生命周期矩形数量上来之后内存占用会上升而且与view自身的拖拽框选机制容易打架。QPainter直绘方案的优势是轻量没有对象销毁的负担特别适合截图标注、临时框选提示、测量标尺这类一次性的视觉反馈。缺点是一旦松开鼠标屏幕上就只有像素没有某一个矩形的概念。想双击删除某一个做不到因为你根本没存这个对象。我自己的判断标准很简单粗暴需要二次编辑的走图元只求画出来给人看的走直绘。二者没有绝对的优劣只有场景合不合适。1.3 实战中最常用的混合模式直绘预览 图元落定实际项目里我最推荐的是混合方案拖动过程中用QPainter做轻量预览视觉反馈快、不产生垃圾图元松开鼠标的那一刻正式生成一个QGraphicsRectItem加进场景。这样既保留了实时预览的流畅性又把最终结果沉淀成了可编辑对象。很多商业截图工具就是这么干的拖动时是半透明的虚线框松手后虚线框消失标注重绘成实线图元。这种预览—落定的交互模型用户体验最好代码结构也清晰后面加撤销、编辑都方便。2. 鼠标交互状态机按下、拖动、松开分别该做什么2.1 三个鼠标事件的标准骨架鼠标画矩形的本质是一个三步状态机按下记录起点、拖动更新终点、松开收尾。很多初学者把这写成一大段if-else最后自己都绕晕。我更建议用成员变量管理状态事件分工明确代码可以长这样class DrawRectView : public QGraphicsView { Q_OBJECT public: explicit DrawRectView(QWidget *parent nullptr); protected: void mousePressEvent(QMouseEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override; void mouseReleaseEvent(QMouseEvent *event) override; private: QPointF m_startScenePos; // 按下点场景坐标 QGraphicsRectItem *m_previewRect nullptr; // 动态预览用的临时矩形 }; void DrawRectView::mousePressEvent(QMouseEvent *event) { if (event-button() Qt::LeftButton) { m_startScenePos mapToScene(event-pos()); m_previewRect new QGraphicsRectItem(); m_previewRect-setPen(QPen(Qt::red, 2)); m_previewRect-setBrush(QColor(255, 0, 0, 30)); // 半透明填充 scene()-addItem(m_previewRect); event-accept(); return; } QGraphicsView::mousePressEvent(event); } void DrawRectView::mouseMoveEvent(QMouseEvent *event) { if ((event-buttons() Qt::LeftButton) m_previewRect) { QPointF cur mapToScene(event-pos()); m_previewRect-setRect(QRectF(m_startScenePos, cur).normalized()); event-accept(); return; } QGraphicsView::mouseMoveEvent(event); } void DrawRectView::mouseReleaseEvent(QMouseEvent *event) { if (event-button() Qt::LeftButton m_previewRect) { QRectF finalRect m_previewRect-rect(); if (finalRect.width() 3 || finalRect.height() 3) { scene()-removeItem(m_previewRect); delete m_previewRect; } m_previewRect nullptr; event-accept(); return; } QGraphicsView::mouseReleaseEvent(event); }这套代码里有个容易漏的细节press里mapToScene(event-pos())一定要在起点记录时做掉后面move里的当前点也要做。如果全程在视图坐标里算拖到后面再加滚动和缩放全部错位。这个后面第4章专门说。2.2 不按左键的移动必须过滤buttons() 的作用mouseMoveEvent有个很坑的性质不按鼠标时也会触发只要光标在控件上移动它就一直在触发。如果你在move里直接无条件更新矩形会出现一个经典场景鼠标只是从窗口上划过结果划出一道长长的矩形。所以move里第一行就判断event-buttons() Qt::LeftButton。这个判断的意思是左键当前处于按住状态。只有按住左键时的移动才算拖拽这样才能区分移动鼠标和拖动绘制两种行为。如果你还想更严格一点可以再加一个m_drawing布尔标志按下置true松开置false。这样即使某些异常情况下 buttons 判断失效也不会画出无头矩形。2.3 不想继承 View 时的备选事件过滤器如果项目里不方便新建子类还可以在构造函数里给viewport装事件过滤器viewport()-installEventFilter(this); bool DrawRectView::eventFilter(QObject *obj, QEvent *event) { if (obj viewport()) { if (event-type() QEvent::MouseButtonPress) { /* ... */ } else if (event-type() QEvent::MouseMove) { /* ... */ } else if (event-type() QEvent::MouseButtonRelease) { /* ... */ } } return QGraphicsView::eventFilter(obj, event); }注意装的是viewport的事件过滤器不是view自己。因为鼠标事件先派发给viewport在view层去拦截反而拿不到。绝大多数新项目我还是建议子类重写三个mouse事件代码直观、好维护还能直接把状态变量封装在类里。3. 动态预览的重难点负宽高矩形、最小尺寸和重绘范围3.1 负宽高矩形鼠标往左上拖的数学问题矩形坐标最容易翻车的点是用户根本不会老老实实从左上往右下拖。鼠标从右下往左拖或者往下往上斜着拖QRectF构造出来width或height就可能是负数。负宽高在部分渲染路径里会被直接忽略有的绘制代码甚至会画反方向。解决它就一行normalized()。这个方法会把矩形的左上角和右下角重新排序保证width和height都为正。所以正确的写法永远是QRectF rect(m_startScenePos, cur); m_previewRect-setRect(rect.normalized());千万不要自己手动比较坐标做排序除非你想把简单问题复杂化。QRectF::normalized()就是QT官方给你准备的标准化工具。3.2 两种动态预览写法与更新区域优化图元方案里你应该持有一个成员变量m_previewRect移动过程中只调setRect千万不要每次move都removeItem再addItem。那样会产生大量对象通知和索引刷新图元稍微多一点就卡成PPT。正确做法是拖拽期间始终操作同一个临时图元直到release才决定留下它还是删掉它。纯QPainter直绘方案则是在drawForeground里画预览void PaintPreviewView::drawForeground(QPainter *painter, const QRectF rect) { QGraphicsView::drawForeground(painter, rect); if (m_drawing) { painter-setRenderHint(QPainter::Antialiasing, true); painter-setPen(QPen(Qt::red, 2)); painter-setBrush(QColor(255, 0, 0, 30)); painter-drawRect(QRectF(m_startScenePos, m_currentScenePos).normalized()); } } void PaintPreviewView::mouseMoveEvent(QMouseEvent *event) { if ((event-buttons() Qt::LeftButton) m_drawing) { m_currentScenePos mapToScene(event-pos()); viewport()-update(); event-accept(); return; } QGraphicsView::mouseMoveEvent(event); }这里有个细节值得说viewport()-update()如果什么都不传会重绘整个视口。视口上如果加载了大图或有大量图元高频拖拽时性能会很差。优化办法是只更新脏矩形区域把场景坐标转回视图坐标再局部更新QRectF dirty QRectF(m_startScenePos, m_currentScenePos) .normalized() .adjusted(-2, -2, 2, 2); viewport()-update(mapFromScene(dirty).boundingRect());多一行转换性能差距肉眼可见尤其在大图标注场景里。3.3 最小尺寸过滤避免点状图元和幽灵矩形release时如果finalRect的宽高都小于3像素说明用户只是原地点了一下根本不是想画矩形。这时候直接删除临时图元不要让它留在场景里。否则场景里会堆积一堆看不见、选不中、删起来也麻烦的点状图元后期调试数据导出一眼黑。这个3像素阈值不是什么标准值完全看你的业务容忍度。截图标注工具通常1~2像素就够了CAD类工具可能5像素以下都算误触。核心思路是主动过滤极小矩形而不是放任不管。4. 视图坐标与场景坐标mapToScene 漏掉的后果很严重4.1 三个坐标系对照viewport 坐标、场景坐标、图元坐标画矩形这条链路上最隐蔽的坑就是坐标混用。很多人Demo跑通了一缩放就废根子在这里。坐标系属于谁原点位置受影响因素viewport坐标QGraphicsView窗口可视区左上角滚动条、缩放、平移场景坐标QGraphicsScene场景逻辑原点全局稳定不随视角变化图元坐标QGraphicsRectItem图元局部原点图元自身transformviewport坐标就是你在mouseEvent里拿到的event-pos()它是相对于当前可视窗口的。只要view平移了或者缩放级别不是100%它就和场景坐标对不上。图元坐标则是item内部的局部坐标绘制控制点和做命中测试时才用得到。4.2 缩放之后全画偏的真实排查经历我之前做标注工具加了滚轮缩放功能之后画出来的矩形全部偏到画面右上方。当时第一反应是item的位置算错了查了很久的setPos结果发现根因荒唐得很press和move里直接用了event-pos()完全没做mapToScene。不缩放时viewport坐标和场景坐标是重合的所以最初跑通Demo很顺利。一旦滚轮放大到200%屏幕中心点的viewport坐标在场景里对应的其实是另一个位置。如果你拿着取景框里的坐标去真实世界里放纸片当然会放歪。正确的做法是统一在场景坐标层运算QPointF scenePos mapToScene(event-pos()); QPointF sceneStart mapToScene(m_viewportStartPos); // 如果存过viewport坐标就要转我之后把press和move里的坐标全部统一成mapToScene结果这个bug再没出现过。记住一个原则进入processor逻辑后所有点都转成场景坐标不要混着用。4.3 RubberBandDrag 与自绘矩形的抢事件冲突另一个高频坑是和RubberBandDrag抢事件。很多人为了框选多个图元给view设置了setDragMode(QGraphicsView::RubberBandDrag)结果发现拖出来的是view自带的虚线选择框自己的矩形根本没机会绘制。原因是view已经拦截拖拽事件去画橡皮筋了鼠标事件轮不到你的代码处理。解决办法不复杂画矩形时把dragMode切回NoDrag要框选时再切回去。如果你的应用有绘制模式和选择模式两种交互状态完全可以在切换模式时同步切换dragModevoid setDrawingMode(bool drawing) { m_drawingMode drawing; setDragMode(drawing ? QGraphicsView::NoDrag : QGraphicsView::RubberBandDrag); }如果你忘了这个切换就会出现切到画矩形模式后一拖就出橡皮筋框死活画不出矩形的诡异情况网上问这个问题的人特别多。5. 把矩形变成活的控制点编辑与图元自绘升级5.1 自定义图元在 paint 里画控制点只停留在画出来是不够的很多场景还得让矩形可以被选中、拖动、改大小。这时候就需要把QGraphicsRectItem升级成自定义图元。核心思路是重写paint()在没有选中时和普通矩形一模一样一旦isSelected()为true就在四角甚至四边中点画出控制点小方块。控制点既是视觉提示也是交互入口class EditableRectItem : public QGraphicsRectItem { public: enum HandleMode { None, MoveItem, ResizeTopLeft, ResizeTopRight, ResizeBottomLeft, ResizeBottomRight }; protected: void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override { QGraphicsRectItem::paint(painter, option, widget); if (!isSelected()) return; QRectF r rect(); painter-setPen(QPen(Qt::white, 1)); painter-setBrush(QColor(0, 0, 0, 160)); const QPointF corners[4] { r.topLeft(), r.topRight(), r.bottomLeft(), r.bottomRight() }; for (const QPointF p : corners) { painter-drawRect(QRectF(p.x() - 4, p.y() - 4, 8, 8)); } } void mousePressEvent(QGraphicsSceneMouseEvent *event) override { m_mode hitTest(event-pos()); if (m_mode None) QGraphicsRectItem::mousePressEvent(event); else event-accept(); } private: HandleMode m_mode None; };这里我把控制点只放在四角简化了模型。实际产品里加上四条边中点做拉伸逻辑是完全一样的就是命中测试多几个候选区域。5.2 命中测试与拖拽模式切换在mousePressEvent里要做一次命中测试判断点击落在矩形内部还是某个控制点上。用item坐标判断最省事把四个控制点各自扩展成8x8的小矩形然后contains(event-pos())。代码示意HandleMode hitTest(const QPointF p) { QRectF r rect(); const qreal s 4.0; if (QRectF(r.topLeft() - QPointF(s, s), QSizeF(2*s, 2*s)).contains(p)) return ResizeTopLeft; if (QRectF(r.topRight() - QPointF(s, s), QSizeF(2*s, 2*s)).contains(p)) return ResizeTopRight; if (QRectF(r.bottomLeft() - QPointF(s, s), QSizeF(2*s, 2*s)).contains(p)) return ResizeBottomLeft; if (QRectF(r.bottomRight() - QPointF(s, s), QSizeF(2*s, 2*s)).contains(p)) return ResizeBottomRight; return MoveItem; }命中的结果决定mouseMoveEvent里怎么改矩形如果命中MoveItem就直接setPos移动整个item如果命中角点控制点就根据event-scenePos()和上一次的差值修改对应角点改完再setRect(rect.normalized())顺手把负宽高的隐患也消掉了。这里的技巧是event-lastScenePos()表示上一次鼠标位置event-scenePos()是当前位置两者的delta在按键拖动时才是稳定的。新手容易把差值算反导致矩形越拖越乱。5.3 图元持久化与撤销重做的切入方向当图元可以编辑之后数据落地和撤销重做就是顺理成章的需求。持久化不用做得太重维护一个QListEditableRectItem*需要保存时遍历列表把每个item的rect()、pen()、brush()序列化即可。加载时反序列化重建图元再逐个addItem回scene。撤销重做则可以接QUndoStack框架把新建矩形移动矩形缩放矩形分别封装成command对象操作发生时push进stackundo时反向执行。这一步看着工程量不小但套路非常标准网上参考很多。6. 实测高频翻车点从双击误触到抗锯齿残影6.1 双击事件带来的多画一个矩形系统双击时总会触发一次额外的press-release-press-release序列。如果画矩形逻辑里没有保护用户想双击选中某个图元结果平白无故多画了一个小矩形出来。最简单的处理是重写mouseDoubleClickEvent并把它吃掉void DrawRectView::mouseDoubleClickEvent(QMouseEvent *event) { event-accept(); }如果你的业务需要双击做别的事比如打开属性面板就在这个函数里做但别让它再继续派发给press/release逻辑。6.2 空矩形与点状图元看不见但很难删press和release发生在同一个像素位置QRectF宽高接近0图元本身不会报错但渲染可能看不见存数据时还会污染结构碰撞检测也容易出诡异结果。release阶段加一个最小尺寸过滤就能根治。阈值建议至少2像素我的经验是3像素最顺手。这行代码能帮你节省大量后期清理时间尤其是标注工具要导出JSON时空矩形比多画几个矩形还烦人。6.3 抗锯齿半像素残影与悬空指针隐患开启QPainter抗锯齿后笔画会向矩形边界外溢出大约半个到1像素。如果你update的脏矩形区域刚好等于矩形本身边缘就会留下上一帧的残影。这也是为什么我在第3章里坚持更新区域要向外多扩2个像素adjusted(-2, -2, 2, 2)不是随便写的。悬空指针也是高频崩溃点。release里如果删除了m_previewRect一定要立刻把它置成nullptr否则后续mouseMove还会继续判断这个指针直接访问已释放内存。很多同学只删不置空程序就崩溃得莫名其妙。凡是用裸指针管理资源记住删完即置空。如果让我给正在做类似功能的人一个建议画矩形这条链路第一版千万别贪多。先用QGraphicsRectItem加previewItem把最简单的按下-拖动-松开跑通再逐步加缩放、控制点、撤销。每一步都验证过再继续后面才不会大面积返工。我自己最后留在项目里的那版就是先用previewItem预览、release时正式addItem的方案稳定性和代码可读性都最好。本文还有配套的精品资源点击获取
返回列表