news 2026/11/4 6:57:47

PyQt6-第17讲-自绘控件QPainter仪表盘

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PyQt6-第17讲-自绘控件QPainter仪表盘

第 17 讲 | 自己画控件:QPainter 仪表盘

本讲目标:掌握自定义绘制。当内置控件和图表都满足不了需求时(仪表盘、进度环、雷达图、
拓扑图),这是唯一的路。

配套代码:cases/c17_custom_paint.py,下载地址看底部


一、什么时候需要自绘

先明确边界——能用内置控件解决的,就别自绘。自绘意味着你要自己处理
坐标计算、DPI 缩放、窗口大小变化、颜色主题,工作量不小。

这几个场景必须自绘:

需求内置方案结论
指针式仪表盘QtCharts 没有自绘
环形进度(带渐变)QProgressBar 只有条形自绘
雷达图 / 六边形能力图没有自绘
设备拓扑图(节点连线)没有自绘
自定义样式的按钮QSS 样式表够用不用自绘
柱状/折线/饼图QtCharts不用自绘(第 15 讲)

自绘的机制就一句话:继承QWidget,重写paintEvent(),
在里面用QPainter把控件画出来。


二、paintEvent:什么时候会被调用

defpaintEvent(self,event)->None:p=QPainter(self)...p.end()

它在三种情况下被调用:

  1. 控件第一次显示时
  2. 控件被 resize 时(或父窗口重排导致它尺寸变了)
  3. 代码调用self.update()时

注意:不是"每个刷新周期都调用"。Qt 只在"内容需要重画"时才调,
这是它性能好的原因之一。所以:

  • 你的数据变了 → 必须主动self.update()
  • 否则界面还是旧的样子
defsetValue(self,value:float)->None:self._target=value...self.update()# ★ 请求重绘,paintEvent 会被调用

update()不是立刻重绘,它只是"登记一次重绘请求",等事件循环空闲时统一处理。
所以连续调用 100 次update()和 1 次的效果是一样的——Qt 会自动合并。


三、QPainter 的三个基础

p=QPainter(self)p.setRenderHint(QPainter.RenderHint.Antialiasing,True)# ① 抗锯齿,必开p.setPen(QPen(QColor("#333a45"),2))# ② 画笔:线条颜色和粗细p.setBrush(QColor("#2d7ff9"))# ③ 画刷:填充色p.drawEllipse(QPointF(cx,cy),r,r)p.end()# ④ 结束,别忘了

四个要点:

① 抗锯齿必须开:不开的话圆和斜线全是锯齿,非常粗糙。

②setPen(Qt.PenStyle.NoPen)可以不画边框
画纯填充图形时用。

③ 画笔宽度用setWidthF(float)
setWidth()只接受整数,画粗线时精度不够。

④p.end()要调用
虽然 Python 的对象析构时会自动结束,但在paintEvent里显式调用更稳——
尤其是函数中途 return 的情况。


四、角度换算:这一讲最容易算错的地方

Qt 画圆弧的参数是drawArc(矩形, 起始角度, 跨越角度),但有两个反直觉的规定:

规定 1:角度单位是 1/16 度

p.drawArc(rect,90*16,-180*16)# 从 90° 到 -90°

为什么要乘 16?历史原因(早期用整型提升精度)。
不乘 16 的话,你画的弧会比你想象的小 16 倍——这是最经典的错误。

规定 2:0° 在 3 点方向,逆时针为正

90° ↑ │ 180° ←────┼────→ 0° │ ↓ 270° (或 -90°)

和数学课的坐标系一样(不是屏幕坐标系!)。所以:

  • 顺时针画弧 = 跨越角度为负数
  • 逆时针画弧 = 跨越角度为正数

仪表盘的角度计算

我们需要一个从"左下"扫到"右下"的 270° 表盘:

╭─────────╮ ╱ ╲ │ ● │ 起始 225°,跨越 -270° ╲ ╱ (顺时针扫到 -45°) ╲_________╱
START_ANGLE=225.0# 表盘左上起始角度SPAN_ANGLE=-270.0# 顺时针扫 270 度# 值 → 比例 → 角度ratio=(value-minimum)/(maximum-minimum)angle=START_ANGLE+SPAN_ANGLE*ratio# 225° → -45°

