做一个按钮,让一条亮条沿着它的边框匀速转圈。听起来简单,我从最直觉的 CSS 方案做起,撞了两次墙才找到正解。这篇把三个版本完整记下来——包括为什么前两个不够用。

三个版本:

  1. CSS + HTML —— 方块沿八段路径平移,手算匀速关键帧
  2. SCSS + HTML —— 同一个方案,但用变量和三角函数驱动,改尺寸不用重算
  3. Lottie JSON —— 换成路径裁剪思路,顺带解决了前两版的致命缺陷,可直接在鸿蒙里播

一、CSS + HTML 版

1.1 结构

三层:按钮本体是底板,::before 是绕行的方块,::after 是内层遮罩——遮罩盖住中间,只在四周留出一圈,那圈就是"边框"。

<button class="borderButton">边框按钮</button>
.borderButton {
  width: 400px;
  height: 200px;
  border: none;
  outline: none;
  position: relative;
  background-color: antiquewhite;
  overflow: hidden;        /* 裁掉方块跑到按钮外的部分 */
  border-radius: 10px;
}

/* 绕行的方块 */
.borderButton::before {
  content: "";
  position: absolute;
  width: 80px;
  height: 80px;
  background-color: #ffb522;
  left: -50px;
  top: 10px;
  animation: rotaion 3s linear infinite;
}

/* 内层遮罩:内缩 10px,露出的一圈就是边框 */
.borderButton::after {
  content: "";
  position: absolute;
  width: calc(100% - 20px);
  height: calc(100% - 20px);
  left: 10px;
  top: 10px;
  background-color: aqua;
  border-radius: 10px;
}

1.2 关键帧:为什么不能平均分百分比

方块要沿八条边走一圈:上边、右上斜角、右边、右下斜角、下边……很容易想到八个拐点就平均分成 12.5% 一段。但那样动画不匀速。

原因是各段路程差得很远。以 400×200 的按钮为例,方块中心走的是一个 420×220 的矩形,四个角切掉 60px:

位移路程
斜角 ×4(60, -60) 之类60√2 ≈ 84.85
上/下边 ×2±300300
左/右边 ×2±100100

总路程 ≈ 1139.41px。如果平均分 12.5%,那 84.85px 的斜角和 300px 的直边花同样时间,直边的速度会是斜角的 3.5 倍——视觉上就是在四个角"卡顿"一下。

匀速的充要条件是:关键帧百分比 = 该点之前的累计路程 ÷ 总路程。

按这个算:

/* 8 段路程:斜 84.85×4 / 横 300×2 / 竖 100×2,总计 ≈1139.41px
   斜段各占 7.447%,横段各占 26.329%,竖段各占 8.777% */
@keyframes rotaion {
  0%       { transform: translate(0, 0); }
  7.447%   { transform: translate(60px, -60px); }
  33.776%  { transform: translate(360px, -60px); }
  41.223%  { transform: translate(420px, 0); }
  50%      { transform: translate(420px, 100px); }
  57.447%  { transform: translate(360px, 160px); }
  83.776%  { transform: translate(60px, 160px); }
  91.223%  { transform: translate(0, 100px); }
  100%     { transform: translate(0, 0); }
}

配合 linear 时间函数,全程速度恒定。路径上下对称,所以 50% 正好落在右边竖段的中点——这是个现成的验算点。

1.3 这版的问题

改尺寸就得全部重算。 把高度从 200 改成 100,八个关键帧的数值和百分比全变。更隐蔽的是:高度 100 时垂直跨度 120px,两个 60px 斜角正好吃完,左右竖边退化成一个尖点,八段变六段——结构都不一样了。


二、SCSS + HTML 版

把上面的手算过程交给编译器。

2.1 变量与推导

@use 'sass:math';
@use 'sass:list';

/* ============ 可调变量 ============ */
$boxWidth: 400px;      // 按钮宽
$boxHeight: 200px;     // 按钮高
$blockSize: 80px;      // 方块边长
$outset: 10px;         // 方块中心轨迹相对按钮边缘外扩多少
$cutX: 60px;           // 拐角斜切的水平投影
$cutAngle: 45deg;      // 斜切角度
$duration: 3s;
$radius: 10px;
$gap: 10px;            // 内层遮罩留出的边框宽度
$blockColor: #ffb522;

/* ============ 推导量 ============
   斜切用三角函数展开:水平投影已知,
   垂直投影 = cutX·tan(θ),斜边 = cutX / cos(θ) */
$cutY: $cutX * math.tan($cutAngle);
$diag: math.div($cutX, math.cos($cutAngle));
$pathW: $boxWidth + $outset * 2;
$pathH: $boxHeight + $outset * 2;
$edgeH: $pathW - $cutX * 2;
$edgeV: $pathH - $cutY * 2;
$total: $diag * 4 + $edgeH * 2 + $edgeV * 2;

$cutAngle 用三角函数展开的好处是:改成非 45° 的斜切角,垂直投影和斜边长度自动跟着变,不用重新画图算。

2.2 拐点列表 + 循环生成关键帧

每个拐点存三个值:累计路程、X 位移、Y 位移。

/* 8 个拐点:(累计路程, translateX, translateY)
   起点在左侧竖边的上端,顺时针绕行 */
$points: (0px, 0px, 0px),
  ($diag, $cutX, -$cutY),
  ($diag + $edgeH, $pathW - $cutX, -$cutY),
  ($diag * 2 + $edgeH, $pathW, 0px),
  ($diag * 2 + $edgeH + $edgeV, $pathW, $edgeV),
  ($diag * 3 + $edgeH + $edgeV, $pathW - $cutX, $edgeV + $cutY),
  ($diag * 3 + $edgeH * 2 + $edgeV, $cutX, $edgeV + $cutY),
  ($diag * 4 + $edgeH * 2 + $edgeV, 0px, $edgeV),
  ($total, 0px, 0px);

@keyframes rotaion {
  @each $p in $points {
    #{math.div(list.nth($p, 1), $total) * 100%} {
      transform: translate(list.nth($p, 2), list.nth($p, 3));
    }
  }
}

一个 @each 就把百分比算完了。匀速在这里是结构保证的,不是算对的——累计路程 / 总路程 这个式子写在代码里,不可能算错。

2.3 方块起始位置也让它推

