简介:面向Qt开发者的自定义控件实战代码包,适合已掌握Qt基础、希望突破内置控件限制、实现个性化界面与交互的中级开发者参考学习。内容围绕继承QWidget等基类展开,涵盖paintEvent绘图机制、鼠标与键盘事件重写、Q_PROPERTY属性声明与信号槽通信、QSS样式表适配、布局管理与元对象系统等关键知识点,并以数值控件、仪表盘、电池、进度条、指示灯、LCD显示等示例呈现不同复杂度的实现思路。资源共32个文件,以14个cpp源文件与13个h头文件为主体,另含pro工程文件、ui界面文件、rc资源脚本与ico图标,压缩包约62KB,工程结构完整可直接编译运行。已有759人学习下载,便于读者对照源码理解自定义控件的绘制、事件响应与样式定制流程,并迁移到实际项目中扩展界面组件。
1. 从一堆散装控件说起:qt 自定义控件到底解决什么问题
接手过一个工业上位机项目,界面里全是仪表盘、圆形进度、带刻度的旋钮,用 Qt 原生控件拼出来的效果像九十年代的软件。当时第一反应是去找现成的 qt 控件库,翻了一圈发现要么风格不搭,要么改不动。最后只能自己动手写自定义控件,从QWidget派生、重写paintEvent、用QPainter一笔一笔画出来。这件事让我意识到,Qt 自带控件只是骨架,真正让界面像样的,是自定义控件这一层。
这份资源就是围绕 qt 自定义控件展开的工程代码集合,适合两类人:一类是刚学完 Qt 基础、想搞清楚控件内部怎么运作的新手;另一类是手里有项目、需要把界面做出差异化效果的熟手。它不讲信号槽语法,而是直接落到「一个控件从继承到绘制到交互」的完整链路,拿过去就能往自己的工程里塞。
2. 控件工程结构拆解:从基类选择到目录组织
2.1 为什么大多数自定义控件都从 QWidget 派生
Qt 里能做自定义控件的基类不止一个,QWidget、QFrame、QAbstractButton都能继承。选哪个不是随便定的,取决于控件要不要内建交互语义。如果只是画个圆环进度条,QWidget足够;如果要做成可点击、可切换状态的按钮类控件,从QAbstractButton派生能省掉一堆鼠标状态管理。
这份代码里绝大多数控件走的是QWidget路线,原因很实际:QWidget是最干净的画布,不带任何默认绘制行为,paintEvent里画什么就是什么,不会被父类的样式干扰。常见做法是定义一个中间基类,把尺寸策略、背景绘制、抗锯齿开关这些公共逻辑收拢,具体控件再从这个基类派生。
// 自定义控件通用基类,收拢重复的绘制准备工作 class BaseWidget : public QWidget { Q_OBJECT public: explicit BaseWidget(QWidget *parent = nullptr) : QWidget(parent) { // 开启抗锯齿,否则圆弧和斜线会有明显锯齿 setAttribute(Qt::WA_TranslucentBackground, false); // 告诉样式表这个控件要自己绘制,避免被默认样式覆盖 setAttribute(Qt::WA_StyledBackground, true); } protected: // 子类统一调用这个函数拿到配置好的画笔 QPainter *beginPaint() { auto *p = new QPainter(this); p->setRenderHint(QPainter::Antialiasing, true); p->setRenderHint(QPainter::TextAntialiasing, true); return p; } };这段基类代码看着简单,但两个setAttribute是血泪经验。WA_StyledBackground不设的话,控件在样式表环境下会出现背景不刷新的玄学问题;抗锯齿不开,圆弧边缘的锯齿在工业屏上特别扎眼。参数上Antialiasing管图形,TextAntialiasing管文字,两个都要开,只开一个会出现文字发虚或线条发毛的情况。
2.2 目录结构怎么摆才方便复用
工程目录按「基类 / 具体控件 / 示例 / 资源」四层来分,这个划分方式在多个项目里验证过,扩展性最好。
custom-widgets/ ├── base/ # 公共基类与工具函数 │ ├── basewidget.h/cpp # 绘制基类 │ └── widgetutils.h/cpp # 颜色、几何计算工具 ├── widgets/ # 具体控件实现 │ ├── circularprogress.h/cpp │ ├── dashboardgauge.h/cpp │ ├── togglebutton.h/cpp │ └── ... ├── demo/ # 演示程序,每个控件一个页面 │ ├── main.cpp │ └── demowindow.h/cpp ├── resources/ # 图片、字体、qss └── custom-widgets.pro # qmake 工程文件base和widgets分开的意义在于:换项目时只拷base和需要的控件文件,不用连带整个 demo。widgetutils里放的是颜色插值、圆角矩形路径、刻度角度换算这类纯函数,跟具体控件解耦,测试起来也方便。demo 目录单独编译,每个控件一个独立页面,改完控件直接跑 demo 看效果,不用在业务工程里反复编译。
提示:如果项目用 CMake 而不是 qmake,把
.pro换成CMakeLists.txt时,记得给每个控件目录单独add_library,做成静态库再链接到主工程,这样增量编译速度会快很多。
2.3 尺寸策略与布局适配
自定义控件最容易翻车的地方不是绘制,是尺寸。原生控件有sizeHint和minimumSizeHint,自定义控件如果不重写这两个函数,放进布局里要么被压成一条线,要么撑满整个窗口。
QSize CircularProgress::sizeHint() const { // 给一个合理的默认尺寸,布局会优先参考这个值 return QSize(160, 160); } QSize CircularProgress::minimumSizeHint() const { // 最小尺寸,低于这个值绘制会糊成一团 return QSize(80, 80); } // 高度随宽度等比缩放,保证圆环始终是正圆 bool CircularProgress::hasHeightForWidth() const { return true; } int CircularProgress::heightForWidth(int w) const { return w; }hasHeightForWidth返回 true 配合heightForWidth,是让控件在布局里保持宽高比的标准做法。不写这两个函数,控件放进QHBoxLayout里会被拉成椭圆,圆环变成扁的,这种问题在第一次跑 demo 时几乎必现。sizeHint给的是理想值,minimumSizeHint给的是底线,两个都要按控件的实际绘制逻辑来定,不能拍脑袋写。
3. 核心绘制链路:paintEvent 里到底发生了什么
3.1 一个圆环进度控件的完整绘制流程
拿圆环进度控件当样本,因为它把「背景、进度、文字、动画」四件事都占全了,拆明白这一个,其他控件基本是同一套骨架换几何形状。
void CircularProgress::paintEvent(QPaintEvent *event) { Q_UNUSED(event) QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // 1. 计算绘制区域,留出线宽的一半作为边距,防止圆弧被裁掉 int side = qMin(width(), height()); int margin = m_lineWidth / 2 + 2; QRectF rect(margin, margin, side - 2 * margin, side - 2 * margin); // 2. 画背景圆环 QPen bgPen(m_bgColor, m_lineWidth, Qt::SolidLine, Qt::RoundCap); painter.setPen(bgPen); painter.drawArc(rect, 0, 360 * 16); // Qt 的角度单位是 1/16 度 // 3. 画进度圆弧,从 90 度位置开始逆时针画 QPen fgPen(m_fgColor, m_lineWidth, Qt::SolidLine, Qt::RoundCap); painter.setPen(fgPen); int startAngle = 90 * 16; int spanAngle = -m_value / 100.0 * 360 * 16; painter.drawArc(rect, startAngle, spanAngle); // 4. 画中心文字 painter.setPen(m_textColor); QFont font = painter.font(); font.setPixelSize(side / 6); font.setBold(true); painter.setFont(font); painter.drawText(rect, Qt::AlignCenter, QString::number(m_value, 'f', 0) + "%"); }逐段说。第一步的margin计算是关键,drawArc的画笔是以路径为中心向两侧扩展的,如果绘制矩形贴着控件边缘,圆弧外侧会被裁掉一半,看起来像被削平了。第二步背景环用RoundCap让端点变圆,视觉上更柔和。第三步的角度单位是 Qt 的历史遗留设计,1/16 度,所以 360 度要乘 16,这个不乘的话圆弧只画一小段,第一次写的人基本都会踩。spanAngle取负值表示逆时针方向,配合startAngle = 90 * 16从顶部开始,符合进度条的直觉。第四步文字大小按控件边长动态算,写死像素值在不同分辨率下会出问题。
3.2 属性系统与动画:让控件动起来
静态绘制只是第一步,控件要好看得有过渡动画。Qt 的QPropertyAnimation要求属性用Q_PROPERTY声明,这是自定义控件接入动画体系的前提。
class CircularProgress : public BaseWidget { Q_OBJECT Q_PROPERTY(double value READ value WRITE setValue NOTIFY valueChanged) public: double value() const { return m_value; } void setValue(double v) { if (qFuzzyCompare(m_value, v)) return; m_value = qBound(0.0, v, 100.0); // 夹紧到合法区间 update(); // 触发重绘 emit valueChanged(m_value); } // 外部调用这个接口做平滑过渡 void animateTo(double target, int durationMs = 600) { auto *anim = new QPropertyAnimation(this, "value", this); anim->setDuration(durationMs); anim->setStartValue(m_value); anim->setEndValue(target); anim->setEasingCurve(QEasingCurve::OutCubic); // 先快后慢,观感自然 anim->start(QAbstractAnimation::DeleteWhenStopped); } signals: void valueChanged(double); private: double m_value = 0.0; int m_lineWidth = 12; QColor m_bgColor = QColor(230, 230, 230); QColor m_fgColor = QColor(64, 158, 255); QColor m_textColor = QColor(51, 51, 51); };Q_PROPERTY里的NOTIFY不能省,动画系统靠它感知属性变化。setValue里qBound夹紧是防御性写法,外部传个 150 进来不至于画出超过一圈的圆弧。update()触发的是异步重绘,不是立即绘制,所以连续调setValue不会卡。animateTo里用DeleteWhenStopped让动画对象自己回收,不手动 delete,避免内存泄漏。缓动曲线选OutCubic是因为进度类动画先快后慢符合心理预期,线性动画看起来像卡顿。
3.3 鼠标交互与状态管理
带交互的控件,比如开关按钮,要在mousePressEvent和mouseReleaseEvent里维护状态,同时处理好按下和释放的视觉反馈。
void ToggleButton::mousePressEvent(QMouseEvent *e) { if (e->button() == Qt::LeftButton) { m_pressed = true; update(); // 立即重绘,显示按下态 } QWidget::mousePressEvent(e); } void ToggleButton::mouseReleaseEvent(QMouseEvent *e) { if (e->button() == Qt::LeftButton && m_pressed) { m_pressed = false; // 只有释放点在控件内才算一次有效点击 if (rect().contains(e->pos())) { m_checked = !m_checked; emit toggled(m_checked); } update(); } QWidget::mouseReleaseEvent(e); }这里有个细节:释放时判断rect().contains(e->pos()),是为了实现「按下后拖出控件再松开不触发」的标准行为。不判断的话,用户误操作也会切换状态,体验很差。m_pressed状态在paintEvent里用来画不同的背景色,形成按压反馈。事件处理末尾调用父类实现是习惯,虽然QWidget的默认实现是空的,但保持这个习惯在控件被嵌入复杂容器时能避免一些事件传递的怪问题。
4. 避坑与排查:自定义控件最容易翻车的五个地方
4.1 控件不显示或显示为空白
现象:控件加进布局后一片空白,日志没有任何报错。原因通常是paintEvent没被调用,或者绘制区域算出来是负尺寸。排查顺序是先确认控件isVisible()为 true,再在paintEvent第一行打日志确认被调用,最后检查rect()的宽高是否大于零。常见触发点是minimumSizeHint返回了比父布局分配空间还大的值,导致控件被压成 0 尺寸。解决方法是把minimumSizeHint调小,或者在布局里给控件设setMinimumSize。
4.2 高 DPI 屏上控件模糊
现象:在 4K 屏或缩放 150% 的显示器上,控件边缘发虚。原因是绘制时用了整数坐标和固定像素尺寸,没有考虑设备像素比。解决方法是绘制前用devicePixelRatioF()换算,或者干脆全程用QPainterPath加浮点坐标,让 Qt 自己做缩放。另外QPainter::Antialiasing在高 DPI 下要配合Qt::HighDpiScaleFactorRoundingPolicy设置,在main函数里QApplication::setHighDpiScaleFactorRoundingPolicy设成PassThrough,缩放才不会被取整。
4.3 样式表对自定义控件不生效
现象:给控件写了 qss,背景色、边框全都不起作用。原因是QWidget派生类默认不处理样式表,必须重写paintEvent里手动调QStyleOption绘制,或者设WA_StyledBackground属性。更彻底的做法是重写paintEvent时先调QStyle::drawPrimitive(PE_Widget, ...)把样式表背景画上,再画自己的内容。这个顺序反了,自己的绘制会被样式表背景盖住。
4.4 动画卡顿或数值跳变
现象:进度动画一卡一卡的,或者数值直接跳到终点没有过渡。原因多半是Q_PROPERTY的WRITE函数里调了repaint()而不是update(),repaint是同步立即重绘,动画每帧都同步绘制会拖垮主线程。改成update()走事件循环合并重绘就好了。另一个原因是动画对象在栈上创建,函数返回就析构了,动画根本没跑完,必须用new加DeleteWhenStopped。
4.5 控件在布局中尺寸异常
现象:控件要么撑满整个窗口,要么被压成一条缝。原因是sizePolicy没设对。默认的Preferred策略在空间充足时会尽量拉伸,空间不足时按sizeHint压缩。如果希望控件保持固定比例,要设setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred)并配合hasHeightForWidth。如果希望控件不随窗口拉伸,设成Fixed并把sizeHint定死。这块没有万能配置,得按控件在界面里的角色逐个调。
5. 二次开发与改造:把控件接进真实项目
5.1 从 demo 到业务工程的移植步骤
把控件从 demo 挪到业务工程,不是拷文件那么简单。第一步,把base目录和需要的控件.h/.cpp拷过去,.pro里加对应源文件。第二步,检查控件依赖的widgetutils函数是否被一起带过去,漏了会编译报错。第三步,业务工程如果用了 qss 全局样式,要确认自定义控件不会被全局样式误伤,必要时在控件构造里setObjectName并写针对性的样式规则。第四步,跑起来看尺寸,业务界面的布局密度通常比 demo 高,sizeHint大概率要调小。
5.2 用配置项替代硬编码颜色和尺寸
demo 里的颜色和线宽是写死的,接进项目必须参数化。我一般会给每个控件加一组setXxx接口,或者更省事的做法是读一个QSettings配置。
void CircularProgress::applyTheme(const QJsonObject &theme) { // 从主题配置里读颜色,读不到就用默认值兜底 m_bgColor = QColor(theme.value("bgColor").toString("#e6e6e6")); m_fgColor = QColor(theme.value("fgColor").toString("#409eff")); m_textColor = QColor(theme.value("textColor").toString("#333333")); m_lineWidth = theme.value("lineWidth").toInt(12); update(); }用QJsonObject传主题是因为业务工程里通常已经有一套主题配置体系,JSON 格式通用性好。toString带默认值参数是防御性写法,配置缺字段时不至于画出透明控件。lineWidth变化后要重新算绘制边距,所以update()必须调。这套接口的好处是换肤时不用改控件代码,只换配置文件。
5.3 验证改造效果的方法
改完控件怎么确认没改坏?我的习惯是保留 demo 工程,每次改完控件先跑 demo 看视觉效果,再用 Qt Test 写几个针对sizeHint和属性边界的单元测试。比如给setValue传 -10 和 200,断言内部值被夹到 0 和 100;给控件设不同尺寸,断言heightForWidth返回值和宽度一致。视觉靠眼睛,逻辑靠断言,两条腿走路才稳。
| 改造点 | 验证方式 | 关注指标 |
|---|---|---|
| 颜色主题化 | demo 切换主题 | 各状态颜色是否正确 |
| 尺寸策略调整 | 拉伸窗口 | 控件是否变形 |
| 动画参数 | 连续触发动画 | 是否卡顿、是否内存增长 |
| 高 DPI 适配 | 缩放系统显示 | 边缘是否清晰 |
5.4 一个具体技巧:用 QPainterPath 统一复杂形状
最后分享一个改造时特别有用的技巧。控件形状一旦复杂起来,drawArc、drawRect这些零散调用会很难维护,改用QPainterPath把整个形状描述出来,再一次性drawPath,代码可读性和可组合性都上一个台阶。
QPainterPath CircularProgress::buildRingPath(const QRectF &rect, double percent) { QPainterPath path; // 外圈顺时针,内圈逆时针,形成环形填充区域 path.addEllipse(rect); QRectF inner = rect.adjusted(m_lineWidth, m_lineWidth, -m_lineWidth, -m_lineWidth); path.addEllipse(inner); path.setFillRule(Qt::OddEvenFill); // 奇偶填充挖空内圈 return path; }OddEvenFill是挖空的关键,两个同心椭圆叠加后,内圈区域因为被覆盖两次而被排除,形成圆环。这个技巧在画仪表盘刻度、异形按钮时都能复用,比手动算角度画扇形省事得多。
从那以后我每次写自定义控件,都强制先把sizeHint、minimumSizeHint、paintEvent三个函数列出来再动手,缺一个后面必返工。希望这份拆解能帮到你,把 qt 自定义控件真正用进自己的项目里。
本文还有配套的精品资源,点击获取