引言

跑马灯(Marquee)是移动应用中一种常见的信息展示模式——文字在有限的空间内水平滚动,用于展示公告、快讯、通知等需要用户关注但又不占用太多屏幕空间的内容。从证券交易软件的行情滚动条到新闻客户端的头条快讯,从直播平台的弹幕滚动到智能家居面板的状态轮播,跑马灯效果无处不在。

然而在 HarmonyOS NEXT(API 24)中,早期版本存在的 .marquee() 属性方法已被移除,系统不再提供开箱即用的跑马灯 API。这意味着开发者需要自行实现这一效果。本文将拆解跑马灯的技术原理,使用 animateTo 显式动画与 onFinish 回调构建一套完整的自定义跑马灯系统,并在此基础上实现暂停/恢复、速度调节和内容切换等交互功能。

读完本文你将能够:

  • 理解跑马灯效果的底层实现原理
  • 掌握 animateTo + onFinish 回调实现循环动画的技巧
  • 学会控制动画生命周期(启动、暂停、恢复、销毁)
  • 了解如何构建可调节速度的动画系统
  • 获得一个生产可用的自定义跑马灯方案

跑马灯的技术原理

核心思路

跑马灯的本质是将一段文本在水平方向上匀速平移,当文本完全移出可视区域后,从起点重新开始循环。从动画的角度看,这是一个"平移 → 复位 → 平移"的无限循环:

帧 1:  [重要公告:今晚22:00-24:00将进行服务器...]  ← 起点,文本从右侧开始
帧 30:        [重要公告:今晚22:00-24:00将进行服务...]  ← 匀速左移
帧 60:                    [重要公告:今晚22:00-24:...]  ← 继续左移
帧 90:                                [重要公告:今晚...]  ← 即将移出
帧 91:  [重要公告:今晚22:00-24:00将进行服务器...]  ← 复位到起点,重新开始

为什么不用系统 Marquee?

在 HarmonyOS 的早期版本中,Text 组件提供了 .marquee() 属性方法,开发者只需配置 startsteploop 等参数即可获得跑马灯效果。但在 API 24 中,这个方法已被移除。可能的移除原因包括:

  1. 性能不可控:系统 Marquee 的运行机制是黑盒,开发者无法精确控制其渲染时机和资源消耗
  2. 定制能力有限:系统 Marquee 不支持中途修改速度、动态切换内容等高级需求
  3. 与新版动画系统冲突:API 24 的动画框架进行了重构,旧 Marquee 与新框架不兼容

失去系统 Marquee 并不意味着失去跑马灯效果。实际上,用 animateTo + translate 构建自定义跑马灯能获得更好的控制力和扩展性。

animateTo 循环动画的实现

animateTo 是 ArkUI 的显式动画 API。常规的 animateTo 调用在动画执行完毕后即结束,但通过 onFinish 回调,我们可以实现循环:

startMarquee(): void {
  if (!this.playing) {
    this.offset = 0;
    return;
  }
  let duration = this.getDuration();
  animateTo({
    duration: duration,
    curve: Curve.Linear,
    onFinish: () => {
      this.offset = 0;       // 复位
      this.startMarquee();   // 递归调用,启动下一轮
    }
  }, () => {
    this.offset = -this.scrollDistance;  // 向左平移
  });
}

这段代码的关键设计点:

  1. Linear 曲线:跑马灯需要恒定速度,任何缓动曲线都会导致视觉上的"加速/减速"感
  2. onFinish 中复位:动画完成时立即将 offset 归零,然后递归启动新一轮
  3. playing 守卫:在函数开头检查播放状态,为暂停功能提供控制点

动画生命周期的精细控制

自定义跑马灯的一个核心优势是可以在任意时刻打断并重启动画。由于 animateTo 的新调用会自动中断前一个动画,我们可以在速度变化、内容切换、暂停恢复时简单地重新调用 startMarquee()

changeSpeed(idx: number): void {
  this.speedLevel = idx;
  this.startTopMarquee();    // 新 animateTo 自动中断旧动画
  this.startSecondMarquee();
}

togglePlay(): void {
  this.playing = !this.playing;
  this.startMarquee();       // 暂停时 offset 置 0 并返回,播放时启动新动画
}

这比系统 API 灵活得多——无需等待当前滚动周期结束,状态变化立即可见。

计算动画时长

