鸿蒙HarmonyOS滚字组件实战 —— 逐字 Slot Roll,从浏览器到原生 ArkUI
一、前言:短文本为什么值得单独做一个组件?
先看业务里最常见的三类短文案:
|
场景 |
例子 |
用户期望 |
|---|---|---|
|
状态切换 |
Copy → Copied → Copy |
相同前缀静止,尾部滚入/滚出 |
|
数字变化 |
1 → 12 → 123 |
只动新增位,顺序绝不反转 |
|
即时反馈 |
Saved / Failed / Retrying |
可 flash 一下再回到原值 |
如果直接:
Text(this.status) // 字符串一换,整段硬切
问题很明显:
-
没有时间感——状态变了,但用户感知不到「过程」;
-
无法局部更新——
Ready→Completed时前几个字母其实可以不动; -
宽度突变——
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
比例字体下,W 和 i 宽度差巨大。每个槽必须:
-
用与渲染一致的
fontSize / fontWeight / fontFamily测宽; -
新增字符:先展开槽宽再入场;
-
删除字符:出场进行大半后,再收缩槽宽。
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,落定清理地狱。
正确做法:
-
动画启动时为每个 Cell 算好 确定性
delay / duration / tilt(bounce 扰动按 index 哈希,不按帧随机); -
setInterval(16ms)只更新frameTime; -
渲染时用纯函数
animationProgress(now, start, delay, duration)求进度; -
到
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 |
行为 |
|---|---|
|
|
有动画时先立刻落定当前目标,再开新目标 |
|
|
当前动画播完,只保留最后一个不同 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 不只是「消警告的字符串」,它决定节点状态所有权和插入语义。
修复演进:
-
永久
slot-${index}→ 反序没了,但旧 Cell 被复用; -
最终:每轮新建 Cell 模型 + generation key +
ForEach保序; -
同一动画帧序列内 key 稳定,下一次文本状态才换代。
// 概念:generation 随 settle / startAnimation 递增
new SlotTextCell(`animation-${generation}-${index}`, ...)
new SlotTextCell(`settled-${generation}-${index}`, ...)
8.3 未变化字符重复入场
现象:1 → 2 后再追加成 23,期望只有 3 动,实际 2 和 3 一起动。
根因:永久位置 key 让渲染节点复用上一轮 Cell;逻辑层新模型已标 changed=false,节点仍持有旧 changed=true。
修复:每次落定和每次启动动画都新 generation;frame 更新不换 key;ForEach 按源数组顺序渲染。
回归必测(只测完全不同的单词发现不了):
|
场景 |
期望 |
|---|---|
|
|
顺序正确;每次只新增尾字符入场 |
|
|
第一次只有 2;第二次 2 静止、3 入场 |
|
|
相同前缀静止;尾槽按 width timing 伸缩 |
|
快速连点 + interrupt |
最终显示最后输入 |
|
快速连点 + queue |
播完当前,只播最后一个 pending |
|
中文 / |
不拆用户感知字符 |
九、@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 |
入场用 |
|
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。 -
MeasureUtils与Text使用同一套字体参数。 -
仅用于单行短标签 / 数字 / 状态,不用于正文或连写脚本段落。
-
出栈清理 timer;减少动态效果传
reduceMotion。 -
父页面可变文本直接
SlotText({ text: this.xxx }),禁止经快照 Builder 中转。 -
列表用 generation key + 保序容器;不要整批换 key 又依赖插入顺序。
-
回归:
1→12→123、1→2→23、Copy/Copied、interrupt/queue、中文与 emoji。
十二、调试顺序(出问题按这个查)
-
动画不触发:
@Watch text是否收到变化?父页面是否经普通 Builder 传动态字符串? -
顺序错误:源 Cell 数组顺序是否正确?若数组对、UI 反,查动态节点容器与批量 key 替换。
-
旧字符重播:unchanged 是否走静态分支?节点是否复用上一轮模型?
-
控件不刷新:视觉表达式是否直接读
this.@State? -
宽度跳动:测宽与渲染字体参数是否一致?新增/删除 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 数值同步。你可以直接复用四文件作为鸿蒙项目的滚字基线,再按品牌色与预设微调节奏。
更多推荐





所有评论(0)