.borderButton {
  width: $boxWidth;
  height: $boxHeight;
  position: relative;
  background-color: antiquewhite;
  overflow: hidden;
  border-radius: $radius;

  /* 起始位置由「中心落在轨迹起点」反推:
     中心起点 = (-$outset, $cutY - $outset),再减半个方块 */
  &::before {
    content: '';
    position: absolute;
    width: $blockSize;
    height: $blockSize;
    background-color: $blockColor;
    left: -($outset + math.div($blockSize, 2));
    top: $cutY - $outset - math.div($blockSize, 2);
    animation: rotaion $duration linear infinite;
  }

  &::after {
    content: '';
    position: absolute;
    width: calc(100% - #{$gap * 2});
    height: calc(100% - #{$gap * 2});
    left: $gap;
    top: $gap;
    background-color: aqua;
    border-radius: $radius;
  }
}

这段解决了一个实际麻烦:把 $blockSize 从 40 改到 80 时,如果 left/top 写死,方块中心就偏离了轨迹,八个关键帧全部失准。让它按"中心对齐轨迹起点"反推,改方块大小时关键帧一个数都不用动。

编译:

npx sass --no-source-map input.scss output.css

2.4 但它有个致命缺陷

$boxHeight 改成 100px 时,$edgeV 自动变 0,八段退化成六段,不用改任何结构——SCSS 这版确实解决了"改尺寸要重算"。

可是方块经过圆角时,会把圆角盖成直角。

方块是个刚性矩形,走到左上角时它就是一块 80×80 的实心橙色,底下那 10px 的圆角被完全覆盖。视觉上按钮的圆角在亮条经过的瞬间"被填平"。

我试过给方块加 border-radius: 50% 变成圆形、试过 radial-gradient 让边缘变虚、试过用 overflow: hidden 裁切——都只是让缺陷不那么显眼,没有解决它。

根因是思路错了:亮条不该是一个盖在边框上的东西,它应该是边框本身的一部分。


三、Lottie JSON 版

3.1 换个思路:裁剪路径

不要方块。画一条圆角矩形路径,给它描边,然后只显示这条描边的一小段,让这一段沿路径滑动。

Lottie 里这个功能叫修剪路径(Trim Path,ty: "tm")。它有三个参数:s(起点 %)、e(终点 %)、o(偏移角度)。固定 s/e 取一个小区间,动画驱动 o 从 0 到 360,那段就绕着路径走了一圈。

亮条是路径的一部分,所以走到圆角处会自然跟着弯曲。前两版的问题从根上消失了,而且不再需要遮罩裁切。

3.2 最小可用结构

{
  "v": "5.7.4", "fr": 60, "ip": 0, "op": 180,
  "w": 400, "h": 200,
  "layers": [
    {
      "ty": 4, "nm": "flow-bar", "ind": 1, "ip": 0, "op": 180,
      "ks": { "p": { "a": 0, "k": [200, 100, 0] } },
      "shapes": [
        {
          "ty": "gr",
          "it": [
            { "ty": "rc", "s": { "a": 0, "k": [398, 198] },
              "p": { "a": 0, "k": [0, 0] }, "r": { "a": 0, "k": 9 } },
            { "ty": "st",
              "c": { "a": 0, "k": [0.18, 0.44, 0.88, 1] },
              "w": { "a": 0, "k": 2 },
              "lc": 2, "lj": 2 },
            { "ty": "tr", "p": { "a": 0, "k": [0, 0] } }
          ]
        },
        {
          "ty": "tm", "m": 1,
          "s": { "a": 0, "k": 0 },
          "e": { "a": 0, "k": 7 },
          "o": { "a": 1, "k": [
            { "i": { "x": [1], "y": [1] }, "o": { "x": [0], "y": [0] },
              "t": 0, "s": [0] },
            { "t": 180, "s": [360] }
          ] }
        }
      ]
    }
  ]
}

要点:

  • ty: "rc" 是圆角矩形,ty: "st" 是描边,ty: "tm" 是修剪路径。tm 必须写在 shapes 数组里、和图形组 gr 平级,写进 it 里不生效。
  • lc: 2 是圆头端点,亮条两端会是圆弧。
  • i:{x:[1],y:[1]}, o:{x:[0],y:[0]} 是线性插值。 Lottie 关键帧默认带缓动,不写这组值动画就不匀速——这是最容易忽略的一点。
  • s: 0, e: 7 表示亮条占路径周长的 7%。

3.3 渐变:为什么要切成多段

想让亮条从蓝渐变到红再回蓝。直觉是用渐变描边(ty: "gs")——但那是错的。

Lottie 的渐变描边按屏幕坐标铺色,不沿路径方向。亮条在上边时可能是蓝到红,转到右边就变成红到蓝,颜色会乱。SVG 的 linearGradient 有同样的问题。

解法是把亮条切成 N 段,每段一个纯色描边,颜色按位置插值:

段 0  trim 0.0000% → 0.4375%   #3b6ad4  ← 接近两端色(蓝)
段 1  trim 0.4375% → 0.8750%   #5461bc
段 2  trim 0.8750% → 1.3125%   #6c57a5
...
段 7  trim 3.0625% → 3.5000%   #e6272e  ← 中点(红)
段 8  trim 3.5000% → 3.9375%   #e6272e
...
段 15 trim 6.5625% → 7.0000%   #3b6ad4  ← 回到蓝

颜色用 mix = 1 - |2t - 1| 算(t 是该段在亮条上的相对位置 0~1),这样两端是 from、中点是 to,对称。

关键约束有两条,坏掉任何一条效果就废了:

  1. 所有段的 o(offset)动画必须完全一致,否则各段以不同速度移动,亮条会散开
  2. 相邻段的 trim 区间必须首尾严格相接(前一段的 e = 后一段的 s),否则亮条中间有缝

16 段视觉上已经完全连续。代价是图层数从 3 涨到 18,但对播放器没什么压力。

3.4 三层同心几何

完整效果需要三层,从下到上:背景(底板)、亮条、内层面板。Lottie 的 layers 数组是越靠前越在上层,所以背景写在最后。

以 400×200、边框 2px 为例:

背景      400×200  圆角 10     ← 底板
亮条路径  398×198  圆角 9      ← 内缩 stroke/2,让 2px 描边居中压在边框上
内层面板  396×196  圆角 8      ← 内缩 stroke,露出的那一圈就是边框

圆角必须逐层递减(10 / 9 / 8)。如果三层都用 10,细边框下圆角处会出现粗细不均。

注意 Lottie 的颜色是 0–1 归一化 RGB,不是 0–255#f22222 要写成 [0.949, 0.133, 0.133, 1](除以 255)。

3.5 用脚本生成

手写 18 个图层的 JSON 不现实,尤其是 16 段的 trim 区间和颜色。我写了个生成脚本:

node gen_lottie.js --out out.json \
  --width 400 --height 200 --radius 10 --stroke 2 \
  --bar-pct 7 --bands 16 --from 2f6fe0 --to f22222 --duration 3

输出:

底板 400×200 圆角 10 · 60fps/180帧 (3s)
图层 18 个 = 1 面板 + 16 亮条段 + 1 背景
路径 398×198 圆角 9 · 周长 1176.55px
亮条 82.36px (7%) · 每段 5.15px · 描边 2px
线速度 392.2 px/s

各段 offset 动画一致: 通过
trim 区间首尾相接:   通过

最后两行是对 3.3 节那两条约束的断言,失败时脚本退出码为 1。这两个问题在静态 JSON 里看不出来,只有播放时才发现亮条散了——所以做成了硬校验。


四、JSON 参数怎么调

4.1 滑块(亮条)

想改什么参数说明
长度--bar-pct 7占路径周长的百分比
粗细--stroke 2和边框宽度是同一个值
两端颜色--from 2f6fe06 位 hex
中点颜色--to f22222渐变是 from→to→from 对称
改成单色--solid只用 --from
渐变细腻度--bands 1616 起够用
绕行速度--duration 3

滑块没有独立的宽高,这是和前两版最大的区别:

  • 「长」= trim 区间长度 → --bar-pct
  • 「宽」= 描边粗细 → --stroke

想按像素给长度?脚本会打印 亮条 82.36px (7%),按比例反推。默认 400×200 周长 1176.55px,想要 60px 就是 --bar-pct 5.1

4.2 载体框

想改什么参数
宽 / 高--width 400 / --height 200
圆角--radius 10
边框宽度--stroke 2

--stroke 一个值同时决定亮条粗细和边框宽度——亮条要正好压在边框上,两者不同粗就会错位。改它时三层同心几何自动重排,不用手算。

底板色和内层面板色目前写死在脚本里,改的时候记得除以 255。

4.3 几个现成配方

# 胶囊形单色:300×80、圆角 40、4px 青色、2 秒一圈
node gen_lottie.js --out out.json \
  --width 300 --height 80 --radius 40 --stroke 4 --solid --from 00d4ff --duration 2

# 正圆:200×200、圆角 100、更长更细腻的亮条
node gen_lottie.js --out out.json \
  --width 200 --height 200 --radius 100 --bands 24 --bar-pct 12

五、JSON 在鸿蒙中怎么用

5.1 装依赖

ohpm install @ohos/lottie

JSON 放在 src/main/resources/rawfile/ 下。

5.2 播放

Lottie 在鸿蒙里渲染进 Canvas

import lottie, { AnimationItem } from '@ohos/lottie';

@Entry
@Component
struct FlowBorderPage {
  private settings: RenderingContextSettings = new RenderingContextSettings(true);
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
  private animateItem: AnimationItem | null = null;

  build() {
    Column() {
      Canvas(this.context)
        .width(400)      // 与 JSON 的 w 一致
        .height(200)     // 与 JSON 的 h 一致
        .onReady(() => {
          this.animateItem = lottie.loadAnimation({
            container: this.context,
            renderer: 'canvas',
            loop: true,
            autoplay: true,
            path: 'flow_border.json',   // rawfile 下的相对路径
          });
        })
    }
  }

  aboutToDisappear(): void {
    // 必须销毁,否则页面退出后动画仍在跑
    if (this.animateItem !== null) {
      this.animateItem.destroy();
      this.animateItem = null;
    }
  }
}

aboutToDisappear 里的销毁不能省,否则离开页面后动画继续消耗资源。

5.3 vp 单位怎么处理

Lottie 的 w/h 只能是纯数字,写 "400vp" 会解析失败。

鸿蒙里的做法是让 JSON 数字和 Canvas 的 vp 尺寸 1:1 对应

JSON:  "w": 400, "h": 200,  --stroke 2
ArkTS: Canvas().width(400).height(200)

这样 1 个 JSON 单位 = 1vp,--stroke 2 出来就是 2vp。vp 本身是密度无关单位,不同 DPI 设备上物理粗细自动跟着密度走,不用额外换算。要 320vp 宽的按钮就 --width 320

5.4 一个必须知道的限制

上面的 1:1 对应只在 Canvas 尺寸和 JSON 宽高完全一致时成立

一旦按钮宽度自适应(比如 .width('100%')),Lottie 会把整张画布等比缩放:2vp 的边框跟着变粗、圆角变形、亮条长度按比例失真。这不是 bug,是固定画布矢量动画的固有特性。

所以:

  • 尺寸固定 → 用 Lottie,按 1:1 生成 JSON
  • 尺寸自适应 → Lottie 不合适,要用 ArkUI 原生的 Path + strokeDashArray / strokeDashOffset

后者是本文 3.1 节那个思路的原生对应物:路径按实际布局宽高算,strokeDashOffsetanimateTo 驱动,尺寸变化时通过 onAreaChange 重算路径,2vp 永远是 2vp。原理和 trim path 完全一样,只是换了 API。

strokeDashArray/strokeDashOffset 在不同 API 版本上的支持情况和是否参与隐式动画,建议对照自己的目标版本文档确认,我没有在全部版本上验证过。如果不支持隐式动画,可以用 displaySync 逐帧推进偏移量。)


六、三个版本对比

CSSSCSSLottie JSON
亮条形态刚性方块刚性方块路径裁剪出的一段
圆角处表现盖成直角盖成直角自然弯曲
匀速手算,易错结构保证结构保证
改尺寸全部重算改变量重编译改参数重生成
渐变分段拼接
鸿蒙可用
自适应

结论:

  • 纯 web 且不在意圆角细节 → CSS 够用
  • 需要频繁调尺寸的 web 项目 → SCSS
  • 要在鸿蒙/Android/iOS 上跑、尺寸固定 → Lottie JSON
  • 要在鸿蒙上跑且尺寸自适应 → ArkUI 原生 Path + dash

最后总结一下这次踩坑的收获:匀速动画的关键帧百分比必须按累计路程分配,以及沿边框流动的亮条应该是路径的一部分而不是盖在上面的元素。第二点想通之后,圆角、渐变、自适应几个问题是一起消失的。


七、完整源码

前面的代码片段为了讲解做了删减,这里给可以直接跑的完整版本。

7.1 CSS + HTML 版(单文件,复制即用)

存成 flow-border-css.html,双击就能看效果。

<!DOCTYPE html>
<html lang="zh-CN">

<head>
  <meta charset="UTF-8">
  <title>流光边框 - CSS 版</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    body {
      display: flex;
      justify-content: center;
      align-items: center;
      min-height: 100vh;
      background: #ed9fdc;
    }

    .borderButton {
      width: 400px;
      height: 200px;
      border: none;
      outline: none;
      position: relative;
      background-color: antiquewhite;
      overflow: hidden;
      /* 裁掉方块跑到按钮外的部分 */
      border-radius: 10px;
    }

    /* 绕行的方块。left/top 由「中心落在轨迹起点」反推:
       中心起点 (-10, 50),减去半个方块 40 → (-50, 10) */
    .borderButton::before {
      content: "";
      position: absolute;
      width: 80px;
      height: 80px;
      background-color: #ffb522;
      left: -50px;
      top: 10px;
      transform-origin: center center;
      animation: rotaion 3s linear infinite;
    }

    /* 内层遮罩:内缩 10px,露出的一圈就是边框 */
    .borderButton::after {
      content: "";
      position: absolute;
      width: calc(100% - 20px);
      height: calc(100% - 20px);
      left: 10px;
      top: 10px;
      background-color: aqua;
      border-radius: 10px;
    }

    /* 匀速环绕:关键帧百分比 = 该点之前的累计路程 / 总路程。
       8 段路程:斜 60√2≈84.85 ×4 / 横 300 ×2 / 竖 100 ×2
       总路程 ≈ 1139.41px,斜段各占 7.447%,横段 26.329%,竖段 8.777% */
    @keyframes rotaion {
      0% {
        transform: translate(0, 0);
      }

      7.447% {
        transform: translate(60px, -60px);
      }

      33.776% {
        transform: translate(360px, -60px);
      }

      41.223% {
        transform: translate(420px, 0);
      }

      50% {
        transform: translate(420px, 100px);
      }

      57.447% {
        transform: translate(360px, 160px);
      }

      83.776% {
        transform: translate(60px, 160px);
      }

      91.223% {
        transform: translate(0, 100px);
      }

      100% {
        transform: translate(0, 0);
      }
    }
  </style>
</head>

<body>
  <div class="borderButton"></div>
</body>

</html>

7.2 SCSS 版(完整,96 行)

HTML 部分:

<!DOCTYPE html>
<html lang="zh-CN">

<head>
  <meta charset="UTF-8">
  <title>流光边框 - SCSS 版</title>
  <link rel="stylesheet" href="./flow-border.css">
</head>

<body>
  <div class="borderButton"></div>
</body>

</html>

flow-border.scss

@use 'sass:math';
@use 'sass:list';

/* ================= 可调变量 ================= */
$boxWidth: 400px;      // 按钮宽
$boxHeight: 200px;     // 按钮高
$blockSize: 80px;      // 移动方块边长
$outset: 10px;         // 方块中心轨迹相对按钮边缘外扩的距离
$cutX: 60px;           // 拐角斜切的水平投影长度
$cutAngle: 45deg;      // 斜切角度,45deg 为等腰切角
$duration: 3s;         // 绕行一周的时间
$radius: 10px;         // 圆角
$gap: 10px;            // ::after 内层遮罩留出的边框宽度
$blockColor: #ffb522;

/* ================= 推导量 =================
   斜切用三角函数展开:水平投影 $cutX 已知,
   垂直投影 = cutX·tan(θ),斜边 = cutX / cos(θ)。 */
$cutY: $cutX * math.tan($cutAngle);            // 斜切的垂直投影
$diag: math.div($cutX, math.cos($cutAngle));   // 斜切段长度
$pathW: $boxWidth + $outset * 2;               // 方块中心轨迹矩形的宽
$pathH: $boxHeight + $outset * 2;              // 方块中心轨迹矩形的高
$edgeH: $pathW - $cutX * 2;                    // 上/下直边长度
$edgeV: $pathH - $cutY * 2;                    // 左/右直边长度
$total: $diag * 4 + $edgeH * 2 + $edgeV * 2;   // 一周总路程

/* 8 段路径的拐点:(该点之前的累计路程, translateX, translateY)
   起点在左侧直边的上端,顺时针绕行。
   关键帧百分比 = 累计路程 / 总路程 —— 这是匀速的充要条件。 */
$points: (0px, 0px, 0px),
  ($diag, $cutX, -$cutY),
  ($diag + $edgeH, $pathW - $cutX, -$cutY),
  ($diag * 2 + $edgeH, $pathW, 0px),
  ($diag * 2 + $edgeH + $edgeV, $pathW, $edgeV),
  ($diag * 3 + $edgeH + $edgeV, $pathW - $cutX, $edgeV + $cutY),
  ($diag * 3 + $edgeH * 2 + $edgeV, $cutX, $edgeV + $cutY),
  ($diag * 4 + $edgeH * 2 + $edgeV, 0px, $edgeV),
  ($total, 0px, 0px);

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 100vh;
  background: #ed9fdc;
}

