一、前言:短文本为什么值得单独做一个组件?

先看业务里最常见的三类短文案:

场景

例子

用户期望

状态切换

Copy → Copied → Copy

相同前缀静止,尾部滚入/滚出

数字变化

1 → 12 → 123

只动新增位,顺序绝不反转

即时反馈

Saved / Failed / Retrying

可 flash 一下再回到原值

如果直接:

Text(this.status) // 字符串一换,整段硬切

问题很明显:

  1. 没有时间感——状态变了,但用户感知不到「过程」;

  2. 无法局部更新——ReadyCompleted 时前几个字母其实可以不动;

  3. 宽度突变——i 换成 W、中文换成英文,布局会跳一下。

Web 上的 slot-roll 解决思路是:每个用户可感知字符一个裁剪槽(slot),旧字退出、新字进入,槽宽独立过渡。E015 的目标不是把 CSS 逐行翻译成 ArkUI,而是在鸿蒙上保留同一套行为契约:

text -> grapheme segments -> measured slots
     -> deterministic timing + width timing
     -> one ArkUI timeline -> settle / queue / flash

二、公共 API:声明式 text + 命令式 Controller

2.1 组件属性

@Component
export struct SlotText {
  @Prop text: string = '';
  @Prop rollDirection: SlotTextDirection = SlotTextDirection.Down; // 注意:不能叫 direction
  @Prop stagger: number = 45;      // 逐字延迟 ms
  @Prop duration: number = 300;    // 单字动画时长
  @Prop exitOffset: number = 50;   // 入场相对出场的错峰
  @Prop bounce: number = .6;       // 弹性 / 倾斜扰动强度
  @Prop colorMode: SlotTextColorMode = SlotTextColorMode.None; // None | Solid | Chromatic
  @Prop enterColor: ResourceColor = '#3478F6';
  @Prop colorFade: number = 280;
  @Prop skipUnchanged: boolean = true;
  @Prop interrupt: boolean = true;
  @Prop playing: boolean = true;
  @Prop respectReducedMotion: boolean = true;
  @Prop reduceMotion: boolean = false;
  @Prop fontSize: number = 18;
  @Prop fontWeight: number | FontWeight = FontWeight.Medium;
  @Prop fontColor: ResourceColor = '#1C1C1E';
  @Prop fontFamily: string = '';
  @Prop controller: SlotTextController = new SlotTextController();
}

最小用法:

@State status: string = 'Copy';

// 声明式:改 text 就滚
SlotText({
  text: this.status,
  fontSize: 20,
  fontWeight: FontWeight.Medium,
  rollDirection: SlotTextDirection.Down
})

// 某处业务
this.status = 'Copied';

2.2 为什么属性叫 rollDirection 而不是 direction

鸿蒙 ArkUI 的 direction所有组件继承的布局 attribute(LTR/RTL 相关)。自定义组件如果再用同名 @Prop direction,会和框架属性冲突或产生难查的异常行为。

规则:公共参数命名为 rollDirection,内部再映射到动画方向枚举 Up / Down

2.3 Controller:set / flash / finish

声明式适合「状态即文案」;命令式适合「临时反馈后回退」:

export class SlotTextController {
  set(text: string, options?: SlotTextOptions): void;
  flash(text: string, options?: SlotTextOptions, revertAfter: number = 1400): void;
  finish(): void;
}
private controller: SlotTextController = new SlotTextController();

SlotText({
  text: this.label,
  controller: this.controller,
  colorMode: SlotTextColorMode.Solid,
  enterColor: '#34C759'
})

// 保存成功:闪一下 Saved,1.4s 后回到原 label
this.controller.flash('Saved');

// 明确改成新稳态(会取消 flash 回退)
this.controller.set('All changes saved');

// 立刻落定当前目标,停掉动画
this.controller.finish();

flash 行为契约(与上游一致):

  • 默认 revertAfter = 1400

  • 重复 flash 会重置回退计时;

  • 显式 set 取消回退,避免旧 timer 把新文案盖回去。

2.4 动画预设

Preset

duration

stagger

bounce

气质

Default

300

45

0.6

通用

Smooth

480

35

0.12

柔和、状态页

Snappy

190

25

0.25

快捷、工具栏

Bouncy

430

55

1.0

活泼、营销感


三、架构分层:纯 Core + 组件生命周期 + Controller 意图

