首页 / 资讯中心 / 文章详情

Qt纯QWidget实现iOS风格滚动时间选择器

Qt纯QWidget实现iOS风格滚动时间选择器 ★ FEATURED ARTICLE
简介这是一份基于Qt框架实现的滚动数字选择器类似手机闹钟时间设置控件源码资源面向Qt初学者与GUI开发进阶者解决UI中高交互性数值选择组件的自定义开发难题。资源包共31个文件含7个核心cpp/h源码文件实现滑动事件响应、数字渐变渲染与中心聚焦逻辑、3个ui界面文件定义控件布局、1个pro工程配置及可直接运行的exe可执行文件辅以makefile、debug/release构建目录等完整覆盖Qt项目开发全流程压缩包大小为1.89MB。已有502人学习下载。读者可直接编译运行查看滑动选择效果深入理解Qt中QPainter图形绘制、QPropertyAnimation动画控制、触摸事件坐标映射与滚动惯性算法等关键技术并复用其模块化结构快速集成到自有项目中。1. 一个被低估的 Qt 时间选择器不用 QTimeEdit也能做出 iOS 风格滚动数字选择器你有没有试过在 Qt 桌面端项目里嵌入一个「看起来像手机系统时间选择器」的控件不是那种带上下箭头的 QSpinBox也不是弹窗式的 QTimeEdit——而是手指或鼠标一划数字轮盘就顺滑滚动、松手自动吸附到最近整点、带惯性回弹和遮罩渐变的滚动数字选择器。alarmText4.zip 就是这样一个被埋没的实战型 Qt 小模块它不依赖 QML纯 QWidget 实现核心仅 3 个类AlarmTextWidget、NumberRoller、RollerItem却完整复刻了 iOS / Android 原生时间选择器的交互手感。它专为嵌入式 HMI、工业控制面板、医疗设备 UI 这类对响应延迟敏感、禁止弹窗干扰操作流的场景设计。如果你正在用 Qt 5.15 开发需要高频设置小时/分钟/秒的界面比如定时报警阈值、倒计时启动、周期采样配置这个包能省掉你至少两天从零写滚动动画 吸附逻辑 键盘焦点管理的血泪调试时间。它不是玩具 Demo而是某高校实验室在真实温控设备上跑了一年多的稳定组件。2. 架构拆解三层结构如何撑起“滚动即响应”的交互体验alarmText4 的轻量级不等于简陋。它的三层结构Widget 层 → Roller 层 → Item 层把视觉、状态、动画完全解耦这是它能兼顾流畅性和可定制性的根本原因。下面逐层展开重点讲清楚每层的职责边界和协作契约——这比直接贴代码更能帮你判断是否值得引入你的项目。2.1 AlarmTextWidget对外暴露的唯一入口负责组合与事件分发这是你实际拖进 UI 或 new 出来的类。它不处理任何滚动逻辑只做三件事布局容器横向排列多个NumberRoller默认 3 个对应时/分/秒统一事件代理将鼠标 press/move/release 事件按坐标分发给当前被触达的NumberRoller值同步出口提供getTime()返回QTime并发射timeChanged(const QTime)信号。关键设计点在于它把NumberRoller当作黑匣子自己不持有任何数字状态。所有数值变更都通过NumberRoller::valueChanged(int)信号向上冒泡再由AlarmTextWidget汇总成QTime。这种信号链路让每个滚轮可独立替换比如把分钟滚轮换成带 AM/PM 的双模式滚轮而 Widget 层完全无感。// alarmtextwidget.h 关键接口声明 class AlarmTextWidget : public QWidget { Q_OBJECT public: explicit AlarmTextWidget(QWidget *parent nullptr); // 获取当前选中时间毫秒级精度但只返回整秒 QTime getTime() const; // 设置初始时间会触发内部滚轮重置 void setTime(const QTime t); signals: void timeChanged(const QTime t); // 注意只在用户操作完成且吸附后触发 private slots: void onRollerValueChanged(int value); // 所有滚轮共用此槽通过 sender() 区分来源 };提示onRollerValueChanged槽函数里没有 if-else 判断哪个滚轮——它靠QObject::sender()动态识别信号来源这是 Qt 信号机制的正确用法避免硬编码索引导致后续扩展困难。2.2 NumberRoller滚动逻辑中枢实现“拖拽-惯性-吸附”三段式动画这才是真正的核心。NumberRoller继承自QFrame但它不画任何东西只管理滚动状态机。它把一次用户操作拆成三个阶段Drag Phase拖拽记录鼠标 Y 偏移量实时计算当前虚拟位置float 类型支持亚像素精度Fling Phase惯性松手瞬间根据速度向量启动 QTimer模拟物理减速Snap Phase吸附惯性停止后计算最近整数位置用QPropertyAnimation平滑过渡到该值。它暴露的关键属性是currentValueint和rangeQPairint,int但内部用m_virtualPosqreal做高精度计算。这种分离让吸附精度远超整数步进——比如你拖动 0.3 个单位它仍能准确判断该吸到 0 还是 1。// numberrroller.cpp 片段吸附逻辑的核心判断 void NumberRoller::snapToNearest() { const qreal snapThreshold 0.25; // 距离整数点小于 0.25 单位才吸附 const int nearest qRound(m_virtualPos); const qreal diff qAbs(m_virtualPos - nearest); if (diff snapThreshold) { // 启动动画从 m_virtualPos 平滑到 nearest QPropertyAnimation *anim new QPropertyAnimation(this, virtualPos); anim-setDuration(200); anim-setStartValue(m_virtualPos); anim-setEndValue(nearest); anim-start(QAbstractAnimation::DeleteWhenStopped); connect(anim, QPropertyAnimation::finished, this, [this, nearest]() { m_virtualPos nearest; m_currentValue qBound(m_range.first, static_castint(m_virtualPos), m_range.second); emit valueChanged(m_currentValue); }); } else { // 不满足吸附条件回弹到最近边界防止卡在半空中 const int boundary (m_virtualPos m_range.first) ? m_range.first : m_range.second; animateTo(boundary); } }参数说明snapThreshold0.25是经验值调小会让吸附更“粘”调大会显得“飘”。我一般在工业屏上设为 0.3在触摸屏上设为 0.2——因为手指触控精度低于鼠标。2.3 RollerItem单个数字的视觉载体负责绘制与缓存RollerItem是QLabel的子类但它重写了paintEvent用QPainter手绘数字而非 setText。为什么因为要支持数字垂直居中对齐考虑不同字体高度差异当前项高亮加粗 蓝色边框上下项半透明模拟景深效果缓存QPixmap避免频繁重绘每个数字只生成一次 pixmap。它不关心滚动逻辑只接收setValue(int)和setActive(bool)两个指令。NumberRoller在滚动过程中会批量调用RollerItem::setValue()更新所有可见项的数字并调用setActive(true/false)标记中心项。// rolleritem.cpp 绘制逻辑节选 void RollerItem::paintEvent(QPaintEvent *e) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing); p.setRenderHint(QPainter::TextAntialiasing); // 背景仅中心项有浅蓝底色其他项透明 if (m_isActive) { p.fillRect(rect(), QColor(230, 240, 255)); p.setPen(QColor(60, 120, 255)); p.drawRect(rect().adjusted(0, 0, -1, -1)); // 蓝色边框 } // 文字垂直居中字体大小自适应高度 QFont f font(); f.setPointSize(height() * 0.7); // 字号占控件高度 70% p.setFont(f); QRect textRect rect().adjusted(0, height()*0.1, 0, -height()*0.1); // 留出上下间距 p.drawText(textRect, Qt::AlignCenter, QString::number(m_value)); }注意height() * 0.7这种动态字号计算是为了适配不同 DPI 屏幕。如果你的项目固定用 1080p 屏可以改成f.setPixelSize(48)更稳定。3. 编译与运行Qt 5.15 环境下的最小化接入流程alarmText4 是标准的 Qt Widgets 项目无外部依赖但编译前必须确认几个关键点。别跳过检查——很多“跑不起来”的问题都卡在这一步。3.1 环境准备Qt 版本与构建工具链项目使用 C17 特性如std::optional替代原始指针因此最低要求 Qt 5.15对应 MSVC 2019 / GCC 8.3 / Clang 7。Qt 6.x 用户需做两处修改将QTime::toString(hh:mm:ss)改为QTime::toString(Qt::TextDate)Qt 6 废除了字符串格式化将QPropertyAnimation的setPropertyName(virtualPos)改为setPropertyName(QByteArrayLiteral(virtualPos))Qt 6 要求 QByteArrayLiteral。提示如果你用的是 Qt Creator新建项目时选择 “Qt Widgets Application”然后把alarmtextwidget.h/cpp、numberrroller.h/cpp、rolleritem.h/cpp全部拖进Sources和Headers文件夹即可。.pro文件已包含CONFIG c17无需额外配置。3.2 快速验证三行代码嵌入现有 UI最简单的验证方式是在你主窗口的构造函数里插入以下代码假设主窗口叫MainWindow// mainwindow.cpp 构造函数内 #include alarmtextwidget.h MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { // 创建滚动选择器 AlarmTextWidget *alarmWidget new AlarmTextWidget(this); alarmWidget-setTime(QTime::currentTime()); // 初始化为当前时间 // 连接信号这里只是打印实际项目中替换为你的业务逻辑 connect(alarmWidget, AlarmTextWidget::timeChanged, [](const QTime t) { qDebug() Selected time: t.toString(hh:mm:ss); }); // 设置为主窗口中心部件 setCentralWidget(alarmWidget); }编译运行后你应该看到三个并排的数字滚轮鼠标按住中间数字上下拖动松手后自动吸附到整点。如果出现空白或崩溃请立即看下一节的避坑指南。3.3 资源文件说明哪些文件可删哪些必须保留解压alarmText4.zip后你会看到这些文件文件名类型是否必需说明alarmtextwidget.h/cpp头文件/实现✅ 必须主控件对外接口numberrroller.h/cpp头文件/实现✅ 必须滚动引擎核心逻辑rolleritem.h/cpp头文件/实现✅ 必须数字渲染单元main.cpp示例入口⚠️ 可删仅用于独立运行测试集成到你的项目时删除alarmtext4.proQt 项目文件⚠️ 可删你的项目已有.pro或CMakeLists.txt无需此文件resources/目录图片资源❌ 可删本项目纯代码绘制无图片资源注意resources/目录为空或仅含占位文件不要试图加载其中的图片——所有视觉效果均由RollerItem::paintEvent动态生成。4. 避坑指南那些让你调试到凌晨三点的典型问题我在某跨平台医疗设备项目里集成这个模块时踩过五个典型坑。这里不讲原理只列现象、原因、解决三要素照着查就能省 3 小时。4.1 现象滚轮显示为空白或数字错位成方块原因字体未正确加载或RollerItem::paintEvent中QFont设置失败。Qt 在某些嵌入式环境如 ARM Linux Freetype下默认字体缺失。解决在main()函数开头强制指定字体int main(int argc, char *argv[]) { QApplication a(argc, argv); // 强制使用系统默认等宽字体避免方块 QFont defaultFont QFontDatabase::systemFont(QFontDatabase::FixedFont); a.setFont(defaultFont); // ... 后续创建窗口 }4.2 现象鼠标拖动时数字跳变不平滑原因NumberRoller::mouseMoveEvent中未正确累加event-y()偏移而是用了绝对坐标。原包代码在mouseMoveEvent里用m_lastY记录上一次 Y 坐标但若鼠标移动过快event-y()可能突变。解决改用QMouseEvent::globalPos()计算相对位移void NumberRoller::mouseMoveEvent(QMouseEvent *e) { if (!m_isDragging) return; const int dy e-globalY() - m_lastGlobalY; // 改用全局坐标差 m_virtualPos - dy * 0.1; // 0.1 是灵敏度系数可调 m_lastGlobalY e-globalY(); updateItems(); // 触发重绘 }4.3 现象设置setTime(QTime(23,59,59))后秒滚轮显示为 0原因AlarmTextWidget::setTime内部调用NumberRoller::setValue()时未触发valueChanged信号导致timeChanged信号未发出但视觉已更新。解决在AlarmTextWidget::setTime末尾手动发射信号void AlarmTextWidget::setTime(const QTime t) { // ... 原有设置逻辑 // 补充确保信号发出 emit timeChanged(t); }4.4 现象在高 DPI 屏幕如 4K上滚轮尺寸异常缩小文字糊成一片原因RollerItem未启用高 DPI 缩放适配。Qt 5.14 默认开启Qt::AA_EnableHighDpiScaling但paintEvent中的像素计算未乘缩放因子。解决在RollerItem::paintEvent开头获取缩放因子void RollerItem::paintEvent(QPaintEvent *e) { const qreal scale devicePixelRatioF(); // Qt 5.14 QPainter p(this); p.scale(scale, scale); // 整体缩放 // ... 后续绘制代码保持不变 }4.5 现象程序退出时崩溃报QPropertyAnimation释放错误原因NumberRoller::snapToNearest中创建的QPropertyAnimation未设置父对象导致NumberRoller析构时动画仍在运行。解决将动画对象设为NumberRoller的子对象QPropertyAnimation *anim new QPropertyAnimation(this, virtualPos); // this 作为 parent // ... 其余代码不变5. 定制化改造从“时间选择器”到“任意数字序列选择器”alarmText4 的真正价值不在它能选时间而在于它提供了一个可无限扩展的滚动选择器骨架。下面以“支持自定义数字序列如 1, 5, 10, 15, 30, 60”为例展示如何在不破坏原有结构的前提下完成改造。这个需求在某高校实验室的通风系统控制面板中真实存在——他们需要选择采样间隔单位是分钟但只允许选预设值。5.1 数据模型升级从int range到QVectorint values原NumberRoller用QPairint,int表示范围如0~23这无法表达非连续序列。我们新增一个QVectorint m_values成员并重载setValue()和value()方法// numberrroller.h 新增 private: QVectorint m_values; // 存储所有合法值如 {1,5,10,15,30,60} int m_currentIndex; // 当前选中值在 m_values 中的索引 public: void setValues(const QVectorint vals); int value() const; // 返回 m_values[m_currentIndex] void setValue(int v); // 查找 v 在 m_values 中的索引再设置关键改造点在snapToNearest()原来用qRound(m_virtualPos)找最近整数现在要遍历m_values找距离m_virtualPos最近的值// numberrroller.cpp 修改 snapToNearest void NumberRoller::snapToNearest() { if (m_values.isEmpty()) return; int bestIndex 0; qreal minDiff qAbs(m_virtualPos - m_values[0]); for (int i 1; i m_values.size(); i) { const qreal diff qAbs(m_virtualPos - m_values[i]); if (diff minDiff) { minDiff diff; bestIndex i; } } if (minDiff 0.25) { // 启动动画到 bestIndex 对应的值 animateTo(bestIndex); } else { // 回弹到边界索引 const int boundaryIndex (m_virtualPos m_values.first()) ? 0 : m_values.size()-1; animateTo(boundaryIndex); } } void NumberRoller::animateTo(int targetIndex) { QPropertyAnimation *anim new QPropertyAnimation(this, currentIndex); anim-setDuration(200); anim-setStartValue(m_currentIndex); anim-setEndValue(targetIndex); anim-start(QAbstractAnimation::DeleteWhenStopped); connect(anim, QPropertyAnimation::finished, this, [this]() { m_currentIndex qBound(0, m_currentIndex, m_values.size()-1); emit valueChanged(m_values[m_currentIndex]); }); }5.2 UI 层适配AlarmTextWidget 如何传递自定义序列AlarmTextWidget需要暴露新接口让调用者能为每个滚轮单独设置序列。我们不修改原有setTime()而是新增setHourValues()、setMinuteValues()等方法// alarmtextwidget.h 新增 public: void setHourValues(const QVectorint vals); void setMinuteValues(const QVectorint vals); void setSecondValues(const QVectorint vals); private: QVectorQVectorint m_rollerValues; // 索引 0时,1分,2秒在构造函数中初始化m_rollerValues为默认序列AlarmTextWidget::AlarmTextWidget(QWidget *parent) : QWidget(parent) { m_rollerValues { QVectorint({0,1,2,...,23}), // 时 QVectorint({0,1,2,...,59}), // 分 QVectorint({0,1,2,...,59}) // 秒 }; // ... 创建滚轮时传入对应序列 for (int i 0; i 3; i) { NumberRoller *roller new NumberRoller(this); roller-setValues(m_rollerValues[i]); // ... 其他初始化 } }5.3 实战验证在通风系统中设置采样间隔某高校实验室的通风控制面板需要设置“采样间隔分钟”选项为{1,5,10,15,30,60}。他们在主窗口中这样调用// 创建 AlarmTextWidget 后 QVectorint minuteOptions {1,5,10,15,30,60}; alarmWidget-setMinuteValues(minuteOptions); alarmWidget-setHourValues({0}); // 小时固定为 0只用分钟滚轮 alarmWidget-setSecondValues({0}); // 秒固定为 0 // 连接信号获取选中的分钟数 connect(alarmWidget, AlarmTextWidget::timeChanged, [](const QTime t) { int minutes t.minute(); // 此时 t.minute() 就是 minuteOptions 中的某个值 qDebug() Sampling interval set to minutes minutes; });从那以后我每次接到“需要自定义选项的滚动选择器”需求都先打开numberrroller.h把m_range替换为m_values再花 15 分钟补全setValue/value的映射逻辑——这套模式在 7 个不同项目里验证过从未翻车。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站