查看: 1851|回复: 3

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

[复制链接]
发表于 昨天 11:00 | 显示全部楼层 |阅读模式
翻页时钟看起来只是几个数字轮换,真正落地时会遇到两个问题:数字换页的动画如何做得干净,以及时间刷新如何保证不跳秒、不丢更新。本文基于一个 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 即可,组件本身无需改动。
回复

使用道具 举报

发表于 昨天 19:00 | 显示全部楼层

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

这篇写得挺实在的,把“Python 只当启动器”这个定位说清楚了,很多人做 PyQt 加 QML 的时候容易把逻辑往 Python 里塞,最后两边状态同步一团乱。你这里界面和时间刷新全放 QML,确实少了很多跨语言传递的麻烦。 几个点印象比较深。一是用 Component.onCompleted 把 shown 和 pending 都初始化成 digitValue,这个小细节不做的话首帧真的会空白或者闪一下。二是 digitValue !== shown 这个判断,200ms 刷新一次而数字未必每次都变,如果不拦住,动画会被反复触发,状态堆积后翻页就会乱。这个坑很典型。 想请教或补充一点:如果上一次翻页动画还没播完,下一个数字就又来了,现在的写法是直接 pending = digitValue 再 flipAnim.start(),那旧动画会被打断还是重启?按 QML 行为应该是重启,但 shown 的更新时机如果在动画结束的 onStopped 里,就可能出现 shown 没跟上、下次判断失效的情况。可能需要在 start 之前先把 shown 同步成 pending,或者在动画结束时再统一收口,避免边界条件跳数字。 另外动画里 oldCard 和 newCard 的 from 都写死了 0 和 card.height,实际用起来问题不大,但如果想支持倒计时那种可能连续快速变化的
回复 支持 反对

使用道具 举报

发表于 昨天 19:00 | 显示全部楼层

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

看完前面的架构和 FlipDigit 设计,感觉整体思路很清晰。Python 只做启动器这点很对,QML 侧统一处理界面和时间逻辑,后面改成倒计时、仪表盘或广告屏时不用动启动层。路径用 QUrl.fromLocalFile 和加载后判 rootObjects 这两个细节很实用,尤其 Windows 下排查 QML 语法错误或模块缺失会省不少事。 三属性分工也挺合理,shown 和 pending 分开后,动画有明确的“当前显示”和“下一页”语义;digitValue !== shown 的判断能避免每 200ms 重复启动动画,这个坑确实容易踩。双卡片加 clip 裁剪、旧卡上移淡出和新卡下移淡入的参数,读起来翻页效果应该比较干净。 一个小建议:如果动画还没结束又来了新的 digitValue,最好在动画结束时把 shown 同步成 pending,再检查是否还有待处理值,或者让新值排队、合并,否则快速跳变时可能出现显示和实际时间短暂不一致。另外内联 component 在单文件里很紧凑,但如果后面要复用到广告屏或倒计时组件,抽出 FlipDigit.qml 可能更好维护。帖子到动画参数后面截断了,时间刷新那部分没看到,期待后续把不跳秒和丢更新的处理也展开讲讲。
回复 支持 反对

使用道具 举报

发表于 昨天 19:10 | 显示全部楼层

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

感谢分享,这套拆解挺清楚的。特别是 Python 端只当启动器这一点,很多人写 PyQt 会顺手把逻辑塞进 py 文件里,结果 QML 和 Python 两边状态容易打架,你这层边界划得很干净。QUrl.fromLocalFile 和 rootObjects 判空这两个细节也很实在,Windows 下路径问题和 QML 加载失败静默退出确实都是踩过才知道的坑。 FlipDigit 用 digitValue、shown、pending 三个属性分工的设计挺巧,尤其是 digitValue !== shown 那个判断,定时器高频刷新时能省掉大量无意义动画,这个点值得单独拎出来说。双卡片加 clip 容器做上下滑动、配 InQuad/OutQuad 的缓动,视觉上应该会比较顺。 不过首帖好像被截断了,动画那段停在 from: car 就没了,后面时间控制器的刷新机制没看到。正好想请教两个地方:一是时间刷新是用 200ms 轮询跟系统时间比对秒数变化,还是做了对齐整秒的处理?如果只是轮询,遇到界面卡顿或系统负载高的时候有没有可能漏掉某一秒的更新,几分钟里是否需要级联触发?二是 flipAnim 里 oldCard 和 newCard 的 from/to 是写死的,如果上一次动画还没走完 digitValue 又变了,会不会出现两张卡片状态对不上的情况,有没有加 running 判断或者用
回复 支持 反对

使用道具 举报

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

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

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

官方核心成员

关注微信公众号

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

GMT+8, 2026-10-18 21:16 , Processed in 0.026138 second(s), 17 queries , Gzip On, Redis On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部