Page @State / Controller command
  -> requestText(target, resolved options)
  -> settle current animation or retain latest pending target
  -> segment current + target into graphemes
  -> MeasureUtils width + deterministic glyph/width timing
  -> fresh render generation of ordered Cell models
  -> frameTime drives transform / width / color overlay
  -> settle target, clear timers, optionally play latest pending target

职责边界:

职责

不做什么

SlotTextCore

字素分割、option 解析、timing、progress、chromatic

不碰 ArkUI

SlotText

timer、settled/target/pending、generation、measure、渲染

不把业务状态存 Controller

SlotTextController

派发 set/flash/finish

不持有 Cell、不持有 timer

这样单测可以覆盖 Core;组件只验证生命周期和命令语义。


四、干货一:字素分割 —— 字符串下标 ≠ 用户看到的字符

鸿蒙业务里经常出现 emoji、中文、组合字符。如果按 for (i...) text[i] 切,会拆坏:

  • 代理对(辅助平面字符)

  • combining mark(如 e + ́)

  • variation selector

  • emoji 肤色 modifier

  • ZWJ 序列(家庭 emoji 等)

E015 自实现轻量 grapheme 合并(不依赖浏览器 Intl.Segmenter):

export function segmentSlotText(text: string): Array<string> {
  // 1. 读 code point(处理代理对)
  // 2. 吞掉 extension:combining / VS / skin tone 等
  // 3. 遇到 ZWJ 继续吞后续 code point + extension
  // 4. 输出用户可感知的一段一段 glyph
}

组件定位因此很明确:

单行短标签、数字、状态、命令——不是正文排版器。

逐字符拆槽会天然丢掉 kerning、ligature、连写脚本的整词 shaping。阿拉伯文、天城文等按孤立字素渲染,不保证整词造型。正文请继续用系统 Text


五、干货二:MeasureUtils 测宽 + 独立 width timing

比例字体下,Wi 宽度差巨大。每个槽必须:

  1. 用与渲染一致的 fontSize / fontWeight / fontFamily 测宽;

  2. 新增字符:先展开槽宽再入场;

  3. 删除字符:出场进行大半后,再收缩槽宽。

private measureGlyph(glyph: string): number {
  if (glyph.length === 0) return 0;
  const content: string = glyph === ' ' ? '\u00A0' : glyph; // 空格可测
  const px: number = this.getUIContext().getMeasureUtils().measureText({
    textContent: content,
    fontSize: this.fontSize,
    fontWeight: this.fontWeight,
    fontFamily: this.fontFamily
  });
  return Math.max(.5, this.getUIContext().px2vp(px));
}

宽度时序(Core 纯函数):

export function widthTiming(previous: string, next: string, timing: SlotGlyphTiming): SlotWidthTiming {
  if (next.length === 0) {
    // 删除:偏晚收缩
    return new SlotWidthTiming(Math.max(140, Math.round(timing.duration * .6)),
      timing.delay + Math.round(timing.duration * .55));
  }
  if (previous.length === 0) {
    // 新增:较早展开
    return new SlotWidthTiming(Math.max(140, Math.round(timing.duration * .45)), timing.delay);
  }
  return new SlotWidthTiming(timing.duration, timing.delay);
}

:测宽参数和 Text 渲染参数不一致 → 槽宽对了、字却溢出或留白。接入时务必同一套字体参数打通。


六、干货三:一条 16ms 共享时间轴

错误做法:每个字素 setTimeout + 多个 animateTo,落定清理地狱。

正确做法:

  1. 动画启动时为每个 Cell 算好 确定性 delay / duration / tilt(bounce 扰动按 index 哈希,不按帧随机);

  2. setInterval(16ms) 只更新 frameTime

  3. 渲染时用纯函数 animationProgress(now, start, delay, duration) 求进度;

  4. completesAt 统一 settle。

// 出场 / 入场错峰
private glyphProgress(cell: SlotTextCell): number {
  return animationProgress(this.frameTime, this.startedAt, cell.timing.delay, cell.timing.duration);
}
private incomingProgress(cell: SlotTextCell): number {
  return animationProgress(
    this.frameTime, this.startedAt,
    cell.timing.delay + this.activeOptions.exitOffset,
    cell.timing.duration
  );
}

单槽结构:Stack 裁剪 + 双 Text(旧字 / 新字)+ 可选彩色覆盖层:

Stack({ alignContent: Alignment.Center }) {
  // changed=false:静态 Text,无 offset / 无 color fade
  // changed=true:previous 退出 + next 进入 + 可选色层
}
.width(this.cellWidth(cell))
.height(this.fontSize * 1.3)
.clip(true)

