一、前言:鸿蒙里「Loading」为什么难做对?

先看三个真实场景:

  1. 即时通讯:对方正在输入 / 消息发送中,气泡旁需要一个不抢戏、但可感知的进度反馈。

  2. AI 对话:模型思考阶段,用户盯着空白区域几秒,没有节奏感就会觉得卡死。

  3. 设置页局部刷新:某个开关提交中,不需要全屏遮罩,只需要一个小矩阵呼吸一下。

系统 LoadingProgress 能用,但样式单一、品牌感弱。手写「旋转圆环」看起来炫,却有三个硬伤:

问题

表现

信息密度低

圆上只有一条高光在转,深色背景下几乎看不清

与布局脱节

圆环是独立几何体,很难和 3×3 / 5×5 的网格内容对齐

Web 思维迁移失败

CSS 的负 animation-delaygrid-template-columns: repeat(...) 在 ArkUI 没有直接等价物

E014 的研究问题因此变成:

能否在鸿蒙 ArkUI 上做一个可调、可测、可清理的矩阵波前加载器,而不是「看起来像网页 demo」的一次性特效?

答案是:可以。关键在于把「颜色采样」「空间距离」「透明度脉冲」「组件生命周期」拆开,用纯函数 + 共享时间轴组装,而不是在 build() 里堆一堆 animateTo


二、核心心智模型:不是旋转,是波前

2.1 一句话定义

gradient stops + grid geometry + wave order
  -> phase
  -> one timeline
  -> opacity curve
  -> matrix feedback

每个 Cell 并不是「在转」。它做的是:

  1. 根据自己在矩阵中的位置算出 距离(wave order);

  2. 距离归一化成 相位 phase

  3. 一条共享时间轴 frameTime % period 推过所有 Cell;

  4. 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 Grid

没有 CSS repeat(N, 1fr) 这种轨道写法;还可能自带滚动,真机出现灰色滚动条

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,会出现「浅色遮罩叠在深色内容上」的灰黑过渡带。

正确做法:

  1. 仍可用 HdsNavDestination 拿统一的返回箭头与标题结构;

  2. 标题、菜单、返回按钮、分隔线、背景全部读本页色板

  3. 关闭会从全局主题采样的 immersive gradient / blur 遮罩;

  4. 图标用系统 Symbol,避免 Unicode 在设备字体缺失时变成方块。

控制面板也要注意:perioddim、行列 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

  • 自定义 GradientStopposition 落在 0..1,避免无序/重复。

  • 在 3×3、5×5、9×15 真机各看一遍 Diagonal / Snake。

  • 矩阵用 Column → Row → Cell,不用可滚动 Grid

  • 动态 Cell 用 Repeat;identity 包含几何与配置,结构变化不复用旧节点。

  • Diagonal/Snake 的扰动必须坐标确定,禁止每帧 Math.random()


八、踩坑速查

现象

根因

修复

矩阵一列排开 / 出现灰滚动条

误用可滚动 Grid

Column + Row

拖动行列后旧点残留

ForEach 稳定 key 复用

Repeat + 几何 identity

Diagonal/Snake 像 4×4 瓷砖

上游四相位在大矩阵暴露

尺寸感知 band + 稳定 jitter

动效发僵、有台阶

时间轴 32ms 过粗

16ms 共享时间轴

页面返回后仍在跑

未清理 timer

aboutToDisappear stop

标题栏灰黑带

局部 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,可以直接拷贝三文件起步,再按品牌色带和矩阵尺寸微调。

Logo

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

更多推荐