简介:这份资源面向具备一定 Qt/QML 基础的开发者,聚焦在 QML 工程中实现圆形、矩形、多边形与直尺的绘制,并支持通过控制点动态调整路径。内容基于 QQuickPaintedItem 自定义绘图组件,覆盖 QPainter 绘制圆与矩形、QPainterPath 构建多边形、直尺刻度绘制,以及拖动控制点实时更新顶点坐标等核心思路,适合图形编辑器、标注工具、测量类界面等场景参考。资源包共 27 个文件,以 cpp 与 h 源码为主体,配合 qml 界面文件、png 示意图、qrc 资源文件及 pro/pri 工程配置,压缩包约 42KB,结构紧凑便于直接导入工程验证。目前已有 855 人学习下载。读者可从中获得可运行的绘图组件实现、路径编辑与交互处理思路,以及按模块拆分的类结构参考,便于快速移植到自己的 QML 项目中扩展旋转、缩放等能力。
1. 从“画得出”到“拖得动”:QML 矢量绘制的核心命题
很多做 QML 界面的同行都遇到过这个场景:产品经理拿着原型图过来说,要一个能画圆、画矩形、画多边形,还得带一把直尺量距离的标注工具,而且每个图形的顶点都要能用鼠标拖着改。第一反应可能是上 Canvas,2D 上下文一把梭,画是能画出来,但一旦要拖动控制点实时改路径,Canvas 的重绘开销和命中检测就成了玄学——鼠标明明在点上,就是拖不动。这个标题要解决的就是这件事:在 QML 工程里实现圆形、矩形、多边形和直尺的绘制,并且让路径能通过控制点被动态修改。它适合两类人:一是正在做标注工具、白板协作、CAD 轻量预览的 QML 开发者;二是想从 Widget 或 Web 前端转到 QML 声明式绘图,需要一套可复现路径的人。核心思路不是“怎么画”,而是“怎么让图形和它的控制点形成双向绑定”——图形变了控制点跟着走,控制点拖了图形实时更新,这才是整个方案里最值得投入的部分。
2. 选型与数据模型:为什么不用 Canvas 而用 Shape 加自定义 Item
2.1 Canvas、Shape、自定义 Item 三种路线的取舍
QML 里画矢量图形,常见做法有三条路。第一条是 Canvas,用 JavaScript 的 2D API 画,灵活但每次重绘都要走一遍脚本,控制点多的时候帧率掉得厉害,而且 Canvas 本身不保留图形对象,命中检测得自己算。第二条是 Qt Quick Shapes 模块,用 Shape、Path、PathLine、PathArc 这些声明式元素组合路径,底层走场景图,渲染效率比 Canvas 高一个量级,但它的路径数据是静态声明的,要动态改就得重建 Path 元素,控制点多了同样难受。第三条是自定义 QQuickItem,在 C++ 层用 QPainterPath 或 QSGGeometry 自己管路径和渲染,控制点作为独立 Item 叠在上面,拖动时只更新路径数据,不重建场景图节点。
我一般会选第三条路,但把控制点用 QML 的 Item 或 Rectangle 来做,路径数据放在 C++ 的 QPainterPath 里,通过属性暴露给 QML。这样渲染在 C++ 层保证效率,交互在 QML 层保证开发速度。如果项目不允许引入 C++,退而求其次用 Shape 加动态 Path 也行,但控制点超过二十个之后要留意性能。
2.2 用 QPainterPath 统一描述四种图形
圆形、矩形、多边形、直尺,看起来是四种东西,但在路径层面可以统一。圆形是椭圆的一种特例,矩形是四条直线的闭合路径,多边形是 N 条直线的闭合路径,直尺本质上是一条带端点标记的线段。统一用 QPainterPath 描述之后,控制点的定义就一致了:每个图形维护一个控制点列表,路径由控制点列表计算得出。
// ShapeItem.h #pragma once #include <QQuickPaintedItem> #include <QPainterPath> #include <QVector> #include <QPointF> class ShapeItem : public QQuickPaintedItem { Q_OBJECT Q_PROPERTY(QVector<QPointF> controlPoints READ controlPoints WRITE setControlPoints NOTIFY controlPointsChanged) Q_PROPERTY(int shapeType READ shapeType WRITE setShapeType NOTIFY shapeTypeChanged) public: enum ShapeType { Circle, Rectangle, Polygon, Ruler }; Q_ENUM(ShapeType) explicit ShapeItem(QQuickItem *parent = nullptr); QVector<QPointF> controlPoints() const { return m_controlPoints; } void setControlPoints(const QVector<QPointF> &pts); int shapeType() const { return m_shapeType; } void setShapeType(int t); void paint(QPainter *painter) override; signals: void controlPointsChanged(); void shapeTypeChanged(); private: QPainterPath buildPath() const; QVector<QPointF> m_controlPoints; int m_shapeType = Rectangle; };这段代码定义了核心 Item 的接口。controlPoints 是一个 QVector ,QML 侧可以直接用 list 赋值。shapeType 决定 buildPath 里走哪个分支。paint 函数负责把 QPainterPath 画出来。注意 QQuickPaintedItem 的 paint 是在渲染线程调用的,setControlPoints 里改完数据要调 update() 触发重绘。
2.3 控制点与路径的映射关系
不同图形的控制点含义不一样,这个映射关系必须在文档里写清楚,否则后面拖动逻辑会乱。圆形用两个控制点:圆心和半径端点,拖动半径端点改大小,拖动圆心整体平移。矩形用四个角点,拖动任意角点改对应边。多边形用 N 个顶点,每个顶点独立可拖。直尺用两个端点,拖动任一端点改长度和角度。
QPainterPath ShapeItem::buildPath() const { QPainterPath path; if (m_controlPoints.size() < 2) return path; switch (m_shapeType) { case Circle: { QPointF center = m_controlPoints[0]; QPointF edge = m_controlPoints[1]; qreal r = QLineF(center, edge).length(); path.addEllipse(center, r, r); break; } case Rectangle: { if (m_controlPoints.size() < 4) break; path.moveTo(m_controlPoints[0]); for (int i = 1; i < 4; ++i) path.lineTo(m_controlPoints[i]); path.closeSubpath(); break; } case Polygon: { path.moveTo(m_controlPoints[0]); for (int i = 1; i < m_controlPoints.size(); ++i) path.lineTo(m_controlPoints[i]); path.closeSubpath(); break; } case Ruler: { path.moveTo(m_controlPoints[0]); path.lineTo(m_controlPoints[1]); break; } } return path; }buildPath 是纯函数,输入控制点输出路径,不依赖任何外部状态。这样单元测试也好写,给定控制点断言路径的 boundingRect 就行。圆形的半径由圆心到第二个控制点的距离决定,这个设计比直接存半径值更直观,因为拖动的时候鼠标位置就是第二个控制点的位置。
3. 控制点交互:从鼠标按下到路径更新的完整链路
3.1 控制点 Item 的创建与布局
控制点本身是独立的 QML Item,叠在 ShapeItem 上面。每个控制点是一个小矩形或圆形,带 MouseArea。控制点的位置由 ShapeItem 的 controlPoints 属性驱动,用 Repeater 加 delegate 来生成。
// ShapeEditor.qml import QtQuick 2.15 import QtQuick.Controls 2.15 Item { id: root property var shapeItem: null property real handleSize: 10 Repeater { model: root.shapeItem ? root.shapeItem.controlPoints : [] delegate: Rectangle { id: handle width: root.handleSize height: root.handleSize radius: root.handleSize / 2 color: mouseArea.drag.active ? "#FF5722" : "#2196F3" x: modelData.x - width / 2 y: modelData.y - height / 2 MouseArea { id: mouseArea anchors.fill: parent drag.target: handle drag.threshold: 0 onPositionChanged: { if (drag.active) { var pts = root.shapeItem.controlPoints.slice() pts[index] = Qt.point(handle.x + handle.width / 2, handle.y + handle.height / 2) root.shapeItem.controlPoints = pts } } } } } }这段 QML 里,Repeater 的 model 直接绑定 controlPoints 数组,每个 delegate 是一个圆形手柄。MouseArea 的 drag.target 设成 handle 自身,拖动时 handle 的 x、y 变化,onPositionChanged 里把新位置写回 controlPoints。注意这里用了 slice() 做浅拷贝,因为直接改数组元素 QML 的属性绑定不会触发更新,必须整体赋值。
3.2 拖动时的坐标转换与边界处理
控制点的坐标是相对于 ShapeEditor 的局部坐标,而 ShapeItem 的 controlPoints 也是局部坐标,两者如果父级相同就直接用。但如果 ShapeItem 有 scale 或 rotation,就得做逆变换。常见做法是在 onPositionChanged 里用 mapToItem 把 handle 的坐标转到 ShapeItem 的坐标系。
onPositionChanged: { if (drag.active) { var localPos = handle.mapToItem(root.shapeItem, handle.width / 2, handle.height / 2) var pts = root.shapeItem.controlPoints.slice() pts[index] = localPos root.shapeItem.controlPoints = pts } }边界处理方面,矩形要防止对角点交叉导致路径自交,圆形要防止半径为零,多边形要防止相邻点重合。我一般会在 C++ 的 setControlPoints 里做一次校验,不合法的点直接拒绝或者钳制到合法范围。
3.3 路径重绘的触发时机与性能考量
每次 controlPoints 变化都会触发 update(),进而触发 paint()。如果拖动时每帧都改,60fps 下每秒 60 次重绘,对于简单路径没问题,但多边形顶点上百的时候就要考虑节流。一个实用技巧是在 MouseArea 的 onPositionChanged 里不做实时更新,而是用一个 Timer 以 16ms 间隔批量提交。
Timer { id: updateTimer interval: 16 repeat: false onTriggered: { root.shapeItem.controlPoints = pendingPoints } }拖动时只更新 handle 的位置和 pendingPoints,Timer 触发时才写回 shapeItem。这样即使鼠标事件频率很高,实际重绘次数也被限制在每秒 60 次左右。血泪经验是不要在 paint 里做任何计算量大的操作,buildPath 尽量轻,复杂路径提前缓存。
4. 避坑与排查:控制点拖不动、路径闪烁、坐标偏移的常见原因
4.1 控制点拖不动,鼠标事件被上层 Item 拦截
现象是鼠标按在控制点上没反应,或者拖的时候整个画布在移动。原因通常是父级有个 Flickable 或者 MouseArea 把事件吃掉了。解决方法是给控制点的 MouseArea 设置 preventStealing: true,或者在父级 MouseArea 里判断事件是否落在控制点区域内再决定是否处理。
MouseArea { anchors.fill: parent preventStealing: true // ... }4.2 路径更新后画面闪烁,旧路径残留
现象是拖动控制点时,新路径画出来了但旧路径还在,或者整个画面闪一下。原因一般是 QQuickPaintedItem 的 paint 没有清空背景,或者 update() 调用时机不对。解决方法是在 paint 开头调 painter->fillRect(contentsBoundingRect(), Qt::transparent) 清空,然后确保 setControlPoints 里改了数据之后调 update()。
4.3 控制点位置和路径对不上,偏移一个父级坐标
现象是控制点显示在路径旁边,拖的时候路径跟着动但控制点不动。原因是控制点的坐标系和 ShapeItem 的坐标系不一致,中间隔了层 Item 或者有 transform。解决方法是统一用 mapToItem 做坐标转换,或者在设计时就保证控制点和 ShapeItem 是同一个父级下的兄弟节点。
4.4 多边形顶点多了之后拖动卡顿
现象是顶点超过五十个之后,拖动明显掉帧。原因是每次 onPositionChanged 都触发全量重绘,而且 QML 的数组赋值会触发整个 Repeater 重建。解决方法是把 Repeater 的 model 换成一个 ListModel,只更新变化的那一项,或者用 C++ 的 QAbstractListModel 做增量更新。
4.5 圆形拖动半径端点时路径跳变
现象是拖圆形半径端点的时候,圆的大小不是连续变化,而是跳一下。原因是控制点坐标是整数,QPointF 的精度不够,或者 mapToItem 返回的坐标被取整了。解决方法是确保所有坐标都用 qreal,QML 侧用 Qt.point 传浮点数,不要在中间做 Math.round。
5. 进阶技巧:用状态机管理编辑模式与直尺的吸附对齐
5.1 用 State 和 Transition 切换绘制与编辑模式
实际工具里,用户不会一直处于编辑状态。常见做法是用一个 StateGroup 管理“绘制中”“编辑中”“空闲”三个状态,不同状态下控制点的可见性和鼠标行为不一样。绘制中控制点隐藏,鼠标按下确定第一个点,移动确定第二个点,释放完成绘制。编辑中控制点显示,可以拖动。空闲时控制点隐藏,只能选中图形。
StateGroup { id: stateGroup states: [ State { name: "idle" }, State { name: "drawing" PropertyChanges { target: handleLayer; visible: false } }, State { name: "editing" PropertyChanges { target: handleLayer; visible: true } } ] transitions: Transition { NumberAnimation { properties: "opacity"; duration: 150 } } }状态切换用 Transition 加个淡入淡出,体验会好很多。注意不要在 State 里改 controlPoints,状态只控制可见性和交互开关,数据还是由 controlPoints 单一来源管理。
5.2 直尺的吸附对齐与角度约束
直尺工具通常需要吸附到水平、垂直或 45 度角。实现方式是在拖动端点时计算当前线段角度,如果和最近的特殊角度差在阈值内,就把端点投影到那个角度上。
QPointF ShapeItem::snapRulerEnd(const QPointF &start, const QPointF &end, qreal threshold) { QLineF line(start, end); qreal angle = line.angle(); // 0-360 const QVector<qreal> snapAngles = {0, 45, 90, 135, 180, 225, 270, 315, 360}; for (qreal sa : snapAngles) { if (qAbs(angle - sa) < threshold) { qreal len = line.length(); qreal rad = qDegreesToRadians(sa); return start + QPointF(len * qCos(rad), -len * qSin(rad)); } } return end; }threshold 一般设 5 到 10 度,太小了吸不住,太大了用户觉得不跟手。这个函数在 setControlPoints 里对 Ruler 类型调用,其他类型跳过。
5.3 用 QPainterPath 的 intersects 做命中检测
选中图形的时候,不用自己算点在不在多边形内,QPainterPath 自带 intersects 方法。把鼠标位置转成 QPainterPath 的一个小矩形,和图形路径求交即可。
bool ShapeItem::hitTest(const QPointF &pos) const { QPainterPath path = buildPath(); QPainterPath probe; probe.addRect(pos.x() - 2, pos.y() - 2, 4, 4); return path.intersects(probe); }这个方法的精度取决于 probe 的大小,太小了边缘点选不中,太大了容易误选。我一般用 4x4 的矩形,配合一个可配置的容差参数。
5.4 一个容易被忽略的细节:控制点的 z 序
控制点必须始终在图形上方,否则拖动的时候会被图形挡住。在 QML 里用 z 属性控制,handleLayer 的 z 设成 shapeItem 的 z 加一。如果图形有多个,每个图形的控制点层级要跟着图形走,不能所有控制点都堆在最上面,否则选中的图形和控制点对不上。
最后说个习惯:每次改完控制点逻辑,我都会用一组极端数据跑一遍——圆形半径设成 0.1、矩形四个点重合、多边形只有两个点、直尺两个端点相同。这些边界情况不崩,基本就稳了。希望帮到你。
本文还有配套的精品资源,点击获取