跑马灯的速度通常用"像素/秒"(px/s)来衡量,而非直接使用毫秒数。这样的好处是无论文本多长,滚动速度保持一致。动画时长需要通过以下公式计算:

duration = (scrollDistance / speed) * 1000

其中 scrollDistance 是文本需要滚动的总距离(像素),speed 是滚动速度(px/s)。

在本 Demo 中,我们定义了三个速度档位:

档位 速度 (px/s) 580px 对应时长 视觉感受
慢速 60 ~9.7s 适合较短的公告文字,阅读体验好
标准 100 ~5.8s 常规滚动速度,适合中等长度文本
快速 160 ~3.6s 适合需要快速浏览的长文本
private speeds: number[] = [60, 100, 160];
private scrollDistance: number = 580;

getDuration(speed: number): number {
  return (this.scrollDistance / speed) * 1000;
}

实战 Demo:公告快讯中心

本文的 Demo 是一个"公告快讯中心"页面,包含以下功能模块:

  1. 两条可独立控制的跑马灯:重要通知(红色主题)和一般公告(蓝色主题)
  2. 全局播放控制:一键暂停/恢复所有跑马灯
  3. 三段速度调节:慢速、标准、快速
  4. 快讯内容切换:系统维护 / 活动推广 / 安全提醒 三种预设内容
  5. 历史公告列表:展示已发布的公告记录

状态设计

@State topPlaying: boolean = true;       // 顶部跑马灯播放状态
@State secondPlaying: boolean = true;    // 第二条跑马灯播放状态
@State speedLevel: number = 1;          // 速度档位 (0/1/2)
@State contentIdx: number = 0;          // 内容模板索引
@State topOffset: number = 0;           // 顶部文本水平偏移
@State secondOffset: number = 0;        // 第二条文本水平偏移

跑马灯动画核心实现

两条跑马灯各自拥有独立的 startXxxMarquee() 方法,可以互不干扰地独立控制:

startTopMarquee(): void {
  if (!this.topPlaying) {
    this.topOffset = 0;
    return;
  }
  let dur = this.getDuration(this.getSpeed());
  animateTo({ duration: dur, curve: Curve.Linear, onFinish: () => {
    this.topOffset = 0;
    this.startTopMarquee();
  }}, () => {
    this.topOffset = -this.scrollDistance;
  });
}

在 UI 层,文本被包裹在 Row 中并通过 .translate() 绑定 offset:

Row() {
  Text(this.contents[this.contentIdx])
    .fontSize(13)
    .fontColor('#1a1a2e')
    .maxLines(1)
}
.translate({ x: this.topOffset, y: 0 })
.layoutWeight(1)
.clip(true)  // 裁剪超出部分,隐藏复位时的闪烁

交互控制

独立暂停/恢复:点击跑马灯卡片切换该条跑马灯的播放状态:

toggleTop(): void {
  this.topPlaying = !this.topPlaying;
  this.startTopMarquee();  // playing=false 时复位,true 时启动
}

全局播放控制:通过 Toggle 开关统一控制两条跑马灯:

toggleAll(val: boolean): void {
  this.topPlaying = val;
  this.secondPlaying = val;
  if (val) {
    this.startTopMarquee();
    this.startSecondMarquee();
  }
}

速度调节:选择速度后,立即中断当前动画并按新速度重启:

changeSpeed(idx: number): void {
  this.speedLevel = idx;
  this.startTopMarquee();     // 以新速度重新计算 duration 并启动
  this.startSecondMarquee();
}

内容切换:切换顶部跑马灯的展示文本:

changeContent(idx: number): void {
  this.contentIdx = idx;
  this.startTopMarquee();     // 新内容 + 复位 + 重新滚动
}

页面结构

整个页面的布局由 Scroll 包裹,包含五个白色卡片区域:

Header(标题栏:公告快讯 + 滚动状态徽章)
Scroll
├── 重要通知跑马灯卡片(红色边框 + 红色标签)
├── 一般公告跑马灯卡片(蓝色边框 + 蓝色标签)
├── 滚动控制卡片(Toggle 全部播放 + 速度三段选择)
├── 快讯内容卡片(内容模板三选一)
├── 历史公告卡片(四条带标签和时间的公告记录)
└── 底部状态栏(当前速度 + 引擎信息)

公告数据结构

class Announcement {
  title: string;
  content: string;
  time: string;
  tag: string;
  tagColor: string;

