QML 实现好看的时钟卡片

发布时间:2026/9/10 19:15:26

QML 实现好看的时钟卡片 前言在网上看到一个好看的时钟效果之前在华为手机上的时钟也有同样类似的效果觉得挺漂亮用qml做一个。中心显示hh:mm格式时间圆环外圈由密集刻度组成一个小黑点作为“秒针”绕圈运动并且在它经过的位置外边框会同步产生柔和的凸起动画。可以作为小组件放到项目中可用的地方。动态效果两个版本右图更细腻的高光、玻璃感和黑点层次更接近“苹果风”。 静态图效果拆解这个效果本质上可以拆成四部分一张圆角渐变卡片一个显示当前时间的居中文字一圈均匀分布的刻度线一个按秒旋转的小黑点以及它驱动的局部凸起动画实现时最关键的不是Text而是圆环和凸起部分。因为这部分更适合用Canvas动态绘制而不是用大量Rectangle拼接。1. 时间如何驱动时间数据用一个Timer每 16ms 刷新一次这样可以让黑点沿圆周连续运动而不是每秒跳一下。核心思路是把“当前秒数 毫秒”换算成0 ~ 1的进度再转成弧度property date now: new Date() property real tau: Math.PI * 2 property real secondsProgress: { var current now return (current.getSeconds() current.getMilliseconds() / 1000.0) / 60.0 } property real secondAngle: secondsProgress * tau - Math.PI / 2 property string timeText: { var hours now.getHours() var minutes now.getMinutes() return (hours 10 ? 0 : ) hours : (minutes 10 ? 0 : ) minutes } Timer { interval: 16 running: true repeat: true onTriggered: { root.now new Date() ringCanvas.requestPaint() } }这里的- Math.PI / 2是为了让 0 秒从正上方开始而不是从三点钟方向开始。2. 为什么用 Canvas 画圆环如果只画固定刻度用Repeater Rectangle也能做。但这个需求有两个动态变化小黑点要连续旋转黑点附近的外轮廓和刻度要出现“局部鼓起”的动画这类局部变形更适合用Canvas因为你可以直接基于角度、半径和曲线公式逐帧计算每个点的位置。基础版里Canvas主要画三样东西外圈轮廓120 根刻度线黑点和它的阴影3. 凸起效果怎么做核心做法是先计算圆环上任意一个点和“当前秒针角度”的角度差然后让这个差值经过一个高斯函数得到一个平滑的影响值。代码如下function wrapAngle(value) { while (value Math.PI) value - root.tau while (value -Math.PI) value root.tau return value } function gaussian(delta, spread) { return Math.exp(-(delta * delta) / (2 * spread * spread)) }wrapAngle()用来把角度差归一化到[-PI, PI]避免跨越 0/360 度时出现跳变。gaussian()返回一个 0 到 1 之间的值。角度越靠近黑点这个值越接近 1离得越远值就越快衰减到 0。然后把这个值用于控制外轮廓半径增加多少刻度线向外拉长多少刻度线宽度和透明度增加多少例如基础版的外轮廓for (var i 0; i 180; i) { var t i / 180 var arcAngle t * root.tau - Math.PI / 2 var delta wrapAngle(arcAngle - angle) var bulge gaussian(delta, 0.28) * 9 var radius tickOuter - 5 bulge var px cx Math.cos(arcAngle) * radius var py cy Math.sin(arcAngle) * radius if (i 0) ctx.moveTo(px, py) else ctx.lineTo(px, py) }这样得到的凸起不是硬切换而是像果冻一样从中心向两边平滑过渡。4. 小黑点如何绘制黑点位置直接由当前角度和轨道半径计算得到var dotOrbit baseRadius 3 var dotX cx Math.cos(angle) * dotOrbit var dotY cy Math.sin(angle) * dotOrbit基础版只给它加了一个柔和阴影。增强版额外做了三层处理一层投影让它更像悬浮在界面上一层微弱白色辉光让局部更有通透感一层深灰渐变实体再加一个小高光点这三层叠起来后黑点会更接近系统级 UI 的细节质感。5. 苹果风版本做了哪些增强main_apple.qml的结构没有变重点是把质感往前推了一步背景渐变更细过渡更平滑顶部增加半透明高光模拟玻璃反射中间增加一层很淡的内圈描边让中心区域更干净外圈凸起更锐一点但仍然保持平滑黑点增加投影、渐变和高光时间文字改成更紧凑的字距看起来更像系统时钟组件也就是说增强版不是换一种实现方式而是在同一套几何逻辑上继续做材质和层次优化。基础版完整代码import QtQuick 2.15 import QtQuick.Window 2.15 Window { id: root visible: true width: 440 height: 440 color: transparent title: Clock Card // flags: Qt.FramelessWindowHint property date now: new Date() property real tau: Math.PI * 2 property real secondsProgress: { var current now return (current.getSeconds() current.getMilliseconds() / 1000.0) / 60.0 } property real secondAngle: secondsProgress * tau - Math.PI / 2 property string timeText: { var hours now.getHours() var minutes now.getMinutes() return (hours 10 ? 0 : ) hours : (minutes 10 ? 0 : ) minutes } Timer { id: clockTimer interval: 16 running: true repeat: true onTriggered: { root.now new Date() ringCanvas.requestPaint() } } Rectangle { id: card anchors.fill: parent anchors.margins: 4 radius: 24 antialiasing: true clip: true gradient: Gradient { GradientStop { position: 0.0; color: #32d12d } GradientStop { position: 0.52; color: #3fd35e } GradientStop { position: 1.0; color: #58d6cf } } Canvas { id: ringCanvas anchors.fill: parent anchors.margins: 22 antialiasing: true renderStrategy: Canvas.Threaded function wrapAngle(value) { while (value Math.PI) value - root.tau while (value -Math.PI) value root.tau return value } function gaussian(delta, spread) { return Math.exp(-(delta * delta) / (2 * spread * spread)) } onPaint: { var ctx getContext(2d) ctx.reset() var w width var h height var cx w / 2 var cy h / 2 var baseRadius Math.min(w, h) * 0.36 var tickInner baseRadius 6 var tickOuter baseRadius 32 var angle root.secondAngle var halo ctx.createRadialGradient(cx, cy, baseRadius * 0.2, cx, cy, tickOuter 28) halo.addColorStop(0.0, rgba(255,255,255,0.00)) halo.addColorStop(0.7, rgba(255,255,255,0.05)) halo.addColorStop(1.0, rgba(255,255,255,0.00)) ctx.fillStyle halo ctx.fillRect(0, 0, w, h) ctx.beginPath() for (var i 0; i 180; i) { var t i / 180 var arcAngle t * root.tau - Math.PI / 2 var delta wrapAngle(arcAngle - angle) var bulge gaussian(delta, 0.28) * 9 var radius tickOuter - 5 bulge var px cx Math.cos(arcAngle) * radius var py cy Math.sin(arcAngle) * radius if (i 0) ctx.moveTo(px, py) else ctx.lineTo(px, py) } ctx.strokeStyle rgba(255,255,255,0.18) ctx.lineWidth 3 ctx.lineJoin round ctx.lineCap round ctx.stroke() var totalTicks 120 for (var tick 0; tick totalTicks; tick) { var tickAngle tick / totalTicks * root.tau - Math.PI / 2 var tickDelta wrapAngle(tickAngle - angle) var emphasis gaussian(tickDelta, 0.20) var extraOuter emphasis * 17 var extraInner emphasis * 4 var lineWidth 2.0 emphasis * 1.8 var alpha 0.92 emphasis * 0.08 var innerRadius tickInner - extraInner var outerRadius tickOuter extraOuter var x1 cx Math.cos(tickAngle) * innerRadius var y1 cy Math.sin(tickAngle) * innerRadius var x2 cx Math.cos(tickAngle) * outerRadius var y2 cy Math.sin(tickAngle) * outerRadius ctx.beginPath() ctx.moveTo(x1, y1) ctx.lineTo(x2, y2) ctx.strokeStyle rgba(255,255,255, alpha.toFixed(3) ) ctx.lineWidth lineWidth ctx.lineCap round ctx.stroke() } var dotRadius 9 var dotOrbit baseRadius 3 var dotX cx Math.cos(angle) * dotOrbit var dotY cy Math.sin(angle) * dotOrbit var glow ctx.createRadialGradient(dotX, dotY, 0, dotX, dotY, 28) glow.addColorStop(0.0, rgba(0,0,0,0.18)) glow.addColorStop(1.0, rgba(0,0,0,0.00)) ctx.fillStyle glow ctx.beginPath() ctx.arc(dotX, dotY, 28, 0, root.tau, false) ctx.fill() ctx.beginPath() ctx.arc(dotX, dotY, dotRadius, 0, root.tau, false) ctx.fillStyle #2c2c2c ctx.fill() } } Repeater { model: 90 Rectangle { width: 1.4 height: 1.4 radius: width / 2 color: Qt.rgba(255,255,255,0.035) x: Math.random() * card.width y: Math.random() * card.height } } Text { anchors.centerIn: parent // anchors.verticalCenterOffset: 14 text: root.timeText color: white font.pixelSize: 102 // font.bold: true font.weight: Font.Light font.family: Segoe UI renderType: Text.NativeRendering } } }苹果质感版完整代码import QtQuick 2.15 import QtQuick.Window 2.15 Window { id: root visible: true width: 440 height: 440 color: #f5f5f7 title: Clock Card Apple property date now: new Date() property real tau: Math.PI * 2 property real secondsProgress: { var current now return (current.getSeconds() current.getMilliseconds() / 1000.0) / 60.0 } property real secondAngle: secondsProgress * tau - Math.PI / 2 property string timeText: { var hours now.getHours() var minutes now.getMinutes() return (hours 10 ? 0 : ) hours : (minutes 10 ? 0 : ) minutes } Timer { interval: 16 running: true repeat: true onTriggered: { root.now new Date() ringCanvas.requestPaint() } } Rectangle { id: card anchors.fill: parent anchors.margins: 6 radius: 26 antialiasing: true clip: true gradient: Gradient { GradientStop { position: 0.0; color: #39cf42 } GradientStop { position: 0.38; color: #4ad064 } GradientStop { position: 0.72; color: #58d59b } GradientStop { position: 1.0; color: #67d9d2 } } Rectangle { anchors.fill: parent color: transparent opacity: 0.95 gradient: Gradient { GradientStop { position: 0.0; color: #33ffffff } GradientStop { position: 0.22; color: #14ffffff } GradientStop { position: 0.55; color: #00ffffff } GradientStop { position: 1.0; color: #0a000000 } } } Rectangle { width: card.width * 0.92 height: card.height * 0.48 anchors.horizontalCenter: parent.horizontalCenter y: -card.height * 0.05 radius: width / 2 color: transparent rotation: -8 opacity: 0.42 gradient: Gradient { GradientStop { position: 0.0; color: #3dffffff } GradientStop { position: 0.5; color: #0dffffff } GradientStop { position: 1.0; color: #00ffffff } } } Rectangle { width: card.width * 0.72 height: card.height * 0.72 anchors.centerIn: parent radius: width / 2 color: transparent border.width: 1 border.color: #1fffffff opacity: 0.6 } Canvas { id: ringCanvas anchors.fill: parent anchors.margins: 20 antialiasing: true renderStrategy: Canvas.Threaded function wrapAngle(value) { while (value Math.PI) value - root.tau while (value -Math.PI) value root.tau return value } function gaussian(delta, spread) { return Math.exp(-(delta * delta) / (2 * spread * spread)) } onPaint: { var ctx getContext(2d) ctx.reset() var w width var h height var cx w / 2 var cy h / 2 var baseRadius Math.min(w, h) * 0.355 var ringInner baseRadius 8 var ringOuter baseRadius 34 var angle root.secondAngle var ambient ctx.createRadialGradient(cx, cy, baseRadius * 0.15, cx, cy, ringOuter 36) ambient.addColorStop(0.0, rgba(255,255,255,0.00)) ambient.addColorStop(0.62, rgba(255,255,255,0.05)) ambient.addColorStop(1.0, rgba(255,255,255,0.00)) ctx.fillStyle ambient ctx.fillRect(0, 0, w, h) ctx.beginPath() for (var i 0; i 220; i) { var t i / 220 var arcAngle t * root.tau - Math.PI / 2 var delta wrapAngle(arcAngle - angle) var bulge gaussian(delta, 0.24) * 11 var radius ringOuter - 6 bulge var px cx Math.cos(arcAngle) * radius var py cy Math.sin(arcAngle) * radius if (i 0) ctx.moveTo(px, py) else ctx.lineTo(px, py) } ctx.strokeStyle rgba(255,255,255,0.16) ctx.lineWidth 3.5 ctx.lineJoin round ctx.lineCap round ctx.stroke() ctx.beginPath() ctx.arc(cx, cy, ringInner - 3, 0, root.tau, false) ctx.strokeStyle rgba(255,255,255,0.10) ctx.lineWidth 1.2 ctx.stroke() var totalTicks 120 for (var tick 0; tick totalTicks; tick) { var tickAngle tick / totalTicks * root.tau - Math.PI / 2 var deltaTick wrapAngle(tickAngle - angle) var emphasis gaussian(deltaTick, 0.18) var stretch emphasis * 18 var pull emphasis * 5 var lineWidth 2.1 emphasis * 1.7 var alpha 0.86 emphasis * 0.14 var innerRadius ringInner - pull var outerRadius ringOuter stretch var x1 cx Math.cos(tickAngle) * innerRadius var y1 cy Math.sin(tickAngle) * innerRadius var x2 cx Math.cos(tickAngle) * outerRadius var y2 cy Math.sin(tickAngle) * outerRadius ctx.beginPath() ctx.moveTo(x1, y1) ctx.lineTo(x2, y2) ctx.strokeStyle rgba(255,255,255, alpha.toFixed(3) ) ctx.lineWidth lineWidth ctx.lineCap round ctx.stroke() } var dotOrbit baseRadius 5 var dotX cx Math.cos(angle) * dotOrbit var dotY cy Math.sin(angle) * dotOrbit var shadow ctx.createRadialGradient(dotX 2, dotY 3, 0, dotX 2, dotY 3, 24) shadow.addColorStop(0.0, rgba(0,0,0,0.22)) shadow.addColorStop(1.0, rgba(0,0,0,0.00)) ctx.fillStyle shadow ctx.beginPath() ctx.arc(dotX 2, dotY 3, 24, 0, root.tau, false) ctx.fill() var glow ctx.createRadialGradient(dotX, dotY, 0, dotX, dotY, 18) glow.addColorStop(0.0, rgba(255,255,255,0.22)) glow.addColorStop(1.0, rgba(255,255,255,0.00)) ctx.fillStyle glow ctx.beginPath() ctx.arc(dotX, dotY, 18, 0, root.tau, false) ctx.fill() ctx.beginPath() ctx.arc(dotX, dotY, 8.5, 0, root.tau, false) var dotFill ctx.createLinearGradient(dotX - 8, dotY - 8, dotX 8, dotY 8) dotFill.addColorStop(0.0, #4a4a4d) dotFill.addColorStop(0.45, #2d2d30) dotFill.addColorStop(1.0, #161618) ctx.fillStyle dotFill ctx.fill() ctx.beginPath() ctx.arc(dotX - 2.5, dotY - 2.8, 2.2, 0, root.tau, false) ctx.fillStyle rgba(255,255,255,0.20) ctx.fill() } } Repeater { model: 120 Rectangle { width: 1.2 height: 1.2 radius: width / 2 color: #07ffffff x: Math.random() * card.width y: Math.random() * card.height } } Text { anchors.centerIn: parent // anchors.verticalCenterOffset: 16 text: root.timeText color: #faffffff font.pixelSize: 102 font.weight: Font.Light font.family: Helvetica Neue font.letterSpacing: -2.2 renderType: Text.NativeRendering } } }总结这个效果的关键不是“画一个圆”而是把角度、半径和时间进度关联起来再通过一个平滑函数把局部变化控制住。这样你就可以得到连续运动的秒针黑点跟随黑点移动的局部凸起不突兀的过渡动画可以持续迭代的视觉层次如果后面还想继续做可以再往这几个方向扩展加上小时和分钟的环形层把黑点拖尾做成流体光晕用ShaderEffect进一步加强玻璃和折射质感做成桌面时钟或车机小组件
延伸阅读

更多相关文章

2026/9/9 16:14:36

ABAP报表程序通用模板

主程序INCLUDE zmm001_top. " 全局数据声明与常量 INCLUDE zmm001_sel. " 选择屏幕定义 INCLUDE zmm001_f01. " 子例程与业务逻辑INITIALIZATION.PERFORM frm_init_data. " 初始化默认值START-OF-SELECTION.PERFORM frm_get_data. " 数据抽取…

2026/9/8 23:16:54

STM32——库函数版——12864点阵屏图形绘制与波形显示实战

1. 12864点阵屏基础与STM32驱动准备第一次用STM32驱动12864点阵屏时,我对着数据手册研究了整整三天。这种单色液晶屏虽然看起来简单,但要想玩转图形绘制和波形显示,得先摸清它的脾气。市面上常见的12864屏主要分两种:带字库的ST79…

2026/9/10 19:14:09

VSG转动惯量与阻尼协同自适应控制:Simulink仿真复现实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/9/10 19:09:09

Comsol超声波探测与回波信号仿真实践指南

1. Comsol超声波探测与回波信号仿真概述 超声波探测技术在工业无损检测、医学成像等领域应用广泛,而Comsol Multiphysics作为一款强大的多物理场仿真软件,能够精确模拟超声波在不同介质中的传播特性。我在过去五年中完成了二十多个超声波相关仿真项目&am…

2026/9/10 16:39:38

超人会飞不算本事:系统稳定依赖清晰规则与边界设计

开头先不绕弯子。“#斯坦李吐槽dc 所以超人是无缘无故会飞的嘛哈哈哈哈哈哈哈锤哥真是技术人才啊!#雷神 #复联”这类调侃式短标题,第一波冲击力在于它把两个宇宙的角色塞进同一个吐槽箱里,但细想一下就能发现,它真正碰到的根本不是…

2026/9/10 11:16:38

超人VS蜘蛛侠:拆解超级IP的影响力与传播方法论

把“蜘蛛侠 vs 超人”放在 CSDN 上聊,可能很多人第一反应是走错片场了。但如果把这两个角色看成“两个持续运营了 80 多年的文化产品”,你会发现,这场比较本质上是两个不同 IP 策略的长期结果对比:超人赢在定义了整个超级英雄题材…

2026/9/9 16:31:09

基于CNN的调制信号识别:MATLAB实现时频图分类实战

简介:本资源是一套面向通信工程与信号处理方向学习者、研究者的深度学习实践方案,聚焦调制信号自动检测与识别这一典型无线通信任务,解决传统方法依赖人工特征、低信噪比下性能下降等痛点。压缩包共12个文件(10.73MB)&…

2026/9/10 0:00:55

目录对比去重实战:用哈希算法精准清理重复文件

我电脑里现在还有一块换了三次机的“数据墓地”硬盘,里面存着2016年以前所有旧笔记本的完整备份。平时不觉得有什么,直到前阵子想把它整理归档,发现同一个安装包、同一批照片、同一份论文草稿,在几个不同的备份目录里反复出现。更…

2026/9/10 0:00:55

Leaflet离线地图完整Demo合集:内网部署与坐标纠偏实战

简介:这是一份面向Web GIS开发者的LeafLet离线地图示例合集,帮助开发者快速掌握离线地图从搭建到交互的完整流程。压缩包共723个文件,大小14.06MB,以319个js脚本、175个html页面和29个css样式文件为主体,配合png/svg图…

2026/9/10 0:00:55

MATLAB读取Rinex 3.02观测文件:多系统GNSS数据解析实战

简介:基于MATLAB开发的Rinex3.02版观测文件(o文件)读取代码包,面向卫星定位导航方向的学习者与研究人员,用于解决新版观测文件的数据解析、历元提取与时间转换问题。压缩包共4个文件,包含两个m脚本、一个19…

2026/9/10 12:32:02

USB Type-C PCB布局分区设计:电源、高速信号与PD协议全攻略

做硬件这行,Type-C接口算是典型的“看着简单,做起来全坑”的东西。光引脚就24个,高低速信号、电源、控制线全部塞在一个小小的连接器里,如果PCB布局不做规划,打样回来基本就是“插上没反应”、“高速掉线”、“静电一打…

2026/9/10 15:19:50

系统编程学习原型如何补齐稳定性边界

系统编程学习原型如何补齐稳定性边界预算有限时&#xff0c;我先优化明显多余的复制&#xff0c;而不是猜测性地换容器。用借用传递只读数据通常就能减少分配&#xff1a; fn parse(line: &str) -> Result<Item, Error> { /* ... */ }用基准确认热点确实在分配&am…

2026/9/10 15:49:53

雨花区哪家财务公司代理记账比较好?

在雨花区&#xff0c;企业处理财税事务常常面临诸多挑战&#xff0c;选择一家靠谱的财务公司至关重要。湖南巨勤财务管理咨询有限公司就是本地正规实体财税服务机构&#xff0c;深耕本地工商财税行业多年&#xff0c;熟悉当地工商局、税务局最新政策与申报流程。主营公司注册、…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
咨询二维码