news 2026/10/9 18:41:03

HarmonyOS自绘翻页时钟与计时器:ArkTS Canvas动画实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
HarmonyOS自绘翻页时钟与计时器:ArkTS Canvas动画实战

这些年我做过不少组件,也折腾过各种时钟类应用,但翻页时钟(fliqlo风格)一直是我个人很偏爱的一个品类——它有一种物理机械的秩序感,和电子设备的“虚无感”形成强烈反差。HarmonyOS组件开发征集活动里,我选择做翻页时钟和计时器组件,倒不是为了追热点,而是因为这个场景非常适合用来展示鸿蒙生态下自绘组件的能力边界。用ArkTS从零画出一个带阻尼感的翻页动画,配合计时器状态机,这套东西做完,你对HarmonyOS NEXT的绘制调度、动画时机、状态管理都会有一个非常直观的认知。

这篇文章不聊虚的,就把这个组件的设计拆解、关键实现、踩坑记录全部展开。如果你也在写鸿蒙自绘组件,或者准备参加类似的征集活动,这应该是一份能直接“抄作业”的参考。

1. 为什么选“翻页时钟和计时器”这个方向

1.1 翻页时钟背后的组件需求

先说个判断:在鸿蒙的原生组件体系里,文本、按钮、列表这些高频组件已经非常成熟,真正缺的是“有视觉记忆点”的复合组件。翻页时钟恰好属于这一类——它既有明确的视觉形态(黑白翻转卡片),又有明确的交互节奏(每秒钟翻动一次),还有一定的动画复杂度(翻转时上下半区交替变化)。这就意味着,做这个组件时不能只堆现成API,得真正去思考绘制顺序、裁剪区域、动画曲线这些底层问题。

另一个考虑是计时器。单做一个时钟,组件功能太单薄,应用场景也窄。翻页时钟加计时器,本质上是在同一个视觉体系里塞进了两个状态模型:一个是“持续运行的绝对时间”,一个是“倒计时的相对时间”。这两者的刷新频率不同、状态语义不同、触发动画的条件也不同,放在一个组件里实现,恰好能覆盖大部分自定义组件的核心设计模式。

1.2 组件化设计的核心思路

我在动手前给自己定了几个原则,这些原则也直接影响了下文的实现方案:

  • 视觉核心是“翻页”,所有动画都围绕翻页这个动作展开,而不是整块数字渐变或滚动。
  • 状态和表现分离,时钟走时与倒计时的状态机独立,UI层只关心“当前展示的数字是什么”,不关心时间是怎么算出来的。
  • 自绘方案优先,翻页效果如果用多张图片拼,适配不同屏幕时会出现拉伸模糊,而且动画中间态也很难控制;用Canvas自绘,所有细节都捏在自己手里。

如果你问我“为什么不用系统自带的动画API做翻转”,我会说:系统动画能做的是整体视图的旋转和位移动画,但翻页时钟的视觉特征是两个半区分别变化,上半个数字先翻下去、下半个数字再翻上来,中间还要模拟一条“中缝压痕”。这种效果只有自绘加精确的裁剪区控制才能做到,纯靠通用动画API拼不出来。

2. 核心设计拆解:fliqlo风格翻页的数学与绘制逻辑

2.1 翻页数字的数学逻辑

很多人以为翻页时钟的核心是“画数字”,其实核心是“翻页动作发生后,上下半区各应该显示什么”。

我简化后的逻辑是这样:

  • 当前时间数字(比如小时十位)是current,下一秒要变成next。
  • 页面分为上半区和下半区。初始状态:上半区显示current,下半区显示current。
  • 翻页开始后:上半区保持显示current,但沿着上半区底边做旋转压缩,视觉上像“向下盖过去”。
  • 紧接着下半区开始翻转,下半区从“显示current”切换为“显示next”,沿着下半区顶边做旋转展开。
  • 翻转结束后,整个卡片变成显示next。

