资讯动态

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

发布时间:2026/10/9 18:41:11 来源:尧图企业网站定制
这些年我做过不少组件也折腾过各种时钟类应用但翻页时钟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矩形区域根据翻转阶段显示当前数字或下一个数字。阴影与中缝卡片中间横向会有一条深色压痕用来模拟纸张翻折的立体感。绘制时有个关键点必须先整体裁剪再独立旋转。以翻页进行到下半区展开为例先裁剪出下半区的矩形区域。保存画布状态。将坐标系沿下半区顶边平移到该边中点然后旋转angle度angle从90度逐步降到0度。在旋转后的坐标系里绘制字符next。恢复画布状态。这样做的效果是字符是“贴”在翻起的纸片上的会随纸片一起旋转出现而不是生硬地直接画在固定位置。对比一下直接用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帧肉眼感知非常明显。图层闪烁的问题比较隐蔽。有时翻页到后半段下半区会闪一下旧数字的残影原因是在flipProgress0.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翻转绘制逐帧动画调度”。骨架立住了后面怎么扩展都是加分项。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