12_常用控件速查(下):QSpinBox、QSlider、QProgressBar、QCheckBox

常用控件速查(下):QSpinBox、QSlider、QProgressBar、QCheckBox

引言

上一章解决了文本输入、有限选择和命令触发。本章继续补齐 Widgets 表单里最常见的四类状态控件:QSpinBox 处理离散整数,QSlider 在一个范围内快速选择,QProgressBar 把任务进展反馈给用户,QCheckBox 表达开关或三态选择。

它们共同处理的不是字符串,而是范围、状态和进度。最重要的第一步不是记住每个函数名,而是先分清职责:谁让用户改值,谁只负责展示结果,谁表示业务选项。本文仍然面向 Qt 6 Widgets,按"先会用 -> 理解信号槽 -> 追踪源码调用"的顺序展开。读完后应能:

  • 用四个控件完成一个可运行的任务控制小窗口;
  • 正确设置范围、步长、默认值与可选状态;
  • 选择 valueChanged()sliderMoved()toggled() 等恰当的信号;
  • 理解程序设值与用户操作在信号语义上的差异;
  • 沿公开 API 追到 QAbstractSpinBoxQAbstractSliderQAbstractButton 等基类的简化调用路径;
  • 避免把进度条当成任务执行器、把控件状态直接当成业务模型。

图 1:QSpinBoxQSliderQCheckBox 分别复用各自的抽象基类;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(...)

先用一句话建立边界:QSpinBoxQSlider输入控件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("立即执行"));

prefixsuffix 是显示层的辅助文字,读取业务值时仍使用 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 并重写转换函数。例如用 x1x2 表示倍率:

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 适合音量、缩放比例、预览进度、亮度等"用户可以快速扫过一个范围"的值。它通常不显示精确数字,因此若数值必须准确可见,常将滑块与 QLabelQSpinBox 配对。

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::HorizontalQt::Vertical 决定方向;setSingleStep() 控制方向键或滚轮等单次动作的步长;setPageStep() 控制 PageUp/PageDown 等页动作的步长。tickInterval 只影响刻度间隔,不会自动改变实际步进值。

4.2 valuesliderPositiontracking

QAbstractSlider 同时维护"已确认值"与"滑块当前位置"两个概念。普通场景下它们相同:

cpp 复制代码
const int value = volumeSlider->value();
const int position = volumeSlider->sliderPosition();

但在拖动且关闭追踪时,两者可以暂时不同:

cpp 复制代码
volumeSlider->setTracking(false);

默认 trackingtrue,拖动过程中会持续更新值并发出 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

因此业务层仍然应该保证 completedCountfileCount 属于同一个任务快照,进度值本身不应该依赖控件的边界保护来纠错。

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 三态:UncheckedPartiallyCheckedChecked

当一个父级复选框代表一组子项时,部分选中应表达为"中间态":

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() 决定下一次用户操作如何在 UncheckedPartiallyCheckedChecked 间切换,并在状态变化后提供相应的状态信号。业务代码不需要重写鼠标事件来实现常规三态逻辑;只需正确设置 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 为什么拖动滑块时任务执行很多次?

默认 trackingtrue,拖动期间会连续产生值变化。若每次变化都启动昂贵计算,可关闭 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》

相关推荐
OPEN-F1 小时前
C++进阶教程:运算符重载与类型转换
java·c++·算法
uoKent1 小时前
c++中的extern关键字
开发语言·c++
jimy12 小时前
c++隐式移动构造、强制拷贝省略、返回具名局部变量
开发语言·c++
fpcc2 小时前
跟我学C++中级篇—static_assert和assert
开发语言·c++
Zenova EdgeOS3 小时前
C++ 工业边缘 Boost.Asio 高级实战
开发语言·c++·边缘计算·工业网关
小小龙学IT3 小时前
C++ std::chrono 时间库深度解析:从 duration 模板到 C++20 日历与时区
linux·c++·c++20
nike0good4 小时前
CF 126B(Password-z algorithm/exkmp)
开发语言·c++·算法
01_ice5 小时前
C++入门基础
c++