这里有个容易被忽略的细节:下半区翻转时,不能直接把下半区里的字符换成next再旋转。因为视觉上应该是“current的下半部分被盖住,露出next的下半部分”,所以下半区在旋转的最开始瞬间,仍然需要绘制current,随着旋转角度增加,再切换成绘制next。

处理方式是我用一个过渡值flipProgress(从0到1),然后:

  • 当flipProgress < 0.5时,上半区参与旋转,对应的旋转角度是0到-90度。
  • 当flipProgress >= 0.5时,下半区参与旋转,对应的旋转角度是90度到0度。
  • 下半区内容的切换点选在flipProgress == 0.5,也就是下半区刚准备从水平方向转出来时,显示的字符直接换成新值。

这样从视觉上,观察者看到的效果就是上一秒的数字被“翻下去”,下一秒的数字“翻上来”。当然,更精细的做法是把切换点放在下半区旋转角接近90度的一瞬间,也就是卡片侧边几乎垂直的时候,这样新旧字交替的痕迹最不明显。

2.2 基于Canvas的翻页绘制方案

HarmonyOS里用Canvas组件配合CanvasRenderingContext2D做绘制,这套API对做过Web Canvas的人非常友好,很多方法命名都一致。整个卡片我分成三个绘制区域:

  • 上半区(upperRegion):矩形区域,只显示当前数字。
  • 下半区(lowerRegion):矩形区域,根据翻转阶段显示当前数字或下一个数字。
  • 阴影与中缝:卡片中间横向会有一条深色压痕,用来模拟纸张翻折的立体感。

绘制时有个关键点:必须先整体裁剪,再独立旋转。以翻页进行到下半区展开为例:

  1. 先裁剪出下半区的矩形区域。
  2. 保存画布状态。
  3. 将坐标系沿下半区顶边平移到该边中点,然后旋转angle度(angle从90度逐步降到0度)。
  4. 在旋转后的坐标系里绘制字符next。
  5. 恢复画布状态。

这样做的效果是:字符是“贴”在翻起的纸片上的,会随纸片一起旋转出现,而不是生硬地直接画在固定位置。

对比一下直接用rotate旋转整个卡片中心,我的方案之所以要多做一次“平移到边缘再旋转”,是为了保证旋转轴在下半区的顶部边缘,这样看起来才是真正的“纸片从底面翻起”。如果绕中心旋转,视觉上会变成整张牌在翻跟头,完全不对。

2.3 计时器组件的状态设计

计时器组件我单独设计了一个状态机,事件分为:START、PAUSE、RESET、TICK。

  • IDLE:初始状态,剩余时间等于设定值。
  • RUNNING:倒计时进行中,每个TICK事件减一秒。
  • PAUSED:暂停状态,保留剩余时间。
  • COMPLETED:倒计时结束,触发回调。

为什么要把状态机拆开而不是用几个布尔标志位?因为翻页时钟和计时器共用了同一套UI渲染逻辑,如果状态分散在多个@State变量里,UI就需要监听多个变量的变化组合,很容易漏掉边界情况(比如倒计时结束后再点击开始,应该从IDLE还是COMPLETED出发)。状态机收敛之后,每个事件的处理路径都是确定的,UI只需要根据当前状态和剩余秒数决定渲染逻辑,省心很多。

UI层渲染时,我做了个转换函数:把剩余秒数(比如3671秒)拆成“小时、分钟、秒”,再分别按“十位/个位”拆成四个数字,传给翻页卡片去渲染。这样时钟和计时器就只用维护一组“当前显示的数字数组”,翻页组件完全不需要关心时间是怎么来的。

3. 实操过程:用ArkTS实现翻页时钟组件

3.1 工程搭建与SDK适配

我开发时用的是HarmonyOS NEXT SDK,也就是API 12+ / 5.0.0(12)这条线。工程上直接选择Empty Ability模板,语言用ArkTS,依赖ArkUI的声明式开发范式。

