查看: 388|回复: 0

PyQt6+QML翻页时钟实现:双卡片动画与时间刷新机制

[复制链接]
发表于 4 小时前 | 显示全部楼层 |阅读模式
翻页时钟看起来只是几个数字轮换,真正落地时会遇到两个问题:数字换页的动画如何做得干净,以及时间刷新如何保证不跳秒、不丢更新。本文基于一个 PyQt6 + QML 的实现,把工程结构、组件划分、动画链路和时间控制器拆开讲清楚,便于改成仪表盘、倒计时或广告屏的展示组件。

一、整体架构:Python 只做启动器

项目只有两个文件:main.py 和 main.qml。Python 端不参与界面渲染,也不写业务逻辑,只负责三件事:创建 QGuiApplication 实例、创建 QQmlApplicationEngine 并加载同目录的 main.qml、进入 Qt 事件循环。所有界面绘制、动画与时间刷新都在 QML 侧完成,界面代码与业务逻辑天然解耦。
  1. import sys
  2. import os
  3. from PyQt6.QtCore import QUrl
  4. from PyQt6.QtGui import QGuiApplication
  5. from PyQt6.QtQml import QQmlApplicationEngine
  6. def main():
  7.     app = QGuiApplication(sys.argv)
  8.     engine = QQmlApplicationEngine()
  9.     qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "main.qml")
  10.     engine.load(QUrl.fromLocalFile(qml_path))
  11.     if not engine.rootObjects():
  12.         print("错误:无法加载 QML 文件", file=sys.stderr)
  13.         sys.exit(-1)
  14.     sys.exit(app.exec())
  15. if __name__ == "__main__":
  16.     main()
复制代码

这里有两个细节值得注意。第一,路径用 QUrl.fromLocalFile 转换,而不是直接把字符串塞给引擎,这样可以规避 Windows 下盘符与反斜杠在 URL 解析上的歧义。第二,加载完成后必须用 engine.rootObjects() 判空:返回值为空说明 QML 文件存在语法错误或依赖模块缺失,此时直接退出并打印信息,比让程序静默卡住更容易排查。

运行前需安装 PyQt6,并保证 main.qml 与 main.py 位于同一目录。

二、QML 界面的四层结构

main.qml 采用声明式写法,界面自上而下分为四层:窗口层用 ApplicationWindow 定义 820x420 的窗口、标题与深色背景;背景层用 Rectangle 叠加 Gradient 绘制渐变,并画一个低透明度的装饰光圈;内容层用 Column 与 Row 垂直、水平排布标题、时钟行和日期行;组件层则是 FlipDigit 翻页卡片与 ColonSeparator 冒号分隔符。

组件使用 QML 的 component 关键字以内联方式定义,相当于在同文件内声明可复用的“类”,每个实例持有独立属性状态,不需要额外拆文件。

三、FlipDigit:三个属性的分工

翻页数字卡片对外暴露三个整型属性:digitValue 是外部传入的目标数字,由时间控制器赋值;shown 是当前卡片上真正显示的数字;pending 是等待翻页后显示的下一个数字。初始化时通过 Component.onCompleted 把 shown 与 pending 都置为 digitValue,避免首帧出现空白。
  1. component FlipDigit: Item {
  2.     id: card
  3.     width: 96
  4.     height: 134
  5.     property int digitValue: 0
  6.     property int shown: 0
  7.     property int pending: 0
  8.     onDigitValueChanged: {
  9.         if (digitValue !== shown) {
  10.             pending = digitValue
  11.             flipAnim.start()
  12.         }
  13.     }
  14.     Component.onCompleted: {
  15.         shown = digitValue
  16.         pending = digitValue
  17.     }
  18. }
复制代码

触发条件里的 digitValue !== shown 判断很关键:定时器每 200ms 刷新一次,但数字并非每次都变,若不加判断,每次属性赋值都会启动动画,造成无意义的状态堆积。

四、双卡片结构与动画参数

每张卡片内部叠放两个 Rectangle,外面再套一个 clip: true 的裁剪容器。oldCard 显示当前数字,初始 y = 0;newCard 显示下一数字,初始藏在下方 y = card.height。裁剪容器保证滑动过程中内容不溢出卡片边界,翻页视觉才干净。

flipAnim 是一个 SequentialAnimation,内部先执行一个 ParallelAnimation,同时驱动两张卡片做位移与透明度变化,参数如下:

oldCard 的 y 从 0 到 -card.height,opacity 从 1.0 到 0.0,缓动为 Easing.InQuad;newCard 的 y 从 card.height 到 0,opacity 从 0.0 到 1.0,缓动为 Easing.OutQuad。
  1. SequentialAnimation {
  2.     id: flipAnim
  3.     ParallelAnimation {
  4.         NumberAnimation {
  5.             target: oldCard; property: "y"
  6.             from: 0; to: -card.height
  7.             easing.type: Easing.InQuad
  8.         }
  9.         NumberAnimation {
  10.             target: oldCard; property: "opacity"
  11.             from: 1.0; to: 0.0
  12.             easing.type: Easing.InQuad
  13.         }
  14.         NumberAnimation {
  15.             target: newCard; property: "y"
  16.             from: card.height; to: 0
  17.             easing.type: Easing.OutQuad
  18.         }
  19.         NumberAnimation {
  20.             target: newCard; property: "opacity"
  21.             from: 0.0; to: 1.0
  22.             easing.type: Easing.OutQuad
  23.         }
  24.     }
  25. }