  constructor(title: string, content: string, time: string,
              tag: string, tagColor: string) {
    this.title = title;
    this.content = content;
    this.time = time;
    this.tag = tag;
    this.tagColor = tagColor;
  }
}

历史公告列表中的每条公告包含标题、摘要、发布时间和一个彩色标签(系统/版本/通知/安全),使用 ForEach 渲染:

ForEach(this.getAnnouncements(), (item: Announcement, idx: number) => {
  Row() {
    Column() {
      Text(item.title).fontSize(14).fontWeight(FontWeight.Medium)
      Text(item.content).fontSize(12).fontColor('#888899').maxLines(1)
    }
    .layoutWeight(1)
    
    Column() {
      Text(item.tag).fontSize(10).fontColor(item.tagColor)
        .backgroundColor(item.tagColor + '15')
      Text(item.time).fontSize(10).fontColor('#CCCCDD')
    }
  }
  .borderRadius(10).backgroundColor('#F8F9FA')
}, (item: Announcement) => item.title)

在这里插入图片描述
在这里插入图片描述

性能优化与注意事项

1. clip(true) 消除复位闪烁

当动画完成一轮、offset 从 -scrollDistance 瞬间复位到 0 时,文本会"闪现"回起点。通过给容器添加 .clip(true),超出容器的文本部分被裁剪,用户看不到复位瞬间的视觉效果。

2. Linear 曲线的必要性

跑马灯动画必须使用 Curve.Linear。任何非线性曲线都会导致文本在不同区段的速度不一致:开始时慢然后加速(EaseIn),或开始时快然后减速(EaseOut),这会破坏跑马灯"匀速滚动"的核心体验。

3. 动画中断与内存管理

每次调用 startMarquee() 都会创建一个新的 animateTo 动画实例。由于新动画会自动中断旧动画,不用担心动画堆积。但需要注意 onFinish 的递归调用链——如果暂停状态(playing = false),递归会在函数开头终止,不会无限循环。

4. 多跑马灯的独立控制

本例中两条跑马灯有各自独立的 playing 状态和 offset 值,可以独立暂停和恢复。在扩展更多跑马灯时,建议将每条跑马灯的状态封装到独立的数据结构中(如数组),避免为每条跑马灯写重复的状态变量和启动方法。

5. 响应式更新陷阱

在 ArkTS 中,@State 变量的变化会触发 UI 重新渲染。跑马灯动画中 offset 以 60fps 的频率更新(通过 animateTo 的插值),这意味着 UI 也会以 60fps 的频率局部更新。对于简单的 translate 变换,这种更新完全在 GPU 合成层完成,性能影响极小。但应注意不要在高频更新的动画循环中执行复杂的计算或布局操作。

扩展方向

掌握了基础的自定义跑马灯后,可以进一步探索:

  1. 无缝循环:通过复制文本内容实现真正无缝的循环(两个相同的文本块交替出现),消除复位时的视觉间断
  2. 双向滚动:利用 playMode: PlayMode.Alternate 实现文字在容器内来回弹跳
  3. 渐变遮罩:在容器两端添加透明度渐变遮罩,让滚动文字的进出更自然
  4. 手势联动:将滚动速度与手势拖拽速度绑定,实现跟手效果的跑马灯
  5. 垂直跑马灯:将 translate 的 x 轴改为 y 轴,实现垂直方向的文字滚动

总结

本文通过拆解跑马灯的技术原理,使用 animateTo + onFinish 递归回调构建了一套完整的自定义跑马灯系统。在没有系统 Marquee API 的情况下,我们不仅实现了一模一样的视觉效果,还获得了更强的控制力——独立暂停、速度调节、内容切换这些功能在自定义方案中只需寥寥几行代码即可实现。

这个实践也揭示了一个更普遍的规律:不要被 API 的存废所限制。当某个便捷 API 被移除时,理解其底层原理能让你用更基础的 API 重建它,而且通常能获得更好的灵活性和可控性。animateTo 是 ArkUI 中最基础的动画 API,但配合 onFinish、状态管理和数学计算,它可以衍生出跑马灯、呼吸灯、打字机效果等丰富多彩的动画模式。

从这个角度看,学习自定义跑马灯不仅是学习一个动画技巧,更是学习一种"用基础积木搭建高级效果"的思维方式——这对于任何平台的 UI 开发都至关重要。


Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