验证一下:

  • value = minimum→ratio = 0→angle = 225°(左下方向)✔
  • value = maximum→ratio = 1→angle = -45°(右下方向)✔
  • value = 中间值→ratio = 0.5→angle = 90°(正上方)✔

指针端点坐标

有了角度求圆周上的点:

ang=math.radians(angle)# 转弧度cos_a,sin_a=math.cos(ang),math.sin(ang)# ★ 注意 y 用减号:Qt 的 y 轴向下,数学的 y 向上tip=QPointF(cx+r*cos_a,cy-r*sin_a)

cy - r * sin_a里的减号是关键:Qt 的屏幕坐标系 y 轴朝下,
而math.sin(90°) = 1表示"向上"。不减的话指针会上下颠倒。

这个坑我吃过:当时指针转到 90° 时指向下面,排查了半天才发现是坐标系方向问题。


五、仪表盘的六个绘制步骤

defpaintEvent(self,event)->None:p=QPainter(self)p.setRenderHint(QPainter.RenderHint.Antialiasing,True)w,h=self.width(),self.height()cx,cy=w/2,h/2+8# 圆心(稍微下移,给标题留空间)r=min(w,h)/2-22# 半径(留出文字空间)rect=QRectF(cx-r,cy-r,2*r,2*r)lw=max(8.0,r*0.11)# ★ 线宽跟着尺寸走# ① 底弧(灰色轨道)pen=QPen(QColor("#e8ebf0"))pen.setWidthF(lw)pen.setCapStyle(Qt.PenCapStyle.RoundCap)# 圆头端点,更好看p.setPen(pen)p.drawArc(rect,int(START_ANGLE*16),int(SPAN_ANGLE*16))# ② 数值弧(颜色随区间变化)pen.setColor(self.current_color)# 绿 / 黄 / 红p.setPen(pen)span=SPAN_ANGLE*self.ratio p.drawArc(rect,int(START_ANGLE*16),int(span*16))# ③ 刻度线pen.setColor(QColor("#c9cfd8"))pen.setWidthF(1.5)p.setPen(pen)foriinrange(0,11):# 11 个刻度t=i/10ang=math.radians(START_ANGLE+SPAN_ANGLE*t)cos_a,sin_a=math.cos(ang),math.sin(ang)r1=r-lw/2-4r2=r-lw/2-(12ifi%5==0else7)# 每隔 5 个画长刻度p.drawLine(QPointF(cx+r1*cos_a,cy-r1*sin_a),QPointF(cx+r2*cos_a,cy-r2*sin_a))# ④ 指针ang=math.radians(START_ANGLE+SPAN_ANGLE*self.ratio)cos_a,sin_a=math.cos(ang),math.sin(ang)tip=QPointF(cx+(r-lw-6)*cos_a,cy-(r-lw-6)*sin_a)pen.setColor(QColor("#333a45"))pen.setWidthF(max(3.0,r*0.035))pen.setCapStyle(Qt.PenCapStyle.RoundCap)p.setPen(pen)p.drawLine(QPointF(cx,cy),tip)# 圆心轴帽p.setPen(Qt.PenStyle.NoPen)p.setBrush(QColor("#333a45"))p.drawEllipse(QPointF(cx,cy),r*0.07,r*0.07)# ⑤ 中间的数值p.setPen(self.current_color)f=QFont("Microsoft YaHei",int(max(13,r*0.24)),QFont.Weight.Bold)p.setFont(f)p.drawText(QRectF(cx-r,cy+r*0.18,2*r,r*0.5),int(Qt.AlignmentFlag.AlignHCenter|Qt.AlignmentFlag.AlignTop),f"{self._value:.0f}")# ⑥ 单位与标题...p.end()

四个设计要点

① 所有尺寸按半径 r 的比例算

lw=max(8.0,r*0.11)# 线宽 = 半径的 11%f=QFont(...,int(max(13,r*0.24)))# 字号 = 半径的 24%

不要写死像素值(比如lw = 12)。控件被放大两倍时,写死的线宽看起来像一根细线,
字体大小也不匹配。按比例算,任何尺寸下比例都正确——这是自绘控件的关键技巧。