复制代码

旧卡加速滑出模拟翻起的惯性,新卡减速滑入模拟落下的缓冲,一快一慢形成自然的翻页手感,这是纯 CSS 式匀速位移难以还原的部分。

五、状态重置与连续翻页

动画结束后由 ScriptAction 收尾:把 shown 更新为 pending,恢复 oldCard 的 y 与 opacity,恢复 newCard 的初始位置与透明状态,然后再判断一次 digitValue 是否仍不等于 shown。如果用户在动画播放期间又改了数字(例如秒位连续跳动),这一步会立即再启动一次 flipAnim,保证更新不丢失、数字不卡住。
  1. ScriptAction { script: {
  2.     card.shown = card.pending
  3.     oldCard.y = 0
  4.     oldCard.opacity = 1
  5.     newCard.y = card.height
  6.     newCard.opacity = 0
  7.     if (card.digitValue !== card.shown) {
  8.         card.pending = card.digitValue
  9.         flipAnim.start()
  10.     }
  11. } }
复制代码

这条链路可以概括为:Timer 每 200ms 触发 refresh(),控制器算出时分秒并写入 digitValue,属性变化触发 onDigitValueChanged,动画启动,ScriptAction 重置并复检。

六、时间控制器与 200ms 刷新

时间控制器是一个 QtObject,持有六个整型属性:小时的十位与个位、分钟的十位与个位、秒的十位与个位。refresh() 中通过 new Date() 取当前时间,再用 Math.floor(h / 10) 与 h % 10 拆分十位个位,同时把完整日期赋给日期标签的属性。
  1. QtObject {
  2.     id: tc
  3.     property int hoursTens: 0
  4.     property int hoursOnes: 0
  5.     property int minsTens: 0
  6.     property int minsOnes: 0
  7.     property int secsTens: 0
  8.     property int secsOnes: 0
  9.     function refresh() {
  10.         var d = new Date()
  11.         var h = d.getHours()
  12.         var m = d.getMinutes()
  13.         var s = d.getSeconds()
  14.         hoursTens = Math.floor(h / 10)
  15.         hoursOnes = h % 10
  16.         minsTens = Math.floor(m / 10)
  17.         minsOnes = m % 10
  18.         secsTens = Math.floor(s / 10)
  19.         secsOnes = s % 10
  20.         dateLabel.now = d
  21.     }
  22. }
  23. Timer {
  24.     interval: 200
  25.     repeat: true
  26.     running: true
  27.     triggeredOnStart: true
  28.     onTriggered: tc.refresh()
  29. }
复制代码

定时器选 200ms 而非 1000ms,是为了让秒的跳变被及时捕捉:系统负载导致定时器延迟时,200ms 的粒度仍能在较小偏差内发现秒数变化,避免出现“跳秒”。triggeredOnStart: true 则保证窗口一出现就显示正确时间,而不是先显示一串 0。

日期行用 Label 的 now 属性承载 Date 对象,配合 dayNames 数组取“星期日”到“星期六”的中文名称,拼接年月日显示,与时钟共享同一次刷新。

七、冒号脉动的实现方式

ColonSeparator 是固定 22x134 的 Item,内部一个 Column 排两个 10x10 的圆点,间距 28,颜色与数字一致。闪烁效果用 SequentialAnimation on opacity 实现:loops 设为 Animation.Infinite,两段 NumberAnimation 交替把透明度在 0.25 与 1.0 之间过渡,每段 800ms,缓动 Easing.InOutQuad,形成类似真实时钟秒针跳动的呼吸节奏。
  1. SequentialAnimation on opacity {
  2.     loops: Animation.Infinite
  3.     NumberAnimation { to: 0.25; duration: 800; easing.type: Easing.InOutQuad }
  4.     NumberAnimation { to: 1.0; duration: 800; easing.type: Easing.InOutQuad }
  5. }
复制代码

八、常见问题与排查要点

程序启动后无窗口:先看 engine.rootObjects() 是否为空,为空通常是 QML 语法错误、import 的模块版本不匹配,或 main.qml 不在预期路径。

数字露出卡片边界:检查 viewport 是否设置了 clip: true,以及外层是否还有一层独立的全尺寸边框矩形覆盖在裁剪层之上。

数字卡住不更新:检查 onDigitValueChanged 是否误写成赋值而非比较,以及 ScriptAction 中重置后是否保留了再次启动动画的复检逻辑。

秒数偶发跳变:确认 Timer 的 interval 未被改成 1000,且没有在其他地方重复创建多个时间控制器造成属性互相覆盖。

动画播放中数字闪烁:多半是 pending 与 shown 的赋值顺序写反,导致新卡片显示的还是旧值。

九、适用场景

这套“声明式界面 + 属性绑定 + 状态机动画”的组合,本质是把刷新逻辑交给属性变化驱动,把视觉过渡交给动画序列驱动,代码量小且易于替换外观。除时钟外,同样适合数字仪表盘、倒计时器、展会广告屏等需要动态刷新数字的界面;若要接入真实业务数据,只需在 Python 侧导出对象或通过信号把数值写入对应 digitValue 即可,组件本身无需改动。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-10-17 15:41 , Processed in 0.024258 second(s), 18 queries , Gzip On, Redis On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部