说一下SDK版本的选择逻辑:翻页组件里我需要用到Canvas的自定义绘制能力、animateTo的动画能力,以及定时器的精确调度。这些API在API 9之后基本都齐了,我之所以选API 12+,是因为API 12对Canvas的字体渲染、transform接口稳定度更好,而且@Component自定义组件的生命周期管理更完善,能避免计时器在页面退场后泄漏的问题。

工程上建议开启buildMode的release预览,因为调试模式下Canvas的绘制帧率优化策略不同,有时候真机表现跟预览器差别较大。翻页动画这类高频绘制组件,一定要以真机为准。

3.2 核心代码实现

下面这段代码是翻页卡片绘制的核心逻辑。我简化了一些边界处理,但主流程是完整的。

@Component export struct FlipCard { private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true)) @Prop current: number = 0 @Prop next: number = 0 @Prop flipProgress: number = 0 // 0~1 private radius: number = 12 private fontRatio: number = 0.6 build() { Canvas(this.ctx) .width('100%') .height('100%') .onReady(() => { this.drawFlipCard() }) } private drawFlipCard() { const w = this.ctx.width const h = this.ctx.height const half = h / 2 const angle = this.flipProgress * Math.PI this.ctx.clearRect(0, 0, w, h) // 1. 绘制静态的下半区背景(始终保持当前数字的下半部分) this.ctx.save() this.drawBackground(0, half, w, half) this.drawDigit(this.current, w / 2, half + half * 0.68, w * this.fontRatio, '#FFFFFF') this.ctx.restore() if (this.flipProgress < 0.5) { // 2. 上半区翻起阶段:当前数字的上半部分向下旋转压缩 const rotateAngle = -90 * (this.flipProgress * 2) this.ctx.save() this.clipRegion(0, 0, w, half) this.translateAndRotate(w / 2, half, this.angleToRad(rotateAngle)) this.drawBackground(0, -half / 2, w, half) this.drawDigit(this.current, w / 2, half * 0.68, w * this.fontRatio, '#FFFFFF') this.ctx.restore() } else { // 3. 下半区翻起阶段:下一数字从底部翻转出现 const rotateAngle = 90 * (1 - (this.flipProgress - 0.5) * 2) const showNext = this.flipProgress >= 0.5 this.ctx.save() this.clipRegion(0, half, w, half) this.translateAndRotate(w / 2, half, this.angleToRad(rotateAngle)) this.drawBackground(0, half, w, half) this.drawDigit(showNext ? this.next : this.current, w / 2, half + half * 0.68, w * this.fontRatio, '#FFFFFF') // 4. 补一层中缝阴影 this.ctx.beginPath() this.ctx.rect(0, half - 4, w, 4) this.ctx.fillStyle = 'rgba(0, 0, 0, 0.4)' this.ctx.fill() this.ctx.restore() } } private angleToRad(deg: number): number { return deg * Math.PI / 180 } private clipRegion(x: number, y: number, w: number, h: number) { this.ctx.beginPath() this.ctx.rect(x, y, w, h) this.ctx.clip() } private translateAndRotate(x: number, y: number, rad: number) { this.ctx.translate(x, y) this.ctx.rotate(rad) } private drawBackground(x: number, y: number, w: number, h: number) { this.ctx.fillStyle = '#111111' this.ctx.beginPath() // 圆角矩形,视觉上模拟卡片厚度 if (this.radius > 0) { const r = Math.min(this.radius, w / 2, h / 2) this.ctx.moveTo(x + r, y) this.ctx.arcTo(x + w, y, x + w, y + h, r) this.ctx.arcTo(x + w, y + h, x, y + h, r) this.ctx.arcTo(x, y + h, x, y, r) this.ctx.arcTo(x, y, x + w, y, r) this.ctx.closePath() } this.ctx.fill() } private drawDigit(digit: number, cx: number, baseY: number, fontSize: number, color: string) { this.ctx.font = `700 ${fontSize}px 'HarmonyOS Sans'` this.ctx.fillStyle = color this.ctx.textAlign = 'center' this.ctx.textBaseline = 'middle' this.ctx.fillText(digit.toString(), cx, baseY) } }