skipUnchanged=true 时,相同索引且相同字素必须走静态分支——这是「1→2→23 时 2 不该再动」的不变量。


七、干货四:中断与排队(高频点击不会炸)

interrupt

行为

true(默认)

有动画时先立刻落定当前目标,再开新目标

false

当前动画播完,只保留最后一个不同 pending

private requestText(next: string, options: SlotTextOptions): void {
  const resolved = resolveSlotTextOptions(options);
  if (!this.playing || (this.respectReducedMotion && this.reduceMotion)) {
    this.settle(next);
    return;
  }
  if (this.frameTimer >= 0 && !resolved.interrupt) {
    // queue:只记最后目标,防止无限排队
    if (next === this.targetText) {
      this.pendingText = undefined;
    } else {
      this.pendingText = next;
      this.pendingOptions = options;
    }
    return;
  }
  if (this.frameTimer >= 0) {
    this.finishImmediately(); // interrupt:先落定
  }
  if (next === this.settledText) return;
  this.startAnimation(next, resolved);
}

原则interrupt=false 不是「无限排队」,而是「抗抖 + 只认最终态」。工具栏连点、Stepper 狂点都靠这个兜底。


八、真机修出来的三条致命问题

这三条是 E015 相对上游「移植成功」的分水岭,也是鸿蒙动态列表动效的通用教训。

8.1 动画完全不触发

现象:点 Roll next / 改输入后,文字瞬间变掉,没有滚动。

根因:Lab 把 this.text 先传给普通 @Builder LiveText(value: string),Builder 内部拿到的是调用时快照。子组件 @Prop text 收不到后续页面 @State 变化。

错误写法

@Builder LiveText(value: string) {
  SlotText({ text: value }) // value 可能永远是第一次传入的字符串
}

// build 里
this.LiveText(this.text) // 看起来响应式,设备上经常不是

正确写法

// 在实际 UI Builder 内直接读 this.@State
SlotText({ text: this.text })
SlotText({ text: this.customText })

规则:可变基本值不要经缓存式展示 Builder 中转。若必须复用结构,Builder 只收不会变的枚举/配置标识,再由实例方法读 this.@State

8.2 落定瞬间 12 变成 21

现象:输入 12 动画过程正确,落定瞬间变成 21;再输入变成 321

根因:动画态/落定态给所有字符换 Repeat key,批量销毁插入时,hand-off 出现反向插入顺序。列表 key 不只是「消警告的字符串」,它决定节点状态所有权和插入语义。

修复演进

  1. 永久 slot-${index} → 反序没了,但旧 Cell 被复用;

  2. 最终:每轮新建 Cell 模型 + generation key + ForEach 保序

  3. 同一动画帧序列内 key 稳定,下一次文本状态才换代。

// 概念:generation 随 settle / startAnimation 递增
new SlotTextCell(`animation-${generation}-${index}`, ...)
new SlotTextCell(`settled-${generation}-${index}`, ...)

8.3 未变化字符重复入场

现象1 → 2 后再追加成 23,期望只有 3 动,实际 23 一起动。

根因:永久位置 key 让渲染节点复用上一轮 Cell;逻辑层新模型已标 changed=false,节点仍持有旧 changed=true

修复:每次落定和每次启动动画都新 generation;frame 更新不换 key;ForEach 按源数组顺序渲染。

回归必测(只测完全不同的单词发现不了):

场景

期望

'' → 1 → 12 → 123

顺序正确;每次只新增尾字符入场

1 → 2 → 23

第一次只有 2;第二次 2 静止、3 入场

Copy → Copied → Copy

相同前缀静止;尾槽按 width timing 伸缩

快速连点 + interrupt

最终显示最后输入

快速连点 + queue

播完当前,只播最后一个 pending

中文 / / emoji / ZWJ

不拆用户感知字符


九、@Builder 响应式陷阱(鸿蒙状态管理深坑)

E015 Lab 还修过一类「看起来像框架 bug」的问题:

  • Words / Numbers / Status 点了,数据变了,高亮不保存

  • Slider 拖了,动画参数变了,右侧数字不刷新

  • Toggle 逻辑生效,开关 UI 仍是旧态

根因同一类:

// 危险 API 形态
@Builder Chip(title: string, active: boolean, action: () => void)
@Builder SliderRow(label: string, value: number, onChange: (v: number) => void)
@Builder ToggleRow(label: string, active: boolean, onChange: (v: boolean) => void)

普通 @Builder 的基本类型参数可能是调用时快照。事件回调改的是 this.@State,但显示分支仍读参数副本 → 写状态和读状态不在同一个可观察源

