常用控件速查(下):QSpinBox、QSlider、QProgressBar、QCheckBox
引言
上一章解决了文本输入、有限选择和命令触发。本章继续补齐 Widgets 表单里最常见的四类状态控件:QSpinBox 处理离散整数,QSlider 在一个范围内快速选择,QProgressBar 把任务进展反馈给用户,QCheckBox 表达开关或三态选择。
它们共同处理的不是字符串,而是范围、状态和进度。最重要的第一步不是记住每个函数名,而是先分清职责:谁让用户改值,谁只负责展示结果,谁表示业务选项。本文仍然面向 Qt 6 Widgets,按"先会用 -> 理解信号槽 -> 追踪源码调用"的顺序展开。读完后应能:
- 用四个控件完成一个可运行的任务控制小窗口;
- 正确设置范围、步长、默认值与可选状态;
- 选择
valueChanged()、sliderMoved()、toggled()等恰当的信号; - 理解程序设值与用户操作在信号语义上的差异;
- 沿公开 API 追到
QAbstractSpinBox、QAbstractSlider、QAbstractButton等基类的简化调用路径; - 避免把进度条当成任务执行器、把控件状态直接当成业务模型。

图 1:QSpinBox、QSlider、QCheckBox 分别复用各自的抽象基类;QProgressBar 直接继承 QWidget,它只负责把进度状态绘制出来。虚线之外的私有实现不是初学阶段的操作对象。
一、先会用:一个可运行的"任务控制台"
下面的例子把四个控件放进同一个小窗口:用户设置并发数、处理速度和"完成后校验"选项,点击开始后,定时器模拟任务推进,并由进度条显示完成比例。真实项目中,QTimer 应替换为工作线程或异步任务发来的进度信号;本章先把注意力放在控件本身。
1.1 CMake 配置
新建一个 Qt Widgets 项目,CMakeLists.txt 最小内容如下:
cmake
cmake_minimum_required(VERSION 3.21)
project(range_controls_demo LANGUAGES CXX)
find_package(Qt6 REQUIRED COMPONENTS Widgets)
qt_standard_project_setup()
qt_add_executable(range_controls_demo
main.cpp
)
target_link_libraries(range_controls_demo PRIVATE Qt6::Widgets)
set_target_properties(range_controls_demo PROPERTIES
CXX_STANDARD 17
CXX_STANDARD_REQUIRED ON
)
四个控件和 QTimer 都能从已链接的 Qt 模块获得;本例不需要额外模块。
1.2 完整代码
把以下代码保存为 main.cpp 后即可构建运行:
cpp
#include <QApplication>
#include <QCheckBox>
#include <QFormLayout>
#include <QLabel>
#include <QProgressBar>
#include <QPushButton>
#include <QSlider>
#include <QSpinBox>
#include <QTimer>
#include <QVBoxLayout>
#include <QWidget>
class TaskControlWidget : public QWidget
{
Q_OBJECT
public:
explicit TaskControlWidget(QWidget *parent = nullptr)
: QWidget(parent)
{
setWindowTitle(tr("任务控制台"));
resize(460, 310);
auto *titleLabel = new QLabel(tr("批量任务设置"), this);
titleLabel->setStyleSheet("font-size: 20px; font-weight: 600;");
workerSpin = new QSpinBox(this);
workerSpin->setRange(1, 16);
workerSpin->setValue(4);
workerSpin->setSingleStep(1);
workerSpin->setSuffix(tr(" 个"));
workerSpin->setToolTip(tr("同时处理任务的工作线程数量"));
speedSlider = new QSlider(Qt::Horizontal, this);
speedSlider->setRange(10, 100);
speedSlider->setValue(50);
speedSlider->setSingleStep(5);
speedSlider->setPageStep(10);
speedSlider->setTickPosition(QSlider::TicksBelow);
speedSlider->setTickInterval(10);
speedSlider->setToolTip(tr("模拟每次推进的处理速度"));
verifyCheck = new QCheckBox(tr("完成后执行完整性校验"), this);
verifyCheck->setChecked(true);
progressBar = new QProgressBar(this);
progressBar->setRange(0, 100);
progressBar->setValue(0);
progressBar->setFormat(tr("%v / %m(%p%)"));
startButton = new QPushButton(tr("开始任务"), this);
summaryLabel = new QLabel(this);
summaryLabel->setWordWrap(true);
summaryLabel->setStyleSheet("color: #526a7d;");
auto *formLayout = new QFormLayout;
formLayout->addRow(tr("并发数:"), workerSpin);
formLayout->addRow(tr("处理速度:"), speedSlider);
formLayout->addRow(QString(), verifyCheck);
formLayout->addRow(tr("当前进度:"), progressBar);
auto *rootLayout = new QVBoxLayout(this);
rootLayout->addWidget(titleLabel);
rootLayout->addLayout(formLayout);
rootLayout->addWidget(startButton);
rootLayout->addWidget(summaryLabel);
timer = new QTimer(this);
timer->setInterval(120);
// 配置可以由用户改动,也可能由程序读取配置后回填,因此监听 valueChanged。
connect(workerSpin, &QSpinBox::valueChanged,
this, &TaskControlWidget::updateSummary);
connect(speedSlider, &QSlider::valueChanged,
this, &TaskControlWidget::updateSummary);
connect(verifyCheck, &QCheckBox::toggled,
this, &TaskControlWidget::updateSummary);
connect(startButton, &QPushButton::clicked,
this, &TaskControlWidget::startTask);
connect(timer, &QTimer::timeout,
this, &TaskControlWidget::advanceTask);
updateSummary();
}
private slots:
void updateSummary()
{
summaryLabel->setText(
tr("并发数:%1;处理速度:%2%%;完成后校验:%3")
.arg(workerSpin->value())
.arg(speedSlider->value())
.arg(verifyCheck->isChecked() ? tr("开启") : tr("关闭")));
}
void startTask()
{
progressBar->setValue(0);
workerSpin->setEnabled(false);
speedSlider->setEnabled(false);
verifyCheck->setEnabled(false);
startButton->setEnabled(false);
timer->start();
}
void advanceTask()
{
const int increment = qMax(1, speedSlider->value() / 10);
const int nextValue = qMin(100, progressBar->value() + increment);
progressBar->setValue(nextValue);
if (nextValue == 100) {
timer->stop();
workerSpin->setEnabled(true);
speedSlider->setEnabled(true);
verifyCheck->setEnabled(true);
startButton->setEnabled(true);
summaryLabel->setText(verifyCheck->isChecked()
? tr("任务完成,正在执行完整性校验。")
: tr("任务完成,已跳过完整性校验。"));
}
}
private:
QSpinBox *workerSpin = nullptr;
QSlider *speedSlider = nullptr;
QCheckBox *verifyCheck = nullptr;
QProgressBar *progressBar = nullptr;
QPushButton *startButton = nullptr;
QLabel *summaryLabel = nullptr;
QTimer *timer = nullptr;
};
int main(int argc, char *argv[])
{
QApplication app(argc, argv);
TaskControlWidget window;
window.show();
return app.exec();
}
#include "main.moc"
所有控件和 QTimer 都以窗口为父对象,会随窗口析构而释放。示例在任务运行期间禁用配置控件,这是比"任务开始后仍允许改参数、但实际不生效"更清楚的交互。这里的 QTimer 只为演示而存在,不能用来执行耗时工作;真实任务应在后续多线程章节中放到工作线程。
如果把类拆到
.h/.cpp文件并让 CMake 的 AUTOMOC 自动运行,单文件末尾的#include "main.moc"就不需要保留。它仅用于本章便于复制的单文件示例。
二、四个控件各自负责什么
| 控件 | 主要职责 | 最常见的读取 API | 典型信号 |
|---|---|---|---|
QSpinBox |
输入有限范围内的整数 | value() |
valueChanged(int)、editingFinished() |
QSlider |
在范围内快速选值 | value()、sliderPosition() |
valueChanged(int)、sliderMoved(int) |
QProgressBar |
展示任务当前进度 | value()、minimum()、maximum() |
valueChanged(int) |
QCheckBox |
表达开关,或三态选择 | isChecked()、checkState() |
toggled(bool)、checkStateChanged(...) |
先用一句话建立边界:QSpinBox 和 QSlider 是输入控件 ,QCheckBox 是选项控件 ,QProgressBar 是输出控件。进度条不启动任务,也不计算完成比例;它只把外部传入的数值可视化。
三、QSpinBox:受范围约束的整数输入
QSpinBox 适合端口号、重试次数、数量、天数、页码等整数。与让用户在 QLineEdit 里自由输入文本相比,它把范围和步长直接放到控件层,能少处理很多无效输入。
3.1 最小使用:范围、默认值与步长
cpp
auto *retrySpin = new QSpinBox(this);
retrySpin->setRange(0, 10);
retrySpin->setValue(3);
retrySpin->setSingleStep(1);
const int retryCount = retrySpin->value();
setRange(minimum, maximum) 应优先于分别调用 setMinimum() 和 setMaximum(),因为它一次说明完整约束。若给出的最大值小于最小值,Qt 会调整范围;不过业务代码不应依赖这种"兜底修正",范围仍应由业务规则明确给出。
setValue() 会把值限制到当前范围内。也就是说,当范围是 0..10 时,setValue(20) 后实际值是 10。这是控件的防线,不是后端规则的替代品。
3.2 前缀、后缀、进制与特殊值
cpp
workerSpin->setPrefix(tr("最多 "));
workerSpin->setSuffix(tr(" 个任务"));
auto *hexSpin = new QSpinBox(this);
hexSpin->setRange(0, 255);
hexSpin->setDisplayIntegerBase(16);
hexSpin->setPrefix("0x");
auto *delaySpin = new QSpinBox(this);
delaySpin->setRange(0, 60);
delaySpin->setSpecialValueText(tr("立即执行"));
prefix 和 suffix 是显示层的辅助文字,读取业务值时仍使用 value()。不要从 text() 反向解析"最多 4 个任务",因为文本可能被翻译、被格式化,也可能受进制和特殊值影响。
setSpecialValueText() 只在值等于最小值时显示特殊文字。它很适合"0 表示不限""0 秒表示立即执行"这类有明确业务含义的最小值;若 0 同时是一个普通值,就不应滥用该 API。
3.3 valueChanged() 与 editingFinished() 怎么选
| 信号 | 何时发出 | 适合做什么 |
|---|---|---|
valueChanged(int) |
数值真的改变,无论来自步进、键盘输入还是 setValue() |
同步配置、联动更新 |
textChanged(const QString &) |
显示文本改变 | 很少需要;不要拿它承载业务数值 |
editingFinished() |
编辑结束,例如失焦或 Enter 后完成解释 | 延后提交、一次性校验 |
cpp
connect(retrySpin, &QSpinBox::valueChanged,
this, &SettingsWidget::updatePreview);
connect(retrySpin, &QSpinBox::editingFinished, this,
[this] {
saveRetryCount(retrySpin->value());
});
当预览要跟随每次值变化时,使用 valueChanged(int);不希望每次按箭头都写一次配置文件时,使用 editingFinished()。这与上一章 QLineEdit 中"实时变化"与"编辑提交"的区分一致。
valueChanged(int) 传递的是已经过范围限制的整数;显示文本变化则由独立的 textChanged(const QString &) 表示。业务层通常连接前者,不要从带前缀、后缀的显示文本反向解析数值。
QSpinBox 还有一个容易被忽略的属性:keyboardTracking`。**
默认值为 true。用户直接键盘输入数字时,只要当前输入能够形成新的有效值,valueChanged() 就可能连续触发。
如果希望用户输入完成后再提交,可以关闭:
rust
retrySpin->setKeyboardTracking(false);
此时输入过程中的中间状态不会立即通过 valueChanged() 同步出去,按下 Enter、失去焦点等完成编辑后才会提交。
因此,对于 QSpinBox,更准确的理解是:
valueChanged() 描述"值发生了变化",keyboardTracking 决定键盘编辑过程中是否实时提交这些变化;editingFinished() 描述"一次编辑行为结束"。
3.4 自定义显示与解析:先用公开虚函数
当显示文本和整数规则不一致时,可以派生 QSpinBox 并重写转换函数。例如用 x1、x2 表示倍率:
cpp
class MultiplierSpinBox : public QSpinBox
{
protected:
QString textFromValue(int value) const override
{
return tr("x%1").arg(value);
}
int valueFromText(const QString &text) const override
{
QString number = text;
number.remove('x', Qt::CaseInsensitive);
return number.toInt();
}
};
不要试图通过重写内部 QLineEdit 的按键事件来"拦截"数值。QAbstractSpinBox 已经处理了校验、编辑解释、步进、撤销和输入法等细节;优先使用 textFromValue()、valueFromText()、fixup()、validate() 等公开扩展点。小数输入应使用 QDoubleSpinBox,不要用整数控件再手工除以 100。
四、QSlider:范围选择与滑块位置
QSlider 适合音量、缩放比例、预览进度、亮度等"用户可以快速扫过一个范围"的值。它通常不显示精确数字,因此若数值必须准确可见,常将滑块与 QLabel 或 QSpinBox 配对。
4.1 最小使用:方向、范围和刻度
cpp
auto *volumeSlider = new QSlider(Qt::Horizontal, this);
volumeSlider->setRange(0, 100);
volumeSlider->setValue(60);
volumeSlider->setSingleStep(1);
volumeSlider->setPageStep(10);
volumeSlider->setTickPosition(QSlider::TicksBelow);
volumeSlider->setTickInterval(10);
Qt::Horizontal 与 Qt::Vertical 决定方向;setSingleStep() 控制方向键或滚轮等单次动作的步长;setPageStep() 控制 PageUp/PageDown 等页动作的步长。tickInterval 只影响刻度间隔,不会自动改变实际步进值。
4.2 value、sliderPosition 与 tracking
QAbstractSlider 同时维护"已确认值"与"滑块当前位置"两个概念。普通场景下它们相同:
cpp
const int value = volumeSlider->value();
const int position = volumeSlider->sliderPosition();
但在拖动且关闭追踪时,两者可以暂时不同:
cpp
volumeSlider->setTracking(false);
默认 tracking 为 true,拖动过程中会持续更新值并发出 valueChanged(int)。关闭后,拖动时仍可通过 sliderMoved(int) 获得当前位置,但值通常在释放后才提交。这适合拖动一次就会触发昂贵计算的场景,例如跳转大型视频、更新远端设备参数。
4.3 高频信号的语义
| 信号 | 是否只由用户操作触发 | 适合做什么 |
|---|---|---|
valueChanged(int) |
否,setValue() 也会触发 |
界面联动、同步模型 |
sliderMoved(int) |
是,用户拖动滑块时触发 | 轻量预览、显示拖动位置 |
sliderPressed() / sliderReleased() |
是 | 开始/结束拖动时准备或提交 |
actionTriggered(int) |
否,反映单步、页步、滑块等动作 | 需要区分动作来源的高级逻辑 |
cpp
connect(volumeSlider, &QSlider::valueChanged, this,
[this](int value) {
volumeLabel->setText(tr("音量:%1%").arg(value));
audioEngine->setVolume(value);
});
connect(volumeSlider, &QSlider::sliderReleased, this,
[this] {
saveVolume(volumeSlider->value());
});
若滑块从配置文件回填时也要更新音频引擎,监听 valueChanged() 是正确的;若只想统计人工拖动,监听 sliderMoved() 或 sliderReleased()。不要用鼠标事件重写来猜测用户意图,控件已经把常用交互语义做成信号。
4.4 滑块不是进度条
两者外观相似,角色却相反:滑块让用户选择值,进度条把程序的结果展示给用户。不要用可拖动的 QSlider 伪装下载进度;也不要期待 QProgressBar 可被用户拿来选择百分比。一个可拖动的播放时间轴可以是滑块,一个仅供观察的编码进度应是进度条。
五、QProgressBar:把任务状态反馈给用户
QProgressBar 的核心模型是最小值、最大值和当前值。约定它们在一个线程安全的业务层计算好后,再把结果发送到 GUI 线程更新控件。
5.1 确定进度:已知总量时
cpp
progressBar->setRange(0, fileCount);
progressBar->setValue(completedCount);
progressBar->setFormat(tr("已完成 %v / %m(%p%)"));
格式字符串中的 %v、%m、%p 分别代表当前值、最大值和百分比。不要自己用整数除法拼百分比再写入标签,除非显示规则确实比 format 更复杂。
QProgressBar::setValue() 不会接受超出当前范围的值。比如范围为 0..100 时调用 setValue(120),当前值不会被设置为 120。
因此业务层仍然应该保证 completedCount 与 fileCount 属于同一个任务快照,进度值本身不应该依赖控件的边界保护来纠错。
5.2 忙碌状态:不知道总量时
cpp
progressBar->setRange(0, 0); // busy indicator
// 已拿到总量后,切回确定进度。
progressBar->setRange(0, totalBytes);
progressBar->setValue(doneBytes);
当最小值和最大值都为 0 时,进度条进入忙碌状态,表示"正在工作,但暂时无法估计总量"。这比永远停在 0% 更诚实。拿到总量后要立即恢复正常范围;若任务失败或取消,也应让界面切换到明确的错误或取消状态,而不是把忙碌动画留在那里。
5.3 文本、方向与重置
cpp
progressBar->setTextVisible(true);
progressBar->setInvertedAppearance(false);
progressBar->reset();
setTextVisible(false) 只隐藏文字,不改变进度数值。setInvertedAppearance(true) 用于从右向左或从下向上的特殊视觉语义,不应用来表达业务失败。任务重新开始前可调用 reset(),或显式设定新任务的范围和值;后一种方式在代码审阅时通常更直观。
5.4 进度更新来自工作线程时
GUI 控件只能在其所属的 GUI 线程中操作。正确的分层方向是:工作对象计算进度,发射普通数据,界面对象接收后调用 setValue()。
cpp
connect(worker, &DownloadWorker::progressChanged,
progressBar, &QProgressBar::setValue);
当 worker 与进度条属于不同线程时,默认的 Qt::AutoConnection 会在需要时使用队列投递,让 setValue() 在 GUI 线程执行。不要在工作线程里直接持有并调用 progressBar;完整的线程亲和性和队列连接会在第三阶段展开。
六、QCheckBox:布尔选项与三态选择
QCheckBox 继承 QAbstractButton。最常见的使用是一个业务开关,例如"记住登录状态""启用自动保存""完成后校验"。
6.1 最小使用:读取和设置选中状态
cpp
auto *autoSaveCheck = new QCheckBox(tr("启用自动保存"), this);
autoSaveCheck->setChecked(true);
const bool enabled = autoSaveCheck->isChecked();
connect(autoSaveCheck, &QCheckBox::toggled, this,
[this](bool enabled) {
settings.autoSaveEnabled = enabled;
});
setChecked() 和 isChecked() 是二元场景的首选 API。和可选中的 QPushButton 一样,toggled(bool) 在用户点击或程序调用 setChecked() 导致状态改变时都会发出;这正适合让界面和业务模型保持同步。
6.2 三态:Unchecked、PartiallyChecked、Checked
当一个父级复选框代表一组子项时,部分选中应表达为"中间态":
cpp
selectAllCheck->setTristate(true);
selectAllCheck->setCheckState(Qt::PartiallyChecked);
const Qt::CheckState state = selectAllCheck->checkState();
| 状态 | 语义 |
|---|---|
Qt::Unchecked |
没有子项选中,或开关关闭 |
Qt::PartiallyChecked |
只有部分子项选中 |
Qt::Checked |
所有子项选中,或开关开启 |
三态不是"多了一个更酷的样式"。它必须由一组子项的聚合结果驱动。单独的"是否启用"选项应继续使用二态复选框,避免让用户不理解中间状态代表什么。
6.3 toggled()、stateChanged() 与 checkStateChanged()
toggled(bool) 只表达布尔状态,兼容性最好,二态和三态场景都能用。当业务需要区分 PartiallyChecked 时,应读取 checkState(),或在 Qt 6.7 及以上连接类型安全的 checkStateChanged(Qt::CheckState):
cpp
connect(selectAllCheck, &QCheckBox::checkStateChanged, this,
[this](Qt::CheckState state) {
updateChildren(state == Qt::Checked);
});
如果项目最低版本是 Qt 6.7,三态场景优先使用:
scss
checkStateChanged(Qt::CheckState)
它相比旧的:
scss
stateChanged(int)
是类型安全的枚举信号。
需要兼容 Qt 6.6 及更早版本时,才考虑继续使用 stateChanged(int)。
注意:从 Qt 6.9 开始,stateChanged(int) 已被标记为 deprecated,新代码不建议继续使用。
6.4 父子复选框联动:避免信号循环
下面是一个简化方向。关键是"父控件写子控件"和"子控件回算父控件"都可能发出信号,因此需要在批量回填时缩小屏蔽范围:
cpp
void SettingsWidget::setAllChildrenChecked(bool checked)
{
QSignalBlocker blocker(selectAllCheck);
for (QCheckBox *child : optionChecks) {
child->setChecked(checked);
}
}
void SettingsWidget::updateSelectAllState()
{
const int checkedCount = std::count_if(optionChecks.cbegin(), optionChecks.cend(),
[](QCheckBox *check) { return check->isChecked(); });
QSignalBlocker blocker(selectAllCheck);
selectAllCheck->setCheckState(checkedCount == 0
? Qt::Unchecked
: checkedCount == optionChecks.size() ? Qt::Checked
: Qt::PartiallyChecked);
}
需要 #include <QSignalBlocker> 和 #include <algorithm>。这里屏蔽的是父复选框自身发出的回流信号,不是整个窗口。更根本的原则仍然是:槽函数应能重复执行且不依赖"这次一定是用户操作"。
七、信号槽:把四类状态连成一个交互闭环
第十一章已经介绍了成员槽、Lambda 与重载信号三种写法。本章把它们放回状态控件的实际语境中。
7.1 成员函数槽:配置变化进入统一处理函数
cpp
connect(workerSpin, &QSpinBox::valueChanged,
this, &TaskControlWidget::updateSummary);
connect(speedSlider, &QSlider::valueChanged,
this, &TaskControlWidget::updateSummary);
connect(verifyCheck, &QCheckBox::toggled,
this, &TaskControlWidget::updateSummary);
当多项配置共同决定一个摘要或可用状态时,让它们汇聚到一个成员槽,能避免在三个 Lambda 中复制同一段逻辑。updateSummary() 每次从控件读取完整状态,因此无需猜测"这次是哪一个控件变了"。
7.2 Lambda:把显示更新留在控件旁边
cpp
connect(speedSlider, &QSlider::sliderMoved, this,
[this](int position) {
previewLabel->setText(tr("准备设置为 %1%%").arg(position));
});
这种短小、只影响局部显示的连接适合 Lambda。捕获 this 时,接收者参数也必须传 this,这样窗口析构时连接会自动断开。不要把复杂业务流程塞进很长的 Lambda;复杂逻辑应提取为成员函数。
7.3 QSpinBox::valueChanged(int):直接连接数值信号
cpp
connect(retrySpin, &QSpinBox::valueChanged,
this, &SettingsWidget::setRetryCount);
QSpinBox::valueChanged(int) 没有重载,因此可直接取成员函数指针。它与 QSpinBox::textChanged(const QString &) 分工明确:前者是用于业务同步的数值,后者是用于显示层观察的文本。不要依赖带前缀、后缀的显示文本。
7.4 单向数据流比互相 setValue() 更稳
需要同时使用滑块和数值框时,很容易写出双向连接:
cpp
connect(slider, &QSlider::valueChanged, spinBox, &QSpinBox::setValue);
connect(spinBox, &QSpinBox::valueChanged, slider, &QSlider::setValue);
Qt 在"新值与旧值相同"时不会重复发出变化信号,因此这个简单例子通常不会无限递归。但当任意一端存在缩放、取整、范围修正、写配置或网络同步时,循环会重新出现。更稳的做法是让两者都写入一个业务值,再由统一的 applySettings() 回填界面;程序回填的一小段时间可用 QSignalBlocker 防止不必要的界面联动。

图 2:用户事件先由控件处理,再更新内部状态、发射信号,随后由元对象系统分发给槽函数。进度条收到新值后请求重绘,真正的绘制仍由事件循环安排。
八、从 API 到源码:四个控件如何响应操作
不同 Qt 6 小版本的私有类名和具体行号可能变化,下面是稳定公开语义对应的简化调用链。读源码时应先从公开类与受保护虚函数入手,再逐步进入私有实现。
8.1 公共主线:状态改变后才有信号
无论是步进框、滑块还是复选框,核心顺序都类似:
text
QApplication 交付鼠标 / 键盘事件
-> 控件或抽象基类处理事件
-> 尝试改变内部状态
-> 新旧状态不同,发射对应信号
-> MOC 生成的信号函数进入 QMetaObject::activate()
-> 找到连接,直接调用槽,或向接收者线程投递调用
这解释了两件常见现象:重复调用 setValue(50) 通常不会反复触发 valueChanged(50);而改变范围可能把当前值夹到新范围内,从而导致一次真实的值变化。emit 只是帮助阅读的宏,真正完成分发的是 MOC 生成代码与 QMetaObject::activate()。
8.2 QSpinBox:文本要先被解释成整数
QSpinBox 的鼠标箭头、键盘上下键、编辑框文本都汇入 QAbstractSpinBox 的编辑与步进逻辑。简化路径如下:
text
用户点击上箭头 / 按 Up
-> QAbstractSpinBox 处理事件
-> stepBy(+1)
-> QSpinBox 按范围、wrapping、singleStep 计算新整数
-> setValue(newValue)
-> 更新内部 QLineEdit 的显示文本
-> valueChanged(int) / textChanged(QString)
-> QMetaObject::activate()
-> 槽函数
用户直接输入文本
-> 内部 QLineEdit 接收键盘事件
-> validate() 判断 Invalid / Intermediate / Acceptable
-> 编辑结束时 interpretText()
-> valueFromText() 转成整数
-> setValue(...)
因此 textFromValue() 和 valueFromText() 是正确的定制点:前者把整数显示为文本,后者把编辑文本解析为整数。自定义解析时要保证两者在合理输入下能够互相对应,并始终尊重 minimum() 与 maximum()。
8.3 QSlider:像素位置需要映射为范围值
滑块不能直接把鼠标的 x 坐标当作数值,因为控件宽度、滑块把手尺寸、布局方向和样式都会影响映射。其简化路径是:
text
QSlider::mousePressEvent() / mouseMoveEvent()
-> 根据 QStyleOptionSlider 获取滑槽与把手几何信息
-> QStyle::sliderValueFromPosition()
-> setSliderPosition(position)
-> 若 tracking 为 true,setValue(position)
-> valueChanged(position)
鼠标释放
-> 若 tracking 为 false,将 sliderPosition 提交给 value
-> sliderReleased() / valueChanged(...)
QStyle 参与映射是为了让不同平台主题的滑块仍具有正确手感。不要自行用 event->position().x() / width() 计算范围值,否则很容易在高 DPI、反向布局、纵向滑块和不同样式下出现偏差。
8.4 QProgressBar:setValue() 请求重绘,而不是立即完成绘制
cpp
progressBar->setValue(doneBytes);
概念上会发生:
text
QProgressBar::setValue()
-> 保存当前进度值
-> 必要时发射 valueChanged(value)
-> update() 请求重绘
-> 事件循环合并 Paint 事件
-> QProgressBar::paintEvent()
-> QStyle 绘制槽、已完成区域和文本
这条路径说明"调用 setValue()"与"屏幕已经画出新进度"不是同一个时刻。如果 GUI 线程紧接着执行耗时循环,绘制事件不能被处理,进度条看起来仍然不动。不要靠频繁 repaint() 或在循环里 processEvents() 掩盖结构问题;应让耗时任务离开 GUI 线程,并以信号回报进度。
8.5 QCheckBox:二态切换建立在 QAbstractButton 上
普通二态复选框的点击大部分由 QAbstractButton 实现:
text
QAbstractButton::mousePressEvent()
-> 进入 down 状态,发射 pressed()
-> mouseReleaseEvent() 确认命中复选框
-> nextCheckState()
-> 更新 checked / checkState
-> toggled(bool)
-> clicked(bool)
-> QMetaObject::activate()
QCheckBox 在此基础上处理三态:nextCheckState() 决定下一次用户操作如何在 Unchecked、PartiallyChecked、Checked 间切换,并在状态变化后提供相应的状态信号。业务代码不需要重写鼠标事件来实现常规三态逻辑;只需正确设置 tristate 并集中维护父子项关系。
九、把控件状态转换为业务数据
以下写法在小程序里很常见:
cpp
if (speedSlider->value() > 80) {
// 直接启动"高速模式"
}
问题不在于这段代码一定错误,而在于数值含义、界面范围和任务参数混在一起。应在提交边界把控件状态转换为明确的业务对象:
cpp
struct TaskOptions {
int workerCount;
int speedPercent;
bool verifyAfterFinish;
};
TaskOptions TaskControlWidget::options() const
{
return {
workerSpin->value(),
speedSlider->value(),
verifyCheck->isChecked()
};
}
QProgressBar 反过来只接受任务的输出:
cpp
connect(taskRunner, &TaskRunner::progressChanged,
progressBar, &QProgressBar::setValue);
这种分界让界面可以替换、配置可单独测试,任务层也不必依赖任何 QWidget 指针。QSpinBox 的后缀、滑块刻度、复选框文字都属于显示细节,不应流进业务模型。
十、不要这样做
这是一个特别容易出现的信号槽内部操作,一次值变化却触发了大量的业务处理:
cpp
connect(slider, &QSlider::valueChanged,
this, [this](int value) {
saveConfigToDisk(value);
restartDevice();
reloadPreview();
sendToNetwork(value);
});
valueChanged() 可能因为程序回填、范围调整、初始化等原因触发。
如果业务操作非常昂贵,就不应该直接把"值变化"当成"用户确认"。
可以拆成:
markdown
UI状态变化
↓
轻量预览
↓
用户释放 / 编辑完成
↓
提交业务操作
这也是为什么 sliderMoved()、sliderReleased()、editingFinished() 等交互信号值得区分。
十一、常见问题与排查顺序
10.1 为什么 QSpinBox 的值总是被改掉?
先查看 minimum()、maximum()、singleStep() 和 wrapping()。最常见原因是程序写入的值超出范围,被自动限制到边界。再检查是否在别的槽函数中回填配置,造成后一次 setValue() 覆盖前一次。
10.2 为什么拖动滑块时任务执行很多次?
默认 tracking 为 true,拖动期间会连续产生值变化。若每次变化都启动昂贵计算,可关闭 setTracking(false),或仅在 sliderReleased() 时提交;拖动期间只做轻量预览。
10.3 为什么进度条一直停在 0%?
检查任务是否真的报告了进度,setRange() 是否给出合理上限,当前值是否落在范围内。若代码在 GUI 线程执行耗时循环,即使每次都调用 setValue(),绘制也可能没有机会发生。把任务移出 GUI 线程才是根本方案。
10.4 为什么复选框只显示两种状态?
PartiallyChecked 需要先调用 setTristate(true) 才会作为用户可循环的状态使用。还要确认父复选框是否真的依据子项数量回算状态;单独调用一次 setCheckState(Qt::PartiallyChecked) 不会自动建立父子关系。
10.5 为什么槽函数没有收到 QSpinBox::valueChanged?
它有重载。新式连接必须用 &QSpinBox::valueChanged 或明确的类型转换选择 int / QString 版本。优先接收整数版本,显示文本中的前缀、后缀不适合进入业务代码。
10.6 为什么 setChecked() 或 setValue() 也触发了联动?
这是预期行为:它们会在状态实际变化时发出相应的变化信号。若只想响应用户拖动,可用 sliderMoved();若只想在编辑完成后保存,使用 editingFinished();若是在批量回填 UI,可在最小作用域使用 QSignalBlocker。
十二、四个控件常用 API 速查表
| 控件 | API | 用途 |
|---|---|---|
QSpinBox |
setRange() |
设置最小值和最大值 |
setValue() / value() |
设置 / 读取整数值 | |
setSingleStep() |
设置单次步进值 | |
setPrefix() / setSuffix() |
设置显示前后缀 | |
setSpecialValueText() |
为最小值设置特殊显示文本 | |
setWrapping() |
到边界后循环 | |
setDisplayIntegerBase() |
设置整数显示进制 | |
QSlider |
setOrientation() |
设置水平或垂直方向 |
setRange() |
设置范围 | |
setValue() / value() |
设置 / 读取当前值 | |
setSliderPosition() |
设置滑块当前位置 | |
setTracking() |
控制拖动中是否提交值 | |
setTickPosition() / setTickInterval() |
设置刻度显示 | |
QProgressBar |
setRange() |
设置进度范围;0, 0 为忙碌状态 |
setValue() / value() |
更新 / 读取进度值 | |
setFormat() |
设置 %v、%m、%p 格式文本 |
|
setTextVisible() |
显示 / 隐藏进度文本 | |
reset() |
重置进度条 | |
QCheckBox |
setChecked() / isChecked() |
设置 / 读取二态选中状态 |
setCheckState() / checkState() |
设置 / 读取三态状态 | |
setTristate() |
启用三态交互 | |
toggled(bool) |
监听布尔状态变化 | |
checkStateChanged(...) |
Qt 6.7+ 监听枚举状态变化 |
总结
四个控件覆盖了桌面表单中另一半高频交互:QSpinBox 把整数限制在可信范围,QSlider 让用户快速挑选范围值,QCheckBox 表示布尔或聚合状态,QProgressBar 将异步任务的结果可视化。入门时先掌握范围、步长、状态和信号语义,再考虑外观与定制。
从源码视角看,它们并不是"控件自己直接调用槽函数":用户事件先进入抽象基类或控件的事件处理,状态实际改变后才由 MOC 生成的信号函数交给 QMetaObject::activate() 分发;进度条更新则进一步交给事件循环和样式系统绘制。理解这条主线后,面对更多 Widgets 时就能先判断控件的状态模型,再选择正确的 API 与信号。
下一章将进入列表、表格、树形等高级控件,数据不再只是一个整数或布尔值,而会开始以条目和层级组织。
下一篇预告:《13_高级控件深度详解:QTableWidget、QTreeWidget、QListWidget》