第 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()它在三种情况下被调用:
- 控件第一次显示时
- 控件被 resize 时(或父窗口重排导致它尺寸变了)
- 代码调用
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。