简介:这是一份基于Qt自定义滑动选择器的完整工程源码,面向需要掌握Qt自定义控件开发的初中级开发者,可用于解决系统自带控件难以满足个性化交互需求的问题。工程支持水平与垂直两种滑动模式,并具备颜色定制、值域设定与滑动事件响应能力,适用于音量调节、进度控制、日期选择等场景。压缩包共8个文件,以cpp、h、ui、pro为主,其中cpp与h构成核心逻辑和接口,ui负责界面布局,pro用于工程配置,整体仅9KB,结构精简便于快速阅读和二次改造。阅读源码可掌握控件绘制、鼠标与触摸事件处理、布局管理等关键环节,也能直接修改配色与值域后集成到个人项目中。已有1765人学习下载,适合想通过实际案例学习事件处理、绘图逻辑和布局管理的Qt学习者。
1. 基于Qt自定义的滑动选择器:为什么默认控件不够用
做Qt桌面客户端时,遇到“档位选择”或“候选项切换”的交互,很多人第一反应是直接用QSlider。但默认QSlider有两条硬伤:样式表在不同平台下的渲染差异很大,刻度标签的排列完全不受控。所以我在需要交付自定义控件的项目里,会基于Qt自己实现一个滑动选择器:继承QWidget,用QPainter画轨道和滑块,鼠标事件自己处理。这样才能让滑块位置、选中高亮、文字样式全都按设计稿来,而不是迁就平台默认效果。
这个方向适合两类人:一类是要把交互细节和视觉都握在自己手里的Qt开发者,另一类是刚接触自绘控件、想搞懂事件与绘制怎么配合的初学者。前者能从这里拿到一套完整的实现路径和参数经验,后者能照着代码一步步把控件跑起来,再理解背后的坐标映射与状态设计。
这篇文章先拆交互模型,再给一个可运行的最小实现,然后把拖动闪屏、高DPI错位、吸附失灵这些坑分别列出来,最后落到性能优化和自动化验证。读完你可以直接复现一个带吸附动画、可拖拽的滑动选择器控件,并且知道改哪些参数、动哪些逻辑会引发什么问题。
2. 先定交互模型:滑块位置、值域映射与三套状态的拆解
2.1 把滑动选择器拆成三套状态:稳定值、拖拽值与几何命中
默认QSlider真正烦人的地方,不只是视觉。它的valueChanged在拖动过程中会连续发射,而业务逻辑往往只关心最终停在哪个挡位。选中的刻度文字也需要你额外用标签去对齐,牵一发而动全身。自定义控件能解决的,就是把这些语义统一收口:内部维护一套清晰的“状态机”,对外只暴露最终结果。
我习惯把滑动选择器拆成三个状态来设计。第一是稳定态,用int保存m_currentIndex,这是控件对外承诺的当前值。第二是拖拽态,用qreal保存m_dragItem,这个值允许是小数。鼠标拖到第3项和第4项之间时,m_dragItem可能是3.6,它代表滑块此刻的连续位置,不代表任何真实选项。第三是几何态,用一个QRectF保存滑块矩形,每次鼠标事件都要先和它做命中判断。
为什么不能用单一变量搞定?因为三个状态的变化节奏完全不同。m_currentIndex只在松手吸附后更新,m_dragItem在鼠标移动时高频变化,而滑块几何是前两者的派生结果。如果拖动过程中直接把m_currentIndex改成小数或实时索引,currentIndexChanged信号就会发得又乱又密,调用方被迫做节流,问题就扩散出去了。把拖拽中间过程隔离成独立变量,后面代码会清爽很多。
几何命中也是设计重点。滑块在视觉上是一个半径约10像素的圆,但如果只按这个圆形做contains判断,点击体验会非常差。鼠标稍微偏一点就点不中,用户会以为控件坏了。我一般把命中矩形在滑块基础上外扩8到12像素,这也意味着m_thumbRect不能被绘制逻辑独享,鼠标事件同样要读它。最省心的做法是让paintEvent每帧统一更新这个矩形,两个事件入口都从同一份几何读取。
2.2 值域与像素的映射:把选项索引换算成滑块坐标
滑动选择器本质上是一个坐标换算器。横向滑条最常见的形态是:左右各留一段操作留白kPadding,选项均匀分布在剩余宽度上。假设控件宽度为W,选项数量为n,步长step = (W - 2 * kPadding) / (n - 1)。那么第i项对应的滑块中心x坐标就是:
x_i = kPadding + i * step
反向映射则把鼠标像素位置换算成连续索引:
item = (x - kPadding) / step
这两个公式是整个控件的核心转换层。关键细节是:n等于1时步长公式分母为0,必须单独处理。我一般让itemStep()在n <= 1时返回0,所有用到步长的地方先判空。另一个细节是坐标基准的选择:将滑块中心作为位置锚点,而不是滑块左边界。这样绘制椭圆、计算文字对齐、命中检测都围绕同一个中心点展开,避免各处各算各的基准,越改越乱。
映射函数我坚持只保留一份定义。如果paintEvent里自己写一遍步长,鼠标事件里再写一遍,两处代码一旦因为后续调整padding或宽度而分叉,就会出现“画在A处、点在B处”的诡异问题。更规范的做法是把itemStep、posToItem、itemToPos三个函数收成私有接口,全控件所有涉及坐标换算的地方都只调用它们。这算是我踩过坑之后养成的习惯:控件越复杂,越要把几何计算从事件与绘制代码中抽离出来。
如果你要做的是连续值滑条,比如音量条或亮度条,思路完全一样,只是把“索引”换成“浮点数值范围”。离散场景用qRound吸附到最近选项,连续场景则直接让m_dragItem经过一次钳制后作为最终值返回。底层的坐标公式、命中逻辑、绘制分层都不用推翻,这也是先把坐标系设计好的回报。
2.3 绘制分层:轨道、滑块、文本的合成顺序
自绘控件的绘制顺序,我严格按“底、中、顶”三层来组织。最底层是轨道,一条圆角矩形表示滑道;中间层是选项文字和已选区间的颜色块;顶层是滑块,盖在轨道上,同时把当前选中项的文字高亮出来。这三层的顺序不是随便排的:QPainter没有Z轴概念,后画的内容天然覆盖先画的内容,所以滑块必须最后一个画,才能浮在轨道和文字之上。
轨道和已选区间之间也存在覆盖关系。已选区间我用一条从起点到滑块中心的高亮色带表示,它必须在轨道之后绘制,才能覆盖在灰色底槽上。文字层我放在高亮色带之后、滑块之前,否则滑块会盖住相邻选项的文字。如果你想实现“滑块压在未选中文字上,但选中文字能浮在滑块上方”的效果,就需要单独再画一次当前项文字。这个细节视觉差异很大,代码上也只多两三行。
绘制阶段还有一个容易被忽略的选择:是否开启QPainter::Antialiasing。圆角轨道和圆形滑块必须开启抗锯齿,不然边缘锯齿感很明显。但抗锯齿会让文字渲染出现亚像素偏移,这跟后面讲到的“拖动时文字抖动”有直接关系。所以我一般只在轨道、滑块这些几何元素上开抗锯齿,文字绘制单独控制坐标取整。这个取舍新手往往意识不到,等出现抖动再回头排查会浪费不少时间。
分层结构同样决定了性能优化方向。轨道、文字、色带这些元素在用户拖动过程中是基本不变的,真正变化的只有滑块位置。既然变化只发生在局部,就不需要每次全量重绘。先记住这个结论,到第5章我会给出具体的预渲染和局部刷新方案,这里先把绘制顺序的层次感建立起来。
3. 从零写一个可拖拽的滑动选择器:最小实现与参数调法
3.1 类结构设计:接口、信号与需要暴露给外部的属性
先给出类定义。这个MinimalSelector叫什么都不重要,关键是接口划分:外部只需要设置选项、设置当前值、接收变化信号,内部自己处理绘制和鼠标事件。
#pragma once #include <QWidget> #include <QStringList> class SlideSelector : public QWidget { Q_OBJECT public: explicit SlideSelector(QWidget* parent = nullptr); void setItems(const QStringList& items); const QStringList& items() const; int currentIndex() const; void setCurrentIndex(int index); QString currentItem() const; signals: void currentIndexChanged(int index); protected: void paintEvent(QPaintEvent* event) override; void mousePressEvent(QMouseEvent* event) override; void mouseMoveEvent(QMouseEvent* event) override; void mouseReleaseEvent(QMouseEvent* event) override; QSize sizeHint() const override; private: qreal itemStep() const; qreal posToItem(qreal x) const; qreal itemToPos(qreal item) const; int snapIndex(qreal item) const; void updateThumbRect(); QStringList m_items; int m_currentIndex = 0; // 稳定态:对外公布的选中项 bool m_dragging = false; // 是否正在拖拽 qreal m_dragItem = 0.0; // 拖拽态:允许落在两个选项之间 QRectF m_thumbRect; // 滑块几何,paintEvent 里统一刷新 };接口设计上有几个取舍。setItems要重置m_currentIndex为0,并调用updateGeometry通知父布局重新计算尺寸,因为选项数量变化会直接影响控件最小宽度。currentIndexChanged信号只在松手完成吸附后发一次,拖动过程中不发,这样调用方不用自己做防抖。setCurrentIndex是程序化设置入口,必须走钳制逻辑,防止外部传入越界值。
m_dragItem用qreal而不是int,是这个类最核心的设计决定。鼠标位置映射出来的索引天然是浮点,比如拖动到第3和第4项之间会得到3.7。保留这个浮点中间态,吸附动画才有起点;如果一开始就用int存储,松手动画就无从谈起。m_thumbRect则属于几何态,它既服务于命中检测,又服务于局部刷新,下一节能看到它如何在paintEvent里被统一赋值。
3.2 paintEvent 绘制实现:轨道、浮块、选中文字怎么画
绘制代码放在paintEvent里,注意它只做一件事:根据当前状态把控件画出来。不要在这里计算业务逻辑,也不要在这里做耗时IO。
void SlideSelector::paintEvent(QPaintEvent*) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing, true); const int n = m_items.size(); if (n == 0) return; const qreal padding = 16.0; const qreal trackY = height() / 2.0; const int selected = m_dragging ? snapIndex(m_dragItem) : m_currentIndex; // 1. 轨道 QRectF trackRect(padding, trackY - 2.0, width() - 2 * padding, 4.0); p.setPen(Qt::NoPen); p.setBrush(QColor(220, 220, 220)); p.drawRoundedRect(trackRect, 2.0, 2.0); // 2. 已选区间 qreal from = itemToPos(0); qreal to = itemToPos(selected); if (to > from) { QRectF selRect(from, trackY - 2.0, to - from, 4.0); p.setBrush(QColor(0, 150, 255)); p.drawRoundedRect(selRect, 2.0, 2.0); } // 3. 选项文字 QFont normalFont = font(); normalFont.setPixelSize(13); QFontMetrics fm(normalFont); for (int i = 0; i < n; ++i) { qreal x = itemToPos(i); QRectF textRect(x - 40.0, trackY - 28.0, 80.0, 20.0); p.setFont(normalFont); p.setPen(i == selected ? QColor(0, 150, 255) : QColor(120, 120, 120)); p.drawText(textRect, Qt::AlignCenter, m_items.at(i)); } // 4. 滑块 qreal thumbX = m_dragging ? itemToPos(m_dragItem) : itemToPos(m_currentIndex); QRectF thumbRect(thumbX - 10.0, trackY - 10.0, 20.0, 20.0); m_thumbRect = thumbRect; p.setPen(QPen(QColor(255, 255, 255), 1.5)); p.setBrush(QColor(0, 150, 255)); p.drawEllipse(thumbRect); }绘制顺序是轨道、已选区间、文字、滑块。滑块最后画,所以它浮在最上面。选中文字的颜色用了和滑块一致的主题蓝,未选中项用灰色。这里有一个参数值得注意:文字绘制区域宽度固定为80像素,而不是用QFontMetrics量出每个文本的实际宽度。这样做的原因是让文字始终在滑块左右20像素的范围内居中,选项文字长短不一时,也不会出现相邻文字互相挤动。
m_thumbRect在paintEvent里被更新,这不是偷懒,而是有意的设计。鼠标事件和绘制代码如果各自维护一份滑块几何,就有同步风险。统一让paintEvent每帧刷新它,能保证所有入口读到的都是同一份几何。必须要说明的是,paintEvent恢复默认参数会让外部看不到这块几何,所以我留了updateThumbRect这个私有函数,后面局部刷新用得上。
这里的默认参数是我反复调过的结果:kPadding左右留白16像素,轨道高度4像素,滑块半径10像素,文字像素大小13。你完全可以根据设计稿改,但要注意左右留白、轨道高度、滑块半径三者是有联动关系的。滑块半径加大时,轨道两侧的留白也要适当放宽,否则滑块会滑出轨道视觉边界。
3.3 鼠标事件与吸附算法:拖动、点击、松手回弹的完整逻辑
鼠标事件是交互的入口,我把三个事件的处理逻辑一次给出。
void SlideSelector::mousePressEvent(QMouseEvent* e) { if (m_items.isEmpty()) return; const QPointF pos = e->position(); // Qt6 写法,Qt5 用 localPos() const QRectF hitRect = m_thumbRect.adjusted(-8.0, -8.0, 8.0, 8.0); m_dragging = true; if (hitRect.contains(pos)) { // 点在滑块上:以滑块中心为起点,避免滑块“跳”到鼠标位置 m_dragItem = posToItem(m_thumbRect.center().x()); } else { // 点在轨道上:直接跳转到对应位置 m_dragItem = posToItem(pos.x()); } update(); } void SlideSelector::mouseMoveEvent(QMouseEvent* e) { if (!m_dragging) return; m_dragItem = posToItem(e->position().x()); update(); } void SlideSelector::mouseReleaseEvent(QMouseEvent*) { if (!m_dragging) return; m_dragging = false; const int index = snapIndex(m_dragItem); if (index != m_currentIndex) { m_currentIndex = index; emit currentIndexChanged(index); } update(); }配合坐标换算与吸附函数:
qreal SlideSelector::itemStep() const { const int n = m_items.size(); if (n <= 1) return 0.0; return (width() - 2 * 16.0) / (n - 1); } qreal SlideSelector::posToItem(qreal x) const { const int n = m_items.size(); if (n <= 1) return 0.0; const qreal step = itemStep(); return std::clamp((x - 16.0) / step, 0.0, double(n - 1)); } qreal SlideSelector::itemToPos(qreal item) const { const int n = m_items.size(); if (n <= 1) return width() / 2.0; return 16.0 + item * itemStep(); } int SlideSelector::snapIndex(qreal item) const { return std::clamp(qRound(item), 0, m_items.size() - 1); }鼠标事件里有两个容易忽略的细节。第一,mousePressEvent判断点中滑块后,m_dragItem要从滑块中心算起,而不是从鼠标位置算起。如果不这样做,点击滑块边缘拖动的一瞬间,滑块会先“跳”到鼠标下方,手感极差。第二,mouseMoveEvent里只更新m_dragItem并调用update(),不发信号。信号全部集中在mouseReleaseEvent里发,保证调用方只在交互结束时收到一次通知。
posToItem用了std::clamp把连续索引限制在[0, n-1]区间。这意味着鼠标拖出控件边界时,m_dragItem也不会越界,松手吸附自然就落在第一个或最后一个档位。这是最后一道保险,即使mouseReleaseEvent里因为某些异常没有执行snapIndex,控件的状态也不会变成非法值。
Qt版本兼容也要提一下。Qt6里QMouseEvent使用position()返回QPointF,而Qt5是localPos()。如果工程要跨版本编译,最好写一个兼容宏,或者统一封装一个本地鼠标坐标函数。很多老项目升级到Qt6后出现点击错位,多半就是这里没适配。
4. 自定义滑动选择器的 5 个常见坑:闪屏、错位、抖动与吸附失灵
4.1 拖动时整个控件闪烁
现象:鼠标一拖动,滑块和文字就开始闪,严重时出现残影和拖尾。
原因:最常见的是在paintEvent里每次重新构造QPainter对象并使用repaint()强制同步刷新。repaint()会立即触发重绘,而鼠标移动事件频率很高,几十次重绘挤在同一帧里,画面自然闪。另一个常见原因是绘制函数本身太重,比如每次重绘都创建一个QLinearGradient大渐变背景,或者反复构造QFontMetrics对象。
解决:刷新统一用update(),它会将多个重绘请求合并到下一帧事件循环中,这是Qt官方推荐的做法。其次确认控件是否设置了setAttribute(Qt::WA_OpaquePaintEvent, true)。设置了这个属性后,Qt不会先用背景色填充控件,如果paintEvent里又漏画了某些区域,就会出现残影。如果你开了这个属性,paintEvent必须自己画满全部内容,不能只画滑块。
4.2 高DPI下点击位置和滑块对不上
现象:2倍屏或系统缩放150%时,明明点到滑块中心,选中结果却偏了几十个像素。
原因:鼠标事件坐标和绘制坐标处于不同的坐标系。Qt5.6以后开启高DPI缩放,鼠标事件默认已经换算为逻辑坐标,但如果你用了QCursor::pos()再mapFromGlobal,或者自己根据devicePixelRatio手动乘除坐标,就会把坐标缩放两次,出现偏移。
解决:统一使用事件自带的本地坐标,Qt6用e->position(),Qt5用e->localPos(),不要用全局坐标手算。绘制QPixmap时,如果需要贴图,记得用pixmap.setDevicePixelRatio(devicePixelRatio()),并且把QPainter的目标设置为同一逻辑坐标体系。验证方法很简单:在mousePressEvent里打印pos.x()和posToItem(pos.x()),再打印鼠标移动时这两者的对应关系,正常情况下应该是一条平滑直线。
4.3 松手后滑块吸附到错误的档位
现象:鼠标拖到3.4的位置,松手后预期停在3档,实际却落在2档或4档。
原因:吸附本身用了qRound不会错,问题多半出在步长两次计算不一致。比如paintEvent里写了一遍(width() - 18) / (n - 1),鼠标换算里又写了(width() - 16) / (n - 1),窗口resize一次后两边就分叉了。padding常量只要有一处改了另一处没改,吸附就会产生系统性偏移。
解决:全控件只保留itemStep()一个步长来源,所有绘制、换算、命中逻辑都调用它。修改padding常量后,检查所有使用它的地方是否同步更新。更保险的方式是对posToItem和itemToPos做往返测试:任意合法索引i,经过itemToPos(i)转到像素,再经posToItem转回索引,结果应该还是i。这种纯函数非常适合放进自动化测试,一次就能把偏移问题暴露出来。
4.4 高频率拖动时文字左右抖动
现象:快速来回拖动滑块,选项文字左右轻微晃动,停住后位置又恢复正常。
原因:drawText接收了浮点坐标,字体的光栅化在亚像素位置不稳定,每个帧渲染结果会有细微差异。另一个因素是选中文字用加粗或不同字号,字体宽度变化导致后续文本对齐跳动。
解决:绘制文字前把x坐标用qRound取整,然后把整个textRect定义成固定宽度的整数矩形。选中态不要用QFont::Bold切换字重,改用颜色区分,或者给选中项画一个固定宽度的背景条。这样不同项的文本宽度不会影响对齐,滑块遮住文字边缘时也不会产生“字被削一刀”的视觉跳动。
4.5 滑块拖出控件边界后松手不回弹
现象:按住滑块拖到控件窗口之外再松手,滑块停在边界外,currentIndex变成越界值。
原因:mouseReleaseEvent里虽然做了吸附处理,但posToItem没有做范围限制,m_dragItem可以超出[0, n-1]。snapIndex里的qRound先把越界值四舍五入,再clamp时如果上下限写错或者漏了,就出现了越界索引。
解决:在posToItem里直接做clamp,拖到天边也只是0或n-1,这是第一道保险。snapIndex里再clamp一次,作为最终发布前的第二道保险。如果你以后要做“拖出边界后自动收回”的效果,也是在posToItem的clamp基础上做插值动画,不会破坏控件的值域安全。
5. 让滑动选择器更跟手:预渲染轨道、局部刷新与事件节流
5.1 把静态背景预渲染成离屏QPixmap
在一次完整重绘中,轨道、文字、已选色带都是固定内容,每帧重复画一遍纯属浪费。尤其是文字绘制,涉及字体匹配和字形光栅化,开销不低。我一般把这三层预先画到一张离屏QPixmap上,paintEvent只需要贴图再画滑块。
void SlideSelector::rebuildTrackPixmap() { const qreal dpr = devicePixelRatio(); m_trackPixmap = QPixmap(size() * dpr); m_trackPixmap.setDevicePixelRatio(dpr); m_trackPixmap.fill(Qt::transparent); QPainter p(&m_trackPixmap); p.setRenderHint(QPainter::Antialiasing, true); drawTrack(p); // 画轨道 drawRange(p); // 画已选色带 drawTexts(p); // 画文字 }paintEvent里就变得非常轻:
void SlideSelector::paintEvent(QPaintEvent*) { QPainter p(this); p.drawPixmap(0, 0, m_trackPixmap); drawThumb(p); // 只画滑块 }这个方法有两个细节必须处理。第一,QPixmap的尺寸要用size()乘以devicePixelRatio(),再调用setDevicePixelRatio,否则高分屏上贴图会模糊或偏大。第二,这张缓存图在哪些时机要重建:resizeEvent、setItems、颜色主题变化时。我一般把重建逻辑单独封装,凡是会改变轨道外观的入口都主动调用一次,而不是等paintEvent里去判断缓存是否过期,省得每个入口都写重复代码。
5.2 局部刷新代替全量update
背景预渲染完之后,仍然可以做进一步优化:拖动过程中,变化的只是滑块周围一小块区域。update不带参数会重绘整个控件,虽然背景贴图本身很快,但整块重绘在跨平台场景下依然可能触发额外的合成开销。正确的做法是只更新脏矩形。
void SlideSelector::updateThumbArea() { const qreal extra = 10.0 + 8.0; // 滑块半径 + 命中外扩 update(m_thumbRect.adjusted(-extra, -extra, extra, extra)); }mouseMoveEvent里从update()改成updateThumbArea(),重绘面积大幅缩小。这里必须配合一个前提:控件不要开启透明背景的自动填充。如果没设置WA_OpaquePaintEvent,Qt在局部重绘前会先把该区域填充成背景色,再调用paintEvent,这会导致被填充区域和未填充区域的视觉出现色差。所以我一般把局部刷新和WA_OpaquePaintEvent一起开:paintEvent始终绘制完整内容,Qt不再预填背景。
事件节流这块,其实只要update本身已经做了合并,一般不会出现重绘风暴。真正的性能隐患是你在mouseMoveEvent里写了额外的耗时操作,比如遍历一个很大的列表、读写数据库、输出日志。这些操作应该全部移出鼠标事件,或者用条件判断只在特定条件下执行。一个滑动选择器在拖动时每帧做的事情应该限定在“更新一个浮点值+一次局部重绘”,任何超出这个范围的工作都值得怀疑。
5.3 用 QVariantAnimation 做吸附动画
松手瞬间从3.7直接跳到4.0,视觉上有点生硬,尤其是从远处拖过来时。给滑块加一段短动画能明显提升手感。我常用QVariantAnimation做位置插值,因为它轻量且不需要给控件增加额外属性。
void SlideSelector::animateToIndex(int index) { if (m_anim) m_anim->stop(); // 打断上一次动画,防止抢控制权 m_anim = new QVariantAnimation(this); m_anim->setStartValue(m_thumbRect.center().x()); m_anim->setEndValue(itemToPos(index)); m_anim->setDuration(140); m_anim->setEasingCurve(QEasingCurve::OutCubic); connect(m_anim, &QVariantAnimation::valueChanged, this, [this](const QVariant& v) { m_animX = v.toReal(); updateThumbArea(); }); connect(m_anim, &QVariantAnimation::finished, this, [this]() { m_animX = std::nullopt; }); m_anim->start(); }m_animX用std::optional 存储,为空时表示没有动画在跑,drawThumb里就用当前值,否则用动画插值。这个设计避免了为动画单独维护一整套状态标志。动画持续140毫秒是体验比较合适的值,超过200毫秒会显得拖沓,低于100毫秒又太快看不出效果。缓动曲线必须用OutCubic,线性缓动会让滑块匀速滑过去,手感很“机械”。
这个方案最需要留意的是动画被打断的场景。用户松手后动画还没跑完,就又按下鼠标开始拖动,此时必须立即停掉动画。如果不停止,动画会继续把滑块拉向目标位置,和用户的拖拽方向打架,表现就是“滑块自己会跑”,极难用。所以在mousePressEvent开头加一句m_anim->stop(),成本极低但体验差异巨大。
6. 验证选择器质量的一个实用技巧:用QTest模拟鼠标拖拽回归交互逻辑
手测拖动当然最直接,但问题是每次改padding、改吸附规则、改命中热区,都要重新手动拖几十次,效率很低。更麻烦的是手测难以覆盖边界场景:拖出控件外再松手、快速连点、动画未结束就再次按下,这些情况你很难每次都用同样的操作去复现。所以我给滑动选择器写了一个最小化的QTest用例,把最核心的“按下-移动-释放”流程自动化。
#include <QtTest> #include "slide_selector.h" class SlideSelectorTest : public QObject { Q_OBJECT private slots: void testDragSnap(); }; void SlideSelectorTest::testDragSnap() { SlideSelector selector; selector.resize(320, 60); selector.show(); QVERIFY(QTest::qWaitForWindowExposed(&selector)); selector.setItems({"低", "中", "高"}); QSignalSpy spy(&selector, &SlideSelector::currentIndexChanged); // 从“中”档位置按住,拖到“高”档位置再松手 const QPoint start(selector.itemToPos(1), 30); const QPoint end(selector.itemToPos(2), 30); QTest::mousePress(&selector, Qt::LeftButton, Qt::NoModifier, start); QTest::mouseMove(&selector, end, 20); QTest::mouseRelease(&selector, Qt::LeftButton, Qt::NoModifier, end); QCOMPARE(selector.currentIndex(), 2); QCOMPARE(spy.count(), 1); QCOMPARE(spy.at(0).at(0).toInt(), 2); }这个用例覆盖了几个关键断言:最终索引落在“高”档、信号只发了一次、信号参数是2。如果哪次重构把吸附逻辑改成了“拖动过程中发信号”或“松手后值不对”,这个用例会立刻红灯。QTest::mouseMove的第二个参数是目标位置和延迟,第三个参数是移动间隔的毫秒数,我一般设20毫秒,模拟真实拖动的节奏。
还有一个容易踩的细节:QTest向无窗口控件发事件时,如果控件没有显示,paintEvent不会执行,m_thumbRect就不会被初始化,随后mousePressEvent里的命中判断会把点击当成轨道点击,结果依然正确但意义不同。所以测试里先show(),再用qWaitForWindowExposed确保窗口真正暴露出来,这样事件才能走到完整的真实路径。如果控件在无界面环境下跑测试,就要用QTEST_MAIN加上offscreen平台插件,但本地开发直接show()就够了。
提示:itemToPos在头文件里是private,测试代码访问不到。我通常会把这个函数声明为friend class SlideSelectorTest,或者将控件尺寸边界条件作为纯函数暴露。两种方式都行,但不要用#define private public这种手段,它会污染整个翻译单元,得不偿失。
这套回归测试的价值,在后续调整视觉参数时体现得最明显。你改一个padding值,手测可能只觉得滑块顺眼了一些,但如果步长公式里某处忘改了,点击位置就会偏移。跑一遍QTest,立刻知道吸附逻辑有没有被改坏。我现在的工作习惯是:调整控件视觉参数之前,先跑一次测试确认基线是绿的,再动手改代码,改完再跑一次。有了这套自动化回归,改样式就敢放开手脚。这个习惯帮我挡掉了不少“改完看起来一切正常,交付后用户说选不准”的尴尬,希望帮到你。
本文还有配套的精品资源,点击获取