这段代码里有两个比较重要的细节。

第一个是clipRegion配合translateAndRotate的顺序。必须先裁剪后变换,否则变换会带着整个画布一起跑,下半区的裁剪区域就失效了。而且每次变换前我都save(),结束变换后restore(),避免前一次的变化叠加到后面。很多初学Canvas的人会在这上面翻车,画出来的翻页卡片会“满天飞”,多半就是忘了恢复坐标系。

第二个是下半区字符的绘制基准。我的drawDigit里传的baseY,实际是旋转后坐标系里的位置。因为我把原点平移到下半区顶边中点后再旋转,所以字符在旋转后的局部坐标系里仍然按照middle基线绘制,文字会自动“贴在翻起的纸面上”。如果你直接传屏幕坐标的y值,旋转后会偏离纸面,错位会非常明显。

3.3 翻页动画调度与性能优化

有了绘制函数,接下来就是驱动它。我封装了一个FlipClock父组件,内部维护一组“目标数字”和“当前显示数字”,当检测到目标数字变化时,触发一次翻页动画。

动画驱动我用的是displaySync回调,而不是setInterval或者animateTo。原因是翻页动画的进度需要逐帧同步,每一帧都根据flipProgress重绘画布。如果用setInterval驱动,实际帧率不稳定,在低端设备上会出现动画掉帧、数字切换瞬间卡顿。

简化后的动画调度逻辑:

