鸿蒙HarmonyOS矩阵加载动效实战 —— 从「假旋转」到可复用的波前反馈组件
一、前言:鸿蒙里「Loading」为什么难做对?
先看三个真实场景:
-
即时通讯:对方正在输入 / 消息发送中,气泡旁需要一个不抢戏、但可感知的进度反馈。
-
AI 对话:模型思考阶段,用户盯着空白区域几秒,没有节奏感就会觉得卡死。
-
设置页局部刷新:某个开关提交中,不需要全屏遮罩,只需要一个小矩阵呼吸一下。
系统 LoadingProgress 能用,但样式单一、品牌感弱。手写「旋转圆环」看起来炫,却有三个硬伤:
|
问题 |
表现 |
|---|---|
|
信息密度低 |
圆上只有一条高光在转,深色背景下几乎看不清 |
|
与布局脱节 |
圆环是独立几何体,很难和 3×3 / 5×5 的网格内容对齐 |
|
Web 思维迁移失败 |
CSS 的负 |
E014 的研究问题因此变成:
能否在鸿蒙 ArkUI 上做一个可调、可测、可清理的矩阵波前加载器,而不是「看起来像网页 demo」的一次性特效?
答案是:可以。关键在于把「颜色采样」「空间距离」「透明度脉冲」「组件生命周期」拆开,用纯函数 + 共享时间轴组装,而不是在 build() 里堆一堆 animateTo。
二、核心心智模型:不是旋转,是波前
2.1 一句话定义
gradient stops + grid geometry + wave order
-> phase
-> one timeline
-> opacity curve
-> matrix feedback
每个 Cell 并不是「在转」。它做的是:
-
根据自己在矩阵中的位置算出 距离(wave order);
-
距离归一化成 相位 phase;
-
一条共享时间轴
frameTime % period推过所有 Cell; -
用
pulseOpacity(timeline, phase, dim)算出当前透明度。
这等价于浏览器里给每个格子设不同的负 animation-delay,但更适合 ArkUI 的状态驱动模型:只有一个 timer,没有 N 个 Cell 各开一个动画。
2.2 为什么「一出现就有形态」?
如果所有 Cell 都从 opacity = 0 一起启动,用户会看到「先铺满再闪一下」——这是廉价感的来源。
正确做法是让每个 Cell 带着相位进入循环:
// 伪代码:phase 由距离决定,组件首次出现就处于稳定态
const phase = order.max === 0 ? 0 : order.distance / (order.max + 1);
const opacity = pulseOpacity((now % period) / period, phase, dim);
这样矩阵第一次渲染就已经有波前形状,不需要冷启动填充。
2.3 透明度曲线
E014 的脉冲曲线分成三段(亮起 → 压暗 → 再亮起):
export function pulseOpacity(timeline: number, phase: number, dim: number): number {
const local: number = ((timeline - phase) % 1 + 1) % 1;
if (local <= .45) return 1 + (dim - 1) * (local / .45); // 从亮到 dim
if (local <= .92) return dim; // 停留在 dim
return dim + (1 - dim) * ((local - .92) / .08); // 快速回亮
}
dim 控制「波谷有多暗」。默认 0.1 时对比明显;调到 0.4 更柔和,适合深色聊天气泡旁的弱反馈。
三、四种波前模式:距离函数决定观感
组件公开四种 GradientSpinPattern:
|
模式 |
距离语义 |
适合场景 |
|---|---|---|
|
ArrowUp |
自下而上的箭头波(含中轴对称) |
默认 Loading、发送中 |
|
Diagonal |
对角线推进 + 尺寸感知周期 |
稍大矩阵、品牌色展示 |
|
Snake |
蛇形路径扫描 |
强调「扫过」的过程感 |
|
Ripple |
切比雪夫距离从中心扩散 |
刷新完成、涟漪反馈 |
3.1 ArrowUp / Ripple:几何距离
// ArrowUp:从底部中轴向上聚拢
distance = (rows - 1 - row) + abs(col - centerCol)
// Ripple:切比雪夫距离(正方形波前)
distance = max(abs(row - centerRow), abs(col - centerCol))
这两种模式在 3×3 和 9×15 上都自然,不需要额外扰动。
3.2 Diagonal / Snake:不要机械复制上游的 4 相位
上游 gradient-spin 对 diagonal / snake 使用四相位分带。源码一致时循环连续,但在鸿蒙设备上的大矩阵会出现明显的 4×4 同步拼贴——看起来像「瓷砖在闪」,而不是「波在走」。
E014 的产品取舍:
-
保留 Diagonal 的向下方向、Snake 的蛇形路径;
-
替换 固定 4 相位为「随网格尺寸变化的周期 + 确定性空间扰动」;
-
扰动由坐标哈希生成,不随帧变化——同一配置可复现,不会闪烁。
function spatialJitter(row: number, col: number): number {
const noise: number = Math.sin((row + 1) * 12.9898 + (col + 1) * 78.233) * 43758.5453;
return (noise - Math.floor(noise)) - .5;
}
// Diagonal:尺寸感知 band + 稳定抖动
const band = adaptiveBand(rows, columns, false);
const distance = ((row + col) % band) + spatialJitter(row, col) * .56;
工程原则:上游事实、设备观察、最终产品取舍要分开写进文档。为了「和网页像素级一致」牺牲真机观感,在鸿蒙产品里通常不值得。
3.3 审阅矩阵尺寸
只看 3×3 无法发现周期拼贴。E014 要求至少三档:
|
尺寸 |
能看到什么 |
|---|---|
|
3×3 |
默认 Loading 观感、相位是否合理 |
|
5×5 |
波前是否连贯 |
|
9×15 |
Diagonal/Snake 是否出现机械拼贴、性能是否可接受 |
四、色彩:为什么用 OKLab 而不是 sRGB 插值
矩阵每个 Cell 需要从渐变色带上取样。常见错误是直接对 #RRGGBB 的 R/G/B 通道做线性插值——跨色相时会经过发灰、发脏的中间色。
E014 把色带采样做成纯函数,在 OKLab 空间插值后再转回 sRGB hex:
export function sampleGradient(stops: Array<GradientStop>, t: number): string {
// 1. 按 position 排序
// 2. 找到 t 所在的上下 stop
// 3. hex → OKLab 插值 → hex
const from = hexToOklab(lower.color);
const to = hexToOklab(upper.color);
return oklabToHex(
from.l + (to.l - from.l) * mix,
from.a + (to.a - from.a) * mix,
from.b + (to.b - from.b) * mix
);
}
组件内置 8 套色带:sunrise / bubble / peach / tonic / mint / spring / twilight / bay。也可以通过 customStops 注入品牌色:
GradientSpin({
customStops: [
new GradientStop('#0A59F7', 0),
new GradientStop('#64D2FF', 0.55),
new GradientStop('#A0E7E5', 1)
],
pattern: GradientSpinPattern.Ripple,
rows: 5,
columns: 5
})
colorBy 有两种分配策略:
|
colorBy |
含义 |
|---|---|
|
Row |
渐变自上而下按行铺设 |
|
Path |
按波前顺序(distance 排名)分配色样 |
Row 更适合「色带条」观感;Path 更适合「颜色跟着波走」。
五、ArkUI 组件实现干货
5.1 公共 API
@Component
export struct GradientSpin {
@Prop preset: GradientPresetName = GradientPresetName.Sunrise;
@Prop customStops: Array<GradientStop> = [];
@Prop pattern: GradientSpinPattern = GradientSpinPattern.ArrowUp;
@Prop rows: number = 3;
@Prop columns: number = 3;
@Prop cellSize: number = 4; // vp
@Prop cellGap: number = 2;
@Prop cellRadius: number = 1;
@Prop period: number = 750; // ms,波前周期
@Prop dim: number = .1; // 波谷透明度
@Prop colorBy: GradientColorBy = GradientColorBy.Row;
@Prop label: string = 'Loading'; // 无障碍文案
@Prop playing: boolean = true;
@Prop respectReducedMotion: boolean = true;
@Prop reduceMotion: boolean = false;
}
最小接入:
// 发送中:3×3 默认 sunrise
GradientSpin({ playing: this.sending })
// AI 思考:稍大一点 + Ripple
GradientSpin({
rows: 5,
columns: 5,
cellSize: 6,
pattern: GradientSpinPattern.Ripple,
preset: GradientPresetName.Twilight,
period: 900,
playing: this.thinking
})
5.2 共享时间轴,不要每个 Cell 一个 timer
private start(): void {
if (!this.playing || (this.respectReducedMotion && this.reduceMotion) || this.timer >= 0) {
return;
}
this.frameTime = Date.now();
// 16ms ≈ 60fps;32ms 时 Diagonal/Snake 会显台阶感
this.timer = setInterval(() => {
this.frameTime = Date.now();
}, 16);
}
private stop(): void {
if (this.timer >= 0) {
clearInterval(this.timer);
this.timer = -1;
}
}
aboutToAppear(): void { this.start(); }
aboutToDisappear(): void { this.stop(); }
生命周期铁律:
-
Loading 结束:
playing = false或直接移除组件; -
页面不可见 / 出栈:
aboutToDisappear必须clearInterval; -
减少动态效果:宿主传入
reduceMotion = true,组件停表并显示稳定opacity = 0.6。
当前 E014 不会自动读系统「减少动态效果」开关,由宿主显式传入——这是有意的边界,避免组件偷偷耦合设备设置 API。
5.3 布局:Column → Row → Cell,不要用可滚动 Grid
踩坑结论:
|
方案 |
问题 |
|---|---|
|
ArkUI |
没有 CSS |
|
Column + Row + Cell |
尺寸完全可控,无滚动副作用 |
build() {
Column({ space: this.cellGap }) {
Repeat<GradientSpinRow>(buildGradientSpinRows(...))
.each((rowItem: RepeatItem<GradientSpinRow>) => {
Row({ space: this.cellGap }) {
Repeat<GradientSpinCell>(rowItem.item.cells)
.each((cellItem: RepeatItem<GradientSpinCell>) => {
Column()
.width(this.cellSize)
.height(this.cellSize)
.borderRadius(this.cellRadius)
.backgroundColor(cellItem.item.color)
.opacity(this.computedOpacity(cellItem.item))
})
.key((cell: GradientSpinCell) => cell.key)
}
})
.key((row: GradientSpinRow) =>
`${this.preset}-${this.pattern}-${this.colorBy}-${this.rows}x${this.columns}-${row.index}`)
}
}
5.4 ForEach 稳定 key vs Repeat:动态矩阵该选谁?
这是鸿蒙自定义动效组件里非常关键的一条:
-
ForEach+ 稳定 key:节点复用强,适合列表业务状态;但对「每帧改 opacity、拖动 Slider 改行列」的矩阵,容易出现旧 Cell 残留 / 属性不同步。 -
Repeat:更适合把新的时间状态和 Cell 值送进动态节点,避免旧矩阵粘在 UI 上。
Cell identity 至少应编码:
preset + pattern + colorBy + rows + columns + row + column
这样改 preset、拖 rows/columns 时,旧节点不会被错误复用。
5.5 算法与视图分层
GradientSpinCore.ets 纯函数:色带、OKLab、距离、相位、透明度
GradientSpinModels.ets 枚举与数据模型
GradientSpin.ets ArkUI 生命周期、时间轴、渲染
GradientSpin.test.ets 锁定纯函数行为(不依赖 UI 树)
为什么分层:图形算法一旦写在 build() 或 @Builder 里,就几乎无法单测。E014 把 Core 抽成纯函数后,波形、扰动、色带都可以在无设备环境下锁定。
六、Lab 页与局部主题:HDS 标题栏别抄错 token
E014 Lab 支持页面局部明暗,用来审阅矩阵在深浅色下的观感。这里有一个容易带进业务的坑:
局部 dark 页面如果继续套用全局 Theme Engine 的 HDS 标题栏渐变/模糊 token,会出现「浅色遮罩叠在深色内容上」的灰黑过渡带。
正确做法:
-
仍可用
HdsNavDestination拿统一的返回箭头与标题结构; -
标题、菜单、返回按钮、分隔线、背景全部读本页色板;
-
关闭会从全局主题采样的 immersive gradient / blur 遮罩;
-
图标用系统 Symbol,避免 Unicode 在设备字体缺失时变成方块。
控制面板也要注意:period、dim、行列 Slider 的数值与选中态直接读 @State,不要把可变基本值塞进缓存式 @Builder 参数(否则会出现「逻辑改了、高亮没变」——E015 里会更系统地讲这条规则)。
七、接入清单(可直接当 PR Review Checklist)
-
复制
GradientSpin.ets/GradientSpinCore.ets/GradientSpinModels.ets,保持同目录。 -
用
GradientSpin({ ... })传入行列、色带、pattern、速度;默认 3×3、4vp、750ms、sunrise、ArrowUp。 -
Loading 结束设
playing=false或移除组件;出栈时时间轴必须停。 -
减少动态效果时传
reduceMotion=true。 -
自定义
GradientStop的position落在0..1,避免无序/重复。 -
在 3×3、5×5、9×15 真机各看一遍 Diagonal / Snake。
-
矩阵用
Column → Row → Cell,不用可滚动Grid。 -
动态 Cell 用
Repeat;identity 包含几何与配置,结构变化不复用旧节点。 -
Diagonal/Snake 的扰动必须坐标确定,禁止每帧
Math.random()。
八、踩坑速查
|
现象 |
根因 |
修复 |
|---|---|---|
|
矩阵一列排开 / 出现灰滚动条 |
误用可滚动 |
改 |
|
拖动行列后旧点残留 |
ForEach 稳定 key 复用 |
改 |
|
Diagonal/Snake 像 4×4 瓷砖 |
上游四相位在大矩阵暴露 |
尺寸感知 band + 稳定 jitter |
|
动效发僵、有台阶 |
时间轴 32ms 过粗 |
16ms 共享时间轴 |
|
页面返回后仍在跑 |
未清理 timer |
|
|
标题栏灰黑带 |
局部 dark 套用全局 HDS 渐变 token |
标题栏读本页色板 |
|
状态栏 / TabBar 遮挡 |
Lab 未处理沉浸与遮罩 |
与 AppShell 协同,进出页恢复 TabBar |
九、已知边界
-
当前是约 60fps 的 ArkUI 状态时间轴,不是浏览器 CSS compositor 的逐帧等价实现。
-
OKLab / 波形纯函数有单测;帧率、功耗、后台恢复仍需专项仪器化,不在本轮「已证明」结论内。
-
系统 reduce motion 需宿主传入,组件不偷偷读设备设置。
-
超大矩阵(例如 20×20)会放大状态刷新成本,业务上应优先小矩阵 + 语义清晰的 pattern。
十、写在最后
鸿蒙上的 Loading 反馈,不应该只是「转圈圈」的默认选项。矩阵波前提供了更高的信息密度和品牌可塑性,但只有把下面几条做对,才算工程组件而不是 demo:
相位代替负 delay,一轴驱动全矩阵。
算法纯函数可测,视图只管生命周期。
布局用 Column/Row,动态节点用 Repeat。
源码一致不是目标,真机观感才是产品。
playing / reduceMotion / aboutToDisappear 三件套,动效才能干净退出。
本文基于 ArkUILab 项目 E014 实验。它不是最终产品形态,但已经是一条可构建、可真机 review、可复用到聊天/AI/设置页的鸿蒙基线。如果你正在做自定义 Loading,可以直接拷贝三文件起步,再按品牌色带和矩阵尺寸微调。
更多推荐




所有评论(0)