max(...)是兜底,防止控件被压到极小时算出 0 或负数(会崩)。

②setCapStyle(RoundCap)让线头变圆

直线的默认端点是方的,加粗后看出明显的"切角"。设成圆头,弧线和指针的终点都是圆润的。

③ 刻度长短交替

r2=r-lw/2-(12ifi%5==0else7)

每 5 个画一根长刻度(对应 0、50、100),其余画短刻度。有了这个层次,
表盘看起来就"像真的"。

④ 绘制顺序 = 图层顺序

后画的盖住先画的。顺序是:底弧 → 值弧 → 刻度 → 指针 → 圆心 → 文字。
如果先画指针再画底弧,指针就被盖住了。


六、过渡动画:让指针"扫"过去

直接setValue()会让指针瞬移,看起来生硬。加一个逐帧逼近的小动画:

def__init__(self,...):...self._value=minimum# 当前显示值self._target=minimum# 目标值self._anim=QTimer(self)self._anim.setInterval(16)# ≈60 FPSself._anim.timeout.connect(self._step)defsetValue(self,value:float)->None:self._target=max(self.minimum,min(self.maximum,float(value)))ifnotself._anim.isActive():self._anim.start()def_step(self)->None:diff=self._target-self._valueifabs(diff)<0.2:# 足够接近 → 吸附到目标值并停止self._value=self._target self._anim.stop()self.valueChanged.emit(self._value)else:self._value+=diff*0.12# 每次逼近 12%self.update()# ★ 每帧都要请求重绘

为什么用 12% 的逼近系数?
这是个"指数衰减":越接近目标速度越慢,视觉上就像指针带着惯性扫过去,
比线性移动自然得多。60 帧/秒(16ms)下,大约 0.3 秒到位。

abs(diff) < 0.2这个判断是必须的:
不加的话,diff会无限逼近但永不等于 0,定时器永远停不下来(CPU 一直占用)。

valueChanged信号在动画结束后才发:这样外部拿到的是"稳定值",
不会收到一串中间值。这是个重要的接口设计决定——如果外部要实时值,
应该在_step里也发信号。


七、环形进度:锥形渐变

第二种常见的自绘控件是带渐变的圆环:

classRingProgress(QWidget):defpaintEvent(self,event)->None:p=QPainter(self)p.setRenderHint(QPainter.RenderHint.Antialiasing,True)w,h=self.width(),self.height()cx,cy=w/2,h/2r=min(w,h)/2-14rect=QRectF(cx-r,cy-r,2*r,2*r)lw=max(7.0,r*0.16)# 底环pen=QPen(QColor("#edf0f5"))pen.setWidthF(lw)pen.setCapStyle(Qt.PenCapStyle.RoundCap)p.setPen(pen)p.drawArc(rect,90*16,-360*16)# 整圈# 渐变进度环grad=QConicalGradient(cx,cy,90)# ★ 锥形渐变(围绕圆心转)grad.setColorAt(0.0,QColor("#2d7ff9"))grad.setColorAt(0.5,QColor("#40d0d0"))grad.setColorAt(1.0,QColor("#52c41a"))pen.setBrush(grad)# ★ 画笔用渐变而不是纯色p.setPen(pen)p.drawArc(rect,90*16,int(-360*self._value*16))# 中心百分比...p.end()

两个关键点:

①QConicalGradient是"锥形渐变"
颜色围绕一个中心点旋转分布,正好适合圆环。另外两种渐变是
QLinearGradient(线性)和QRadialGradient(径向)。

② 画笔的 brush 可以设成渐变

pen.setBrush(grad)# 线条也支持渐变填充

这是很多人不知道的能力——画出来的弧线本身就是渐变的,
比纯色弧线好看一个档次。

③drawArc(rect, 90 * 16, -360 * 16)画整圈
从 90° 开始,逆时针跨越 -360°(也就是顺时针一整圈)。


八、让自绘控件"像个正常控件"

自定义控件要能被正常使用,需要提供和内置控件一致的接口:

classGaugeWidget(QWidget):valueChanged=pyqtSignal(float)# ① 暴露信号defsetValue(self,value:float)->None:# ② 驼峰式 setter(和 Qt 一致)...defvalue(self)->float:# ③ 对应的 getterreturnself._valuedefsetMinimumSize(...)# ④ 设置最小尺寸,防止被压成 0