private startFlip(nextDigit: number) { if (this.isFlipping) return this.next = nextDigit this.isFlipping = true this.flipProgress = 0 let lastTime = 0 const duration = 800 // 翻页动画总时长,单位ms const step = (timestamp: number) => { if (lastTime === 0) lastTime = timestamp const delta = timestamp - lastTime lastTime = timestamp this.flipProgress = Math.min(this.flipProgress + delta / duration, 1) this.drawFlipCard() if (this.flipProgress < 1) { this.displaySync.requestAnimationFrame(step) } else { this.isFlipping = false this.current = this.next } } this.displaySync.requestAnimationFrame(step) }

我实测下来,duration设为800ms是一个视觉节奏比较舒服的值。fliclo原版大约是600-700ms左右,但鸿蒙的卡片比例偏宽,翻转速度太快会显得“轻飘”,太慢又会拖沓。800ms配合合适的缓动曲线,能模拟出机械翻页的阻尼感。

关于缓动曲线,我建议翻页的前半段用easeOut,后半段用easeIn。简单说,就是上半区刚翻下来时速度稍微快一点,接近90度时逐渐减速;下半区刚翻起时速度慢一点,接近水平时逐渐加速。这种“慢-快-慢”的节奏更接近物理机构中被弹簧和阻尼共同作用的感觉。

性能方面有个坑必须提醒:绘制卡片时不要频繁创建Paint对象或Font对象,ArkTS的垃圾回收在频繁触发时会造成卡顿。我自己是把字体配置直接放进setFont里,只传字体大小和名称,需要换字重时再更新字符串,而不是每次绘制都重新构造一个完整字体对象。另外Canvas的宽高在onReady里缓存下来,不要把this.ctx.width当变量到处传,因为ArkUI的Canvas上下文在不同时机取宽度,表现不一定一致。

4. 常见问题与排查技巧实录

4.1 翻页卡顿与图层闪烁

翻页卡顿是我调试期间遇到最多的问题,而且不同设备表现差异很大。在预览器上很流畅的动画,放到真机上偶尔会掉帧。

排查之后找到两个原因:一是绘制函数里有多余的clearRect全区域清除,每帧都清全图,浪费GPU带宽;二是绘制背景圆角时,我用的arcTo变得复杂,连续画多个圆角矩形导致路径计算量大。

解决办法是尽量缩小清除区域,比如只清除上下半区各自的有效绘制区域;圆角路径改成一次path构建完成,不要每帧重新计算圆角半径。实测下来,真机帧率从30帧提升到60帧,肉眼感知非常明显。

图层闪烁的问题比较隐蔽。有时翻页到后半段,下半区会闪一下旧数字的残影,原因是在flipProgress=0.5的临界帧,我把showNext置为true,但Canvas的光栅化缓存还没清掉上一帧的绘制内容。对比了两种方案后,我在临界帧手动再清一次下半区矩形,同时把下半区的背景绘制和数字绘制合并进同一次save/restore,问题就消失了。

4.2 倒计时精度漂移

用setInterval做倒计时,最常见的坑是误差累积。比如设定1000ms执行一次TICK,但主线程一旦被其他任务阻塞,回调时间就会延后。累计下来,一个25分钟的番茄钟可能会走慢十几秒,这对计时器组件来说是无法接受的。

我最终的方案是:不用累加次数,而是用时间戳差值。每次TICK时,计算new Date().getTime()与预设的endTime之间的差值,向上取整作为剩余秒数。

const remainingMs = this.endTime - Date.now() this.remainingSeconds = Math.max(0, Math.ceil(remainingMs / 1000))

这样即使回调有延迟,下一帧也会根据绝对时间戳重新校准,不会累计误差。同时sleep状态下如果你对精度有更高要求,可以用setTimeout自递归,并在每次回调里动态计算下一次执行的延迟时间,进一步减少定时器漂移。不过对普通UI倒计时来说,时间戳差值法已经足够了。

4.3 真机适配与字体渲染差异

HarmonyOS自带的系统字体是HarmonyOS Sans,在真机和预览器上的渲染效果基本一致,但有一个细节:不同设备上相同字号的实际渲染宽度会有细微差异,导致卡片里数字不是绝对居中。如果只是做展示组件影响不大,但如果要做成可复用组件,建议在onReady里动态测量文本宽度。

const metrics = this.ctx.measureText(digit.toString()) const digitWidth = metrics.width const startX = (w - digitWidth) / 2

用measureText动态计算起点,再交给fillText绘制,而不是依赖textAlign: 'center',这样在任何字体、任何缩放比例下都能精确居中。顺便一提,如果卡片内容包含冒号分隔符、日期、星期等扩展文本,这个方案尤其重要,因为冒号和数字的宽度比例在不同字体下差异很大。

4.4 页面退场与定时器清理

这是个容易被忽视的问题。如果你的翻页时钟组件放在某个页面里,用户退出页面后,如果定时器没有清理,会在后台继续触发@State更新,轻则报错,重则内存泄漏。

我建议在组件的aboutToDisappear里统一清理所有定时器和动画帧回调:

aboutToDisappear() { if (this.displaySync) { this.displaySync.cancelAnimationFrame() // 如果有类似API } if (this.timerHandle) { clearTimeout(this.timerHandle) } }

同时,在组件的aboutToAppear里重新初始化时间模型。不要指望页面缓存机制替你兜底,自己做清理最稳妥。

5. 组件扩展与设计延伸

5.1 从翻页时钟延伸到番茄钟

计时器功能如果只做“倒计时到0”就结束了,其实没有完全发挥翻页组件的视觉优势。我做了一个扩展:把翻页时钟和番茄钟工作流绑定,在倒计时过程中每一秒的翻页动画都保留,但把字体颜色从白色切换成暖橙色;当倒计时进入最后5秒时,卡片背景改成高亮色,翻页频率不变,触发回调提醒用户。

这个扩展在代码上的成本很低,因为我的状态机早就把RUNNING、PAUSED、COMPLETED分离好了,UI层只需要根据状态切换主题参数。但对使用场景的拓宽却非常明显——翻页时钟不再只是桌面装饰,而是变成了真正的效率工具。

5.2 主题化与参数开放

一个组件做完并跑通后,我强烈建议把以下几类参数开放出去:

  • 数字颜色、背景颜色、翻页持续时间、翻页缓动曲线。
  • 卡片圆角、字体比例、中缝阴影强度。
  • 是否显示冒号闪烁、是否显示日期、是否开启整点动画效果。

这些参数全部做成@Prop或@Param,供外部使用者在构造组件时传入。我实现时用了一个FlipTheme接口来承载这些配置项,默认给一套“类fliqlo”的黑白配色,外部传参则覆盖默认值。

参数开放的意义不只是方便别人用,更能逼着你重新审视代码结构。如果某个参数不能通过外部配置生效,多半说明这个参数被硬编码在了绘制函数深处,这样的组件是不合格的自绘组件。

6. 参赛与组件设计的一些体会

最后聊点实在的。参加组件开发征集活动,我最大的感受是:评委真正在意的不是你堆了多少炫酷API,而是组件设计有没有清晰的边界、状态管理是否自洽、性能有没有经过真实验证。我的翻页时钟和计时器组件,在技术上并没有用到太高深的东西,靠的就是“绘制顺序正确、状态转换严密、动画时序可控”这三点。

如果你现在也想做一个类似的组件,我的建议是:先给自己定一个“完成标准”,比如“从屏幕外看3秒能认出来是翻页时钟”或者“连续运行30分钟,倒计时误差不超过1秒”。有了这种可验证的完成标准,开发过程中你就不会在细节里迷失方向。

翻页时钟这个品类,做得越深入越会发现,它的每个细节都是可以打磨的:翻页中缝的光影过渡、数字字重的细微差异、偶数小时切换时的动画节奏、秒数变化瞬间新旧数字的重叠感——每一项单独拎出来都够写一篇文章。但核心骨架无非就是“数字状态模型+Canvas翻转绘制+逐帧动画调度”。骨架立住了,后面怎么扩展都是加分项。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/9 18:40:45

原生JS响应式悬浮客服插件:从定位布局到交互避坑全解析

简介&#xff1a;js响应式网站右侧悬浮在线客服插件是一份面向网页开发者的轻量级前端工具包&#xff0c;用于在网站右侧添加一个随屏幕滚动保持可见的在线客服浮层&#xff0c;解决PC与移动端自适应展示客服入口的问题。压缩包体积仅12KB&#xff0c;共包含3个文件&#xff1a…

作者头像 李华
网站建设 2026/10/9 18:39:37

Flutter 在 OpenHarmony 上实现高对比度 UI 的完整实践指南

前阵子把一套 Flutter 应用的主界面迁移到 OpenHarmony 电视盒子上&#xff0c;遇到一个特别尴尬的画面&#xff1a;同一个界面模板&#xff0c;在 Android 模拟器上挺清楚&#xff0c;一上电视&#xff0c;浅色背景上的浅灰色说明文字几乎直接消失。家里老人想看清楚操作提示&…

作者头像 李华
网站建设 2026/10/9 18:35:47

Navicat for MySQL 实战指南:连接、同步、备份与排错全解析

简介&#xff1a;Navicat for MySQL是一款专为MySQL设计的图形化数据库管理工具&#xff0c;适用于数据库管理员、后端开发者和数据分析人员&#xff0c;可显著简化日常建库、建表、查询、备份与同步等操作。该压缩包共30个文件&#xff0c;大小20.21MB&#xff0c;内含主程序、…

作者头像 李华
网站建设 2026/10/9 18:34:48

Linux命令行开发工具链全解析:GCC、GDB、Make与Git实战

刚把上一篇的编辑器和终端基础过完&#xff0c;这篇直接进入正题&#xff1a;真正写代码、编代码、调代码时每天都要碰的那套工具链。我见过太多新手卡在“代码写好了但不知道下一步干嘛”的状态&#xff0c;其实Linux下的开发流程非常固定&#xff0c;无非就是编辑、编译、调试…

作者头像 李华