.borderButton {
  width: $boxWidth;
  height: $boxHeight;
  border: none;
  outline: none;
  position: relative;
  background-color: antiquewhite;
  overflow: hidden;
  border-radius: $radius;

  /* 起始位置由「中心落在轨迹起点」反推:
     中心起点 = (-$outset, $cutY - $outset),再减去半个方块。 */
  &::before {
    content: '';
    position: absolute;
    width: $blockSize;
    height: $blockSize;
    background-color: $blockColor;
    left: -($outset + math.div($blockSize, 2));
    top: $cutY - $outset - math.div($blockSize, 2);
    transform-origin: center center;
    animation: rotaion $duration linear infinite;
  }

  &::after {
    content: '';
    position: absolute;
    width: calc(100% - #{$gap * 2});
    height: calc(100% - #{$gap * 2});
    left: $gap;
    top: $gap;
    background-color: aqua;
    border-radius: $radius;
  }
}

@keyframes rotaion {
  @each $p in $points {
    #{math.div(list.nth($p, 1), $total) * 100%} {
      transform: translate(list.nth($p, 2), list.nth($p, 3));
    }
  }
}

编译:

npx sass --no-source-map flow-border.scss flow-border.css

编译产物(可以对照着看百分比是怎么算出来的):

.borderButton::before {
  content: "";
  position: absolute;
  width: 80px;
  height: 80px;
  background-color: #ffb522;
  left: -50px;
  top: 10px;
  transform-origin: center center;
  animation: rotaion 3s linear infinite;
}

@keyframes rotaion {
  0% { transform: translate(0px, 0px); }
  7.4470752656% { transform: translate(60px, -60px); }
  33.7764623672% { transform: translate(360px, -60px); }
  41.2235376328% { transform: translate(420px, 0px); }
  50% { transform: translate(420px, 100px); }
  57.4470752656% { transform: translate(360px, 160px); }
  83.7764623672% { transform: translate(60px, 160px); }
  91.2235376328% { transform: translate(0px, 100px); }
  100% { transform: translate(0px, 0px); }
}

7.3 Lottie JSON 生成脚本(完整,Node.js)

18 个图层的 JSON 有 6 万多字符,贴出来没意义。这是生成它的脚本,存成 gen_lottie.js 直接 node gen_lottie.js --out out.json 就能用,无依赖。

#!/usr/bin/env node
/**
 * 生成"沿边框匀速流动的渐变亮条" Lottie JSON。
 *
 * 原理:一条圆角矩形路径 + 描边,用 trim path (ty:"tm") 只显示其中一小段,
 * 动画驱动 trim 的 offset 从 0→360 让这段沿路径滑一整圈。
 * 亮条是路径的一部分,所以拐角处自然弯曲,不会像方块那样盖平圆角。
 *
 * 渐变靠切段实现:Lottie 的 gradient stroke 按屏幕坐标铺色、不沿路径弯曲,
 * 所以改成 N 个首尾相接的 trim 段,每段一个纯色描边,共用同一条 offset 动画。
 */
'use strict';

const fs = require('fs');
const path = require('path');

// ---------- 参数 ----------
const DEFAULTS = {
  width: 400, height: 200, radius: 10,
  stroke: 2, barPct: 7, bands: 16,
  from: '2f6fe0', to: 'f22222',
  duration: 3, fps: 60,
  solid: false, out: null,
};

function usage() {
  console.log(`用法: node gen_lottie.js --out <输出.json> [选项]

选项(括号内为默认值):
  --out <path>        输出文件路径(必填)
  --width <n>         底板宽 (${DEFAULTS.width})
  --height <n>        底板高 (${DEFAULTS.height})
  --radius <n>        底板圆角 (${DEFAULTS.radius})
  --stroke <n>        亮条/边框粗细 (${DEFAULTS.stroke})
  --bar-pct <n>       亮条占路径周长的百分比 (${DEFAULTS.barPct})
  --bands <n>         渐变分段数 (${DEFAULTS.bands})
  --from <hex>        亮条两端色 (${DEFAULTS.from})
  --to <hex>          亮条中点色 (${DEFAULTS.to})
  --duration <sec>    绕行一周的秒数 (${DEFAULTS.duration})
  --fps <n>           帧率 (${DEFAULTS.fps})
  --solid             单色亮条,不做分段渐变
  --help`);
}

function parseArgs(argv) {
  const o = { ...DEFAULTS };
  const map = {
    '--out': ['out', String], '--width': ['width', Number], '--height': ['height', Number],
    '--radius': ['radius', Number], '--stroke': ['stroke', Number],
    '--bar-pct': ['barPct', Number], '--bands': ['bands', Number],
    '--from': ['from', String], '--to': ['to', String],
    '--duration': ['duration', Number], '--fps': ['fps', Number],
  };
  for (let i = 2; i < argv.length; i++) {
    const a = argv[i];
    if (a === '--help' || a === '-h') { usage(); process.exit(0); }
    if (a === '--solid') { o.solid = true; continue; }
    const hit = map[a];
    if (!hit) { console.error('未知参数: ' + a); usage(); process.exit(1); }
    const raw = argv[++i];
    if (raw === undefined) { console.error(a + ' 缺少值'); process.exit(1); }
    o[hit[0]] = hit[1](raw);
  }
  if (!o.out) { console.error('必须指定 --out'); usage(); process.exit(1); }
  if (o.solid) o.bands = 1;
  return o;
}

const hexToRgb = (h) => {
  const s = h.replace('#', '');
  if (!/^[0-9a-fA-F]{6}$/.test(s)) { console.error('颜色需为 6 位 hex: ' + h); process.exit(1); }
  return [0, 2, 4].map(i => parseInt(s.slice(i, i + 2), 16) / 255);
};
const rgbToHex = (c) => '#' + c.slice(0, 3).map(v => Math.round(v * 255).toString(16).padStart(2, '0')).join('');

// ---------- Lottie 结构 ----------
const tr = () => ({
  ty: 'tr', nm: 'Transform',
  p: { a: 0, k: [0, 0] }, a: { a: 0, k: [0, 0] }, s: { a: 0, k: [100, 100] },
  r: { a: 0, k: 0 }, o: { a: 0, k: 100 }, sk: { a: 0, k: 0 }, sa: { a: 0, k: 0 },
});

const rect = (size, r) => ({
  ty: 'rc', d: 1, nm: 'rect', hd: false,
  s: { a: 0, k: size }, p: { a: 0, k: [0, 0] }, r: { a: 0, k: r },
});

function build(o) {
  const { width: W, height: H, fps: FR, stroke: SW, bands: N } = o;
  const OP = Math.round(o.duration * FR);
  const from = hexToRgb(o.from), to = hexToRgb(o.to);

  // 三层同心内缩:背景 R / 亮条路径 R-SW/2 / 内层面板 R-SW
  const pathW = W - SW, pathH = H - SW, pathR = Math.max(0, o.radius - SW / 2);
  const innerW = W - SW * 2, innerH = H - SW * 2, innerR = Math.max(0, o.radius - SW);

  const layer = (ind, nm, shapes) => ({
    ddd: 0, ind, ty: 4, nm, sr: 1, ao: 0, bm: 0, ip: 0, op: OP, st: 0,
    ks: {
      o: { a: 0, k: 100 }, r: { a: 0, k: 0 }, p: { a: 0, k: [W / 2, H / 2, 0] },
      a: { a: 0, k: [0, 0, 0] }, s: { a: 0, k: [100, 100, 100] },
    },
    shapes,
  });

  const filled = (nm, size, r, rgb) => [{
    ty: 'gr', nm, np: 2, cix: 2, bm: 0, ix: 1, hd: false,
    it: [rect(size, r),
      { ty: 'fl', nm: 'fill', hd: false, r: 1, bm: 0, c: { a: 0, k: [...rgb, 1] }, o: { a: 0, k: 100 } },
      tr()],
  }];

  // 第 i 段:同一条路径 + 自己的 trim 区间;offset 动画所有段完全一致
  const band = (i) => {
    const d = o.barPct / N;
    const mix = N === 1 ? 1 : 1 - Math.abs(2 * ((i + 0.5) / N) - 1);
    const c = from.map((b, k) => +(b + (to[k] - b) * mix).toFixed(8));
    return [
      {
        ty: 'gr', nm: 'seg', np: 2, cix: 2, bm: 0, ix: 1, hd: false,
        it: [rect([pathW, pathH], pathR),
          {
            ty: 'st', nm: 'stroke', hd: false, bm: 0,
            c: { a: 0, k: [...c, 1] }, o: { a: 0, k: 100 }, w: { a: 0, k: SW },
            lc: 2, lj: 2, ml: 4,   // lc:2 圆头端点 → 亮条两端是圆弧
          },
          tr()],
      },
      {
        ty: 'tm', nm: 'trim', hd: false, m: 1, ix: 2,
        s: { a: 0, k: +(i * d).toFixed(6), ix: 1 },
        e: { a: 0, k: +((i + 1) * d).toFixed(6), ix: 2 },
        // i:{x:[1],y:[1]} o:{x:[0],y:[0]} 才是线性;Lottie 默认缓动会破坏匀速
        o: { a: 1, ix: 3, k: [
          { i: { x: [1], y: [1] }, o: { x: [0], y: [0] }, t: 0, s: [0] },
          { t: OP, s: [360] },
        ] },
      },
    ];
  };

  const layers = [];
  let ind = 1;
  layers.push(layer(ind++, 'inner-panel', filled('inner', [innerW, innerH], innerR, [0, 1, 1])));
  for (let i = 0; i < N; i++) layers.push(layer(ind++, 'band-' + String(i).padStart(2, '0'), band(i)));
  layers.push(layer(ind++, 'background', filled('bg', [W, H], o.radius, [0.98039216, 0.92156863, 0.84313725])));

  return {
    doc: { v: '5.7.4', fr: FR, ip: 0, op: OP, w: W, h: H, nm: 'flow-border', ddd: 0, assets: [], markers: [], layers },
    geom: { pathW, pathH, pathR, innerW, innerH, innerR, OP },
  };
}

// ---------- 校验 ----------
function verify(doc, geom, o) {
  const bands = doc.layers.filter(l => l.nm.startsWith('band'));
  const { pathW, pathH, pathR } = geom;
  const peri = 2 * (pathW - 2 * pathR) + 2 * (pathH - 2 * pathR) + 2 * Math.PI * pathR;
  const barLen = peri * o.barPct / 100;

  const ref = JSON.stringify(bands[0].shapes[1].o.k);
  const sameOffset = bands.every(b => JSON.stringify(b.shapes[1].o.k) === ref);

  let seamless = true;
  for (let i = 1; i < bands.length; i++) {
    if (Math.abs(bands[i].shapes[1].s.k - bands[i - 1].shapes[1].e.k) > 1e-9) seamless = false;
  }

  console.log(`底板 ${o.width}×${o.height} 圆角 ${o.radius} · ${o.fps}fps/${geom.OP}帧 (${o.duration}s)`);
  console.log(`图层 ${doc.layers.length} 个 = 1 面板 + ${bands.length} 亮条段 + 1 背景`);
  console.log(`路径 ${pathW}×${pathH} 圆角 ${pathR} · 周长 ${peri.toFixed(2)}px`);
  console.log(`亮条 ${barLen.toFixed(2)}px (${o.barPct}%) · 每段 ${(barLen / bands.length).toFixed(2)}px · 描边 ${o.stroke}px`);
  console.log(`线速度 ${(peri / o.duration).toFixed(1)} px/s`);
  console.log();
  console.log('各段 offset 动画一致: ' + (sameOffset ? '通过' : '失败(亮条会散开)'));
  console.log('trim 区间首尾相接:   ' + (seamless ? '通过' : '失败(亮条有缝)'));

  if (bands.length > 1) {
    console.log();
    console.log('颜色 ' + rgbToHex(hexToRgb(o.from)) + ' → ' + rgbToHex(hexToRgb(o.to)) + ' → ' + rgbToHex(hexToRgb(o.from)) + ':');
    console.log('  ' + bands.map(b => rgbToHex(b.shapes[0].it[1].c.k)).join(' '));
  }
  return sameOffset && seamless;
}

// ---------- main ----------
const o = parseArgs(process.argv);
const { doc, geom } = build(o);
fs.mkdirSync(path.dirname(path.resolve(o.out)), { recursive: true });
fs.writeFileSync(o.out, JSON.stringify(doc, null, 2) + '\n');
console.log('已写入 ' + o.out + '\n');
process.exit(verify(doc, geom, o) ? 0 : 1);

7.4 完整 Lottie JSON(单色版,可直接用)

上面脚本的产物长这样。这是 --solid 单色版(3 图层),存成 .json 就能播;16 段渐变版结构完全一样,只是把中间的 band-00 复制成 16 份、改各自的 s/e/颜色,有 6 万多字符,不适合贴。

看的时候注意三处,就是前面反复讲的那几个点:

  • layers 顺序是 inner-panelband-00background越靠前越在上层
  • band-00shapes 里,ty:"tm"ty:"gr"平级
  • tmo 关键帧带 i:{x:[1],y:[1]}, o:{x:[0],y:[0]},这才是线性
{
  "v": "5.7.4",
  "fr": 60,
  "ip": 0,
  "op": 180,
  "w": 400,
  "h": 200,
  "nm": "flow-border",
  "ddd": 0,
  "assets": [],
  "markers": [],
  "layers": [
    {
      "ddd": 0,
      "ind": 1,
      "ty": 4,
      "nm": "inner-panel",
      "sr": 1,
      "ao": 0,
      "bm": 0,
      "ip": 0,
      "op": 180,
      "st": 0,
      "ks": {
        "o": {"a":0,"k":100},
        "r": {"a":0,"k":0},
        "p": {"a":0,"k":[200,100,0]},
        "a": {"a":0,"k":[0,0,0]},
        "s": {"a":0,"k":[100,100,100]}
      },
      "shapes": [
        {
          "ty": "gr",
          "nm": "inner",
          "np": 2,
          "cix": 2,
          "bm": 0,
          "ix": 1,
          "hd": false,
          "it": [
            {
              "ty": "rc",
              "d": 1,
              "nm": "rect",
              "hd": false,
              "s": {"a":0,"k":[396,196]},
              "p": {"a":0,"k":[0,0]},
              "r": {"a":0,"k":8}
            },
            {
              "ty": "fl",
              "nm": "fill",
              "hd": false,
              "r": 1,
              "bm": 0,
              "c": {"a":0,"k":[0,1,1,1]},
              "o": {"a":0,"k":100}
            },
            {
              "ty": "tr",
              "nm": "Transform",
              "p": {"a":0,"k":[0,0]},
              "a": {"a":0,"k":[0,0]},
              "s": {"a":0,"k":[100,100]},
              "r": {"a":0,"k":0},
              "o": {"a":0,"k":100},
              "sk": {"a":0,"k":0},
              "sa": {"a":0,"k":0}
            }
          ]
        }
      ]
    },
    {
      "ddd": 0,
      "ind": 2,
      "ty": 4,
      "nm": "band-00",
      "sr": 1,
      "ao": 0,
      "bm": 0,
      "ip": 0,
      "op": 180,
      "st": 0,
      "ks": {
        "o": {"a":0,"k":100},
        "r": {"a":0,"k":0},
        "p": {"a":0,"k":[200,100,0]},
        "a": {"a":0,"k":[0,0,0]},
        "s": {"a":0,"k":[100,100,100]}
      },
      "shapes": [
        {
          "ty": "gr",
          "nm": "seg",
          "np": 2,
          "cix": 2,
          "bm": 0,
          "ix": 1,
          "hd": false,
          "it": [
            {
              "ty": "rc",
              "d": 1,
              "nm": "rect",
              "hd": false,
              "s": {"a":0,"k":[398,198]},
              "p": {"a":0,"k":[0,0]},
              "r": {"a":0,"k":9}
            },
            {
              "ty": "st",
              "nm": "stroke",
              "hd": false,
              "bm": 0,
              "c": {"a":0,"k":[0.94901961,0.13333333,0.13333333,1]},
              "o": {"a":0,"k":100},
              "w": {"a":0,"k":2},
              "lc": 2,
              "lj": 2,
              "ml": 4
            },
            {
              "ty": "tr",
              "nm": "Transform",
              "p": {"a":0,"k":[0,0]},
              "a": {"a":0,"k":[0,0]},
              "s": {"a":0,"k":[100,100]},
              "r": {"a":0,"k":0},
              "o": {"a":0,"k":100},
              "sk": {"a":0,"k":0},
              "sa": {"a":0,"k":0}
            }
          ]
        },
        {
          "ty": "tm",
          "nm": "trim",
          "hd": false,
          "m": 1,
          "ix": 2,
          "s": {"a":0,"k":0,"ix":1},
          "e": {"a":0,"k":7,"ix":2},
          "o": {
            "a": 1,
            "ix": 3,
            "k": [
              {"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]},"t":0,"s":[0]},
              {"t":180,"s":[360]}
            ]
          }
        }
      ]
    },
    {
      "ddd": 0,
      "ind": 3,
      "ty": 4,
      "nm": "background",
      "sr": 1,
      "ao": 0,
      "bm": 0,
      "ip": 0,
      "op": 180,
      "st": 0,
      "ks": {
        "o": {"a":0,"k":100},
        "r": {"a":0,"k":0},
        "p": {"a":0,"k":[200,100,0]},
        "a": {"a":0,"k":[0,0,0]},
        "s": {"a":0,"k":[100,100,100]}
      },
      "shapes": [
        {
          "ty": "gr",
          "nm": "bg",
          "np": 2,
          "cix": 2,
          "bm": 0,
          "ix": 1,
          "hd": false,
          "it": [
            {
              "ty": "rc",
              "d": 1,
              "nm": "rect",
              "hd": false,
              "s": {"a":0,"k":[400,200]},
              "p": {"a":0,"k":[0,0]},
              "r": {"a":0,"k":10}
            },
            {
              "ty": "fl",
              "nm": "fill",
              "hd": false,
              "r": 1,
              "bm": 0,
              "c": {"a":0,"k":[0.98039216,0.92156863,0.84313725,1]},
              "o": {"a":0,"k":100}
            },
            {
              "ty": "tr",
              "nm": "Transform",
              "p": {"a":0,"k":[0,0]},
              "a": {"a":0,"k":[0,0]},
              "s": {"a":0,"k":[100,100]},
              "r": {"a":0,"k":0},
              "o": {"a":0,"k":100},
              "sk": {"a":0,"k":0},
              "sa": {"a":0,"k":0}
            }
          ]
        }
      ]
    }
  ]
}

7.5 Lottie 预览页(本地看效果)

改完参数想立刻看效果,用这个页面。必须通过 http 服务打开file:// 下 fetch JSON 会被 CORS 拦掉:

python3 -m http.server 8765
# 然后访问 http://localhost:8765/preview.html
<!DOCTYPE html>
<html lang="zh-CN">

<head>
  <meta charset="UTF-8">
  <title>Lottie 预览</title>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js"></script>
  <style>
    body {
      min-height: 100vh;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      gap: 24px;
      background: #ed9fdc;
      font: 14px/1.6 -apple-system, "PingFang SC", sans-serif;
    }

    /* 画布给足余量,避免亮条被裁 */
    #stage {
      width: 500px;
      height: 300px;
    }

    button {
      padding: 6px 16px;
      border: none;
      border-radius: 6px;
      background: #333;
      color: #fff;
      cursor: pointer;
    }
  </style>
</head>

<body>
  <div id="stage"></div>
  <div>
    <button id="play">播放 / 暂停</button>
    <button id="slow">0.25 倍速</button>
    <button id="normal">正常速度</button>
  </div>
  <p id="info">加载中…</p>

  <script>
    const anim = lottie.loadAnimation({
      container: document.getElementById('stage'),
      renderer: 'svg',
      loop: true,
      autoplay: true,
      path: 'out.json'      // 换成你的 JSON 路径
    });

    anim.addEventListener('DOMLoaded', () => {
      document.getElementById('info').textContent =
        `帧率 ${anim.frameRate}fps · 总帧数 ${Math.round(anim.totalFrames)} · 时长 ${(anim.totalFrames / anim.frameRate).toFixed(2)}s`;
    });

    anim.addEventListener('data_failed', () => {
      document.getElementById('info').textContent =
        '加载失败:请通过本地 http 服务打开本页,file:// 会被浏览器拦下。';
    });

    let playing = true;
    document.getElementById('play').onclick = () => {
      playing ? anim.pause() : anim.play();
      playing = !playing;
    };
    document.getElementById('slow').onclick = () => anim.setSpeed(0.25);
    document.getElementById('normal').onclick = () => anim.setSpeed(1);
  </script>
</body>

</html>

0.25 倍速那个按钮不是摆设——转角处是否真的匀速,正常速度下肉眼看不出来,慢放才能发现。

7.6 附:鸿蒙自适应版(ArkTS,不用 Lottie)

如果按钮宽度会变,Lottie 就不适用了(第 5.4 节)。这是 trim path 思路的 ArkUI 原生实现,尺寸变化时重算路径,2vp 永远是 2vp。

注意:这段代码我没有在真机/模拟器上验证过strokeDashArray/strokeDashOffset 的 API 支持情况和是否参与隐式动画,请对照自己的 API 版本确认。如果隐式动画不生效,用文件末尾的 displaySync 备选方案替换 startAnimation

/** 一段亮条的颜色 + dash 参数 */
interface Band {
  color: string;
  dashArray: number[];
  offset: number;
}

@Component
export struct FlowBorder {
  /** 载体宽高(vp)。传 0 表示交给父容器决定,此时用 onAreaChange 实测 */
  @Prop boxWidth: number = 400;
  @Prop boxHeight: number = 200;
  @Prop radius: number = 10;
  /** 亮条/边框粗细(vp):同一个值,亮条要正好压在边框上 */
  @Prop strokeWidth: number = 2;
  /** 亮条长度(vp)。固定像素,元素变宽时长度不变 */
  @Prop barLength: number = 80;
  /** 渐变分段数。1 为单色 */
  @Prop bands: number = 16;
  @Prop duration: number = 3000;
  @Prop fromColor: string = '#2f6fe0';
  @Prop toColor: string = '#f22222';
  @Prop bgColor: string = '#faebd7';
  @Prop panelColor: string = '#00ffff';

  @State private measuredW: number = 0;
  @State private measuredH: number = 0;
  /** 动画驱动量:0 → 1 表示沿路径走完一圈 */
  @State private progress: number = 0;

  private get w(): number {
    return this.boxWidth > 0 ? this.boxWidth : this.measuredW;
  }

  private get h(): number {
    return this.boxHeight > 0 ? this.boxHeight : this.measuredH;
  }

  /** 亮条路径:内缩半个线宽,让描边居中压在边框上 */
  private get pathCommands(): string {
    const half: number = this.strokeWidth / 2;
    const w: number = this.w;
    const h: number = this.h;
    if (w <= 0 || h <= 0) return '';
    const r: number = Math.max(0, Math.min(this.radius - half, (Math.min(w, h) - this.strokeWidth) / 2));
    const iw: number = w - this.strokeWidth;
    const ih: number = h - this.strokeWidth;
    const x: number = half;
    const y: number = half;
    return `M${x + r},${y}H${x + iw - r}A${r},${r} 0 0 1 ${x + iw},${y + r}`
      + `V${y + ih - r}A${r},${r} 0 0 1 ${x + iw - r},${y + ih}`
      + `H${x + r}A${r},${r} 0 0 1 ${x},${y + ih - r}`
      + `V${y + r}A${r},${r} 0 0 1 ${x + r},${y}Z`;
  }

  /** 周长:直边 + 四个 1/4 圆弧。ArkTS 没有 getTotalLength(),只能算 */
  private get perimeter(): number {
    const half: number = this.strokeWidth / 2;
    const w: number = this.w;
    const h: number = this.h;
    if (w <= 0 || h <= 0) return 0;
    const r: number = Math.max(0, Math.min(this.radius - half, (Math.min(w, h) - this.strokeWidth) / 2));
    const iw: number = w - this.strokeWidth;
    const ih: number = h - this.strokeWidth;
    return 2 * (iw - 2 * r) + 2 * (ih - 2 * r) + 2 * Math.PI * r;
  }

  /**
   * 分段是必须的:描边渐变按屏幕坐标铺色、不沿路径弯曲。
   * 各段共用同一个 progress,所以始终首尾相接不会散开。
   */
  private get bandList(): Band[] {
    const total: number = this.perimeter;
    const list: Band[] = [];
    if (total <= 0) return list;

    const n: number = Math.max(1, this.bands);
    const bar: number = Math.min(this.barLength, total);
    const segLen: number = bar / n;
    const flow: number = this.progress * total;
    const from: number[] = FlowBorder.hexToRgb(this.fromColor);
    const to: number[] = FlowBorder.hexToRgb(this.toColor);

    for (let i = 0; i < n; i++) {
      const mix: number = n === 1 ? 1 : 1 - Math.abs(2 * ((i + 0.5) / n) - 1);
      const r: number = Math.round(from[0] + (to[0] - from[0]) * mix);
      const g: number = Math.round(from[1] + (to[1] - from[1]) * mix);
      const b: number = Math.round(from[2] + (to[2] - from[2]) * mix);
      list.push({
        color: `rgb(${r},${g},${b})`,
        dashArray: [segLen, total - segLen],
        // dashOffset 正值是向后走的,取负号才是正向流动
        offset: -(flow + i * segLen),
      } as Band);
    }
    return list;
  }

  private static hexToRgb(hex: string): number[] {
    const s: string = hex.replace('#', '');
    return [
      parseInt(s.substring(0, 2), 16),
      parseInt(s.substring(2, 4), 16),
      parseInt(s.substring(4, 6), 16),
    ];
  }

  /** 匀速绕行:linear 曲线 + iterations -1 无限循环 */
  private startAnimation(): void {
    animateTo({
      duration: this.duration,
      curve: Curve.Linear,
      iterations: -1,
      playMode: PlayMode.Normal,
    }, () => {
      this.progress = 1;
    });
  }

  aboutToAppear(): void {
    this.startAnimation();
  }

  build() {
    Stack() {
      // 底板:露出的一圈就是边框
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor(this.bgColor)
        .borderRadius(this.radius)

      // 内层面板:内缩 strokeWidth,圆角同步递减
      Column()
        .width(this.w - this.strokeWidth * 2)
        .height(this.h - this.strokeWidth * 2)
        .backgroundColor(this.panelColor)
        .borderRadius(Math.max(0, this.radius - this.strokeWidth))

      // 亮条:N 段叠在一起,共用同一个 progress
      ForEach(this.bandList, (band: Band, index: number) => {
        Path()
          .commands(this.pathCommands)
          .fill(Color.Transparent)
          .stroke(band.color)
          .strokeWidth(this.strokeWidth)
          .strokeLineCap(LineCapStyle.Round)
          .strokeDashArray(band.dashArray)
          .strokeDashOffset(band.offset)
          .width('100%')
          .height('100%')
      }, (band: Band, index: number) => `band-${index}`)
    }
    .width(this.boxWidth > 0 ? this.boxWidth : '100%')
    .height(this.boxHeight > 0 ? this.boxHeight : '100%')
    .onAreaChange((_oldArea: Area, newArea: Area) => {
      if (this.boxWidth <= 0) this.measuredW = newArea.width as number;
      if (this.boxHeight <= 0) this.measuredH = newArea.height as number;
    })
  }
}

用法:

FlowBorder({ boxWidth: 400, boxHeight: 200, strokeWidth: 2 })
FlowBorder({ boxWidth: 0, boxHeight: 56, radius: 28 })          // 宽度跟父容器
FlowBorder({ bands: 1, fromColor: '#00d4ff', duration: 2000 })  // 单色青

strokeDashOffset 不支持隐式动画,用逐帧驱动替换 startAnimation

import { displaySync } from '@kit.ArkGraphics2D';

private syncer: displaySync.DisplaySync | undefined;
private startTime: number = 0;

aboutToAppear(): void {
  this.startTime = Date.now();
  this.syncer = displaySync.create();
  this.syncer.on('frame', () => {
    const elapsed: number = (Date.now() - this.startTime) % this.duration;
    this.progress = elapsed / this.duration;
  });
  this.syncer.start();
}

aboutToDisappear(): void {
  this.syncer?.stop();   // 必须停掉,否则页面退出后仍在刷新
  this.syncer = undefined;
}

7.7 web 自适应版(SVG,附赠)

顺带给一个 web 端的自适应实现,和 ArkTS 版同一个原理。宿主元素需要 position: relative,调用 attachFlow(el) 即可:

/**
 * 给元素加一圈沿边框匀速流动的渐变亮条(自适应尺寸)。
 * 尺寸变化(窗口缩放 / 父容器变化 / padding 变化 / 拖拽)都会重算路径。
 */
function attachFlow(host, opt = {}) {
  const {
    strokeWidth = 2,     // 亮条粗细(px)
    barLength = 80,      // 亮条固定长度(px)
    barRatio = null,     // 给了它就按周长比例算,忽略 barLength
    bands = 16,          // 渐变分段数,传 1 为单色
    duration = 3000,     // 绕一圈的时间(ms)
    speed = null,        // 给了它就按恒定线速度(px/s),忽略 duration
    from = [0x2f, 0x6f, 0xe0],
    to = [0xf2, 0x22, 0x22],
  } = opt;

  const NS = 'http://www.w3.org/2000/svg';
  const svg = document.createElementNS(NS, 'svg');
  Object.assign(svg.style, {
    position: 'absolute', inset: '0', width: '100%', height: '100%',
    overflow: 'visible', pointerEvents: 'none',
  });

  // 每段一条 path,颜色按 1-|2t-1| 做 from→to→from 的对称插值
  const paths = Array.from({ length: bands }, (_, i) => {
    const p = document.createElementNS(NS, 'path');
    p.setAttribute('fill', 'none');
    p.setAttribute('stroke-linecap', 'round');
    p.setAttribute('stroke-width', strokeWidth);
    const mix = bands === 1 ? 1 : 1 - Math.abs(2 * ((i + 0.5) / bands) - 1);
    const c = from.map((v, k) => Math.round(v + (to[k] - v) * mix));
    p.setAttribute('stroke', `rgb(${c.join(',')})`);
    svg.appendChild(p);
    return p;
  });
  host.prepend(svg);

  let total = 0, segLen = 0, lap = duration, raf = 0;

  function rebuild() {
    const w = host.clientWidth, h = host.clientHeight;
    if (!w || !h) return;

    // 描边居中在路径上,所以路径整体内缩半个线宽
    const half = strokeWidth / 2;
    const hostR = parseFloat(getComputedStyle(host).borderTopLeftRadius) || 0;
    const r = Math.max(0, Math.min(hostR - half, (Math.min(w, h) - strokeWidth) / 2));
    const x = half, y = half, iw = w - strokeWidth, ih = h - strokeWidth;

    const d = `M${x + r},${y}H${x + iw - r}A${r},${r} 0 0 1 ${x + iw},${y + r}`
      + `V${y + ih - r}A${r},${r} 0 0 1 ${x + iw - r},${y + ih}`
      + `H${x + r}A${r},${r} 0 0 1 ${x},${y + ih - r}`
      + `V${y + r}A${r},${r} 0 0 1 ${x + r},${y}Z`;

    svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
    paths.forEach(p => p.setAttribute('d', d));

    // 用实测长度而非 pathLength 属性:Safari 对 pathLength 支持不可靠
    total = paths[0].getTotalLength();
    const bar = Math.min(barRatio ? total * barRatio : barLength, total);
    segLen = bar / bands;
    lap = speed ? total / speed * 1000 : duration;
    paths.forEach(p => p.setAttribute('stroke-dasharray', `${segLen} ${total - segLen}`));
  }

  // 单条 rAF 驱动全部分段,保证它们始终首尾相接
  function tick(now) {
    if (total) {
      const flow = (now % lap) / lap * total;
      // dashoffset 正值是向后走的,取负号才是正向流动
      paths.forEach((p, i) => p.setAttribute('stroke-dashoffset', -(flow + i * segLen)));
    }
    raf = requestAnimationFrame(tick);
  }

  // 必须盯 border-box:只改 padding 而内容宽度不变时 content-box 不触发
  const ro = new ResizeObserver(rebuild);
  ro.observe(host, { box: 'border-box' });
  rebuild();
  raf = requestAnimationFrame(tick);

  return {
    rebuild,
    destroy() { cancelAnimationFrame(raf); ro.disconnect(); svg.remove(); },
  };
}
<div class="flowBox" style="position:relative; padding:24px 32px; border-radius:10px; background:antiquewhite">
  宽度自适应,亮条自动跟随
</div>
<script>
  attachFlow(document.querySelector('.flowBox'));
</script>

这版有个 CSS/Lottie 都做不到的能力:尺寸、圆角、背景色全部从宿主元素的 CSS 读,不用传参。改 border-radius 亮条就跟着变。

Logo

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

更多推荐