这样做的好处:用起来和QProgressBar、QSlider一模一样:

self.g_temp.valueChanged.connect(lambdav:self.lbl_status.setText(f"{v:.0f}℃"))self.sl_temp.slider.valueChanged.connect(self.g_temp.setValue)

信号连信号、滑块驱动仪表盘,完全透明。

还有一点:重写resizeEvent

defresizeEvent(self,event)->None:super().resizeEvent(event)self.update()# 尺寸变了要重绘

其实 Qt 在 resize 后会自动触发 paintEvent,这一行通常是冗余的。
但加上无害,还能防止某些平台下的重绘时机问题。

九、四个坑

坑 1:角度忘了乘 16
弧线画出来特别小,或者根本看不见。drawArc的角度单位是 1/16 度。

坑 2:y 坐标没取反
指针上下颠倒。Qt 的 y 轴朝下,算圆周坐标时是cy - r * sin(angle)。

坑 3:写死像素尺寸
控件放大后线宽和字号比例全乱。所有尺寸按半径 r 的比例算。

坑 4:动画定时器停不下来
diff无限逼近但永不等于 0。加abs(diff) < 阈值的判断,到阈值就stop()。


十、小结

  • 重写paintEvent+QPainter是自绘的全部机制
  • drawArc两个反直觉规定:角度单位是1/16 度;逆时针为正
  • 圆周坐标 y 要取反(Qt 的 y 轴朝下)
  • 所有尺寸按半径比例算,不要写死像素
  • 过渡动画用 QTimer 逐帧逼近,记得设停止阈值
  • 暴露setValue+valueChanged,让自定义控件用起来像内置控件

Part 5 结束。你现在能画柱状/饼/折线,能实时刷新,还能自绘任意形状的控件。

下一讲:回到"解决实际问题"的路子上。做文件自动整理工具——
按类型或日期把一团乱的下载目录归好类。这一讲的重点是冲突处理
(目标目录已有同名文件怎么办)和可撤销的移动操作。


本讲是《PyQt6 客户端编程从入门到实战》专栏的第 17 讲。专栏共 22 讲、22 个可运行案例。
**下载地址:git clone https://gitee.com/wuyikui555/pyqt6-client.git。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/4 6:56:45

Doris JSON函数快速上手:建表、写路径、选函数的完整指南

Doris JSON函数快速上手&#xff1a;建表、写路径、选函数的完整指南 【免费下载链接】doris Apache Doris is an easy-to-use, high performance and unified analytics database. 项目地址: https://gitcode.com/gh_mirrors/dori/doris Apache Doris JSON函数套件让半…

作者头像 李华
网站建设 2026/11/4 6:56:24

Regular and Irregular Nouns — One-Page A4 Notes

&#x1f4d8; Regular and Irregular Nouns — One-Page A4 NotesBritish English Plural rules examples common mistakes1️⃣ What is a Noun? A noun /naʊn/ names a person, place, thing, animal, or idea. Nouns can be countable or uncountable. Countable — c…

作者头像 李华
网站建设 2026/11/4 6:52:31

HarmonyOS API 26 UKey PIN 授权门禁:状态查询、失败归因与资源闭环

HarmonyOS API 26 UKey PIN 授权门禁&#xff1a;状态查询、失败归因与资源闭环 外接 UKey 的签名流程里&#xff0c;最危险的不是“PIN 输入框没弹出来”&#xff0c;而是页面把“用户刚刚验证过”当成一张长期通行证。资源被关闭、操作被取消、扩展返回错误&#xff0c;甚至…

作者头像 李华
网站建设 2026/11/4 6:51:28

DriverStore Explorer 完整指南:3 步清理 Windows 冗余驱动

DriverStore Explorer 完整指南&#xff1a;3 步清理 Windows 冗余驱动 【免费下载链接】DriverStoreExplorer Driver Store Explorer 项目地址: https://gitcode.com/gh_mirrors/dr/DriverStoreExplorer C 盘越来越满&#xff0c;一个常见的元凶是驱动。DriverStore Ex…

作者头像 李华