推荐模式

@Builder PresetChip(value: SlotTextPreset) {
  // 只传固定枚举;选中态在内部读 this.preset
  Text(this.presetLabel(value))
    .fontColor(this.preset === value ? Color.White : Color.Black)
    .onClick(() => { this.preset = value; })
}

@Builder StaggerSlider() {
  // 数值、label、onChange 都通过实例方法绑同一 @State
  Text(`${this.stagger}`)
  Slider({ value: this.stagger, min: 0, max: 100 })
    .onChange((v: number) => { this.stagger = v; })
}

口诀

事件写状态、视觉读状态,必须指向同一个 this.@State / this.@Prop
Builder 参数只放稳定身份(枚举、id),不放会变的 boolean / number / string 展示值。

这条规则对鸿蒙自定义组件 Lab 页、设置页、筛选条几乎通用——E014 控制面板同样遵守。


十、彩色入场与减少动态效果

10.1 colorMode

模式

行为

None

只用 fontColor

Solid

入场用 enterColor,再按 colorFade 淡回

Chromatic

按字符 index 生成彩虹色,再淡回

实现上是「基色 Text + 彩色覆盖层按 opacity 衰减」,而不是改系统 Text 的 color transition(ArkUI 侧更可控)。

10.2 playing / reduceMotion

if (!this.playing || (this.respectReducedMotion && this.reduceMotion)) {
  this.settle(next); // 或 finishImmediately
  return;
}
  • 页面不可见:移除组件或 playing=false

  • 减少动态效果:宿主传 reduceMotion=true(组件暂不自动读系统设置);

  • aboutToDisappear:清 frame timer、revert timer,并 controller.unbind()


十一、接入清单

  • 复制 SlotText.ets / SlotTextCore.ets / SlotTextModels.ets / SlotTextController.ets,同目录放置。

  • 常规入口用声明式 text;临时反馈才用 controller.flash

  • 方向属性用 rollDirection,不要命名为 direction

  • MeasureUtilsText 使用同一套字体参数。

  • 仅用于单行短标签 / 数字 / 状态,不用于正文或连写脚本段落。

  • 出栈清理 timer;减少动态效果传 reduceMotion

  • 父页面可变文本直接 SlotText({ text: this.xxx }),禁止经快照 Builder 中转。

  • 列表用 generation key + 保序容器;不要整批换 key 又依赖插入顺序。

  • 回归:1→12→1231→2→23、Copy/Copied、interrupt/queue、中文与 emoji。


十二、调试顺序(出问题按这个查)

  1. 动画不触发@Watch text 是否收到变化?父页面是否经普通 Builder 传动态字符串?

  2. 顺序错误:源 Cell 数组顺序是否正确?若数组对、UI 反,查动态节点容器与批量 key 替换。

  3. 旧字符重播:unchanged 是否走静态分支?节点是否复用上一轮模型?

  4. 控件不刷新:视觉表达式是否直接读 this.@State

  5. 宽度跳动:测宽与渲染字体参数是否一致?新增/删除 width timing 是否反了?


十三、已知边界

  • 单行短文本,不支持自动换行或段落。

  • 连写脚本不保证整词 shaping。

  • ArkUI 状态时间轴 ≠ 浏览器 compositor transition 的逐帧等价物。

  • 系统「减少动态效果」需宿主传入 reduceMotion

  • 持续大字号、长字符串滚字会放大 measure + 帧刷新成本——业务上应限制长度。


十四、写在最后

鸿蒙上的滚字组件,难点不在「Text 往上挪一点」,而在把浏览器里分散的 DOM/CSS 机制,收敛成符合 ArkUI 状态模型的一条管线:

字素是槽,不是 code unit。
一轴驱动 timing,不用每字一个 timer。
Controller 只管意图,Cell 与生命周期在组件内。
key 是状态所有权,generation 换代才能既不反序也不脏复用。
@Builder 不传可变基本值,读写真源必须是 this.@State。

记住场景边界:SlotText 服务的是短标签的时间感,不是排版引擎。Copy/Copied、计数、状态机文案用它会很漂亮;聊天正文、富文本、多行说明请用系统 Text。

本文基于 ArkUILab 项目 E015 实验。设备侧已验证并修复:动画触发、追加字符顺序、未变化字符静止、控件高亮与 Slider 数值同步。你可以直接复用四文件作为鸿蒙项目的滚字基线,再按品牌色与预设微调节奏。

Logo

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

更多推荐