流光边框三种实现:从 CSS 关键帧到 Lottie,附鸿蒙落地
流光边框三种实现:从 CSS 关键帧到 Lottie,附鸿蒙落地
做一个按钮,让一条亮条沿着它的边框匀速转圈。听起来简单,我从最直觉的 CSS 方案做起,撞了两次墙才找到正解。这篇把三个版本完整记下来——包括为什么前两个不够用。
三个版本:
- CSS + HTML —— 方块沿八段路径平移,手算匀速关键帧
- SCSS + HTML —— 同一个方案,但用变量和三角函数驱动,改尺寸不用重算
- 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 | ±300 | 300 |
| 左/右边 ×2 | ±100 | 100 |
总路程 ≈ 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,对称。
关键约束有两条,坏掉任何一条效果就废了:
- 所有段的
o(offset)动画必须完全一致,否则各段以不同速度移动,亮条会散开 - 相邻段的 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 2f6fe0 | 6 位 hex |
| 中点颜色 | --to f22222 | 渐变是 from→to→from 对称 |
| 改成单色 | --solid | 只用 --from |
| 渐变细腻度 | --bands 16 | 16 起够用 |
| 绕行速度 | --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 节那个思路的原生对应物:路径按实际布局宽高算,strokeDashOffset 用 animateTo 驱动,尺寸变化时通过 onAreaChange 重算路径,2vp 永远是 2vp。原理和 trim path 完全一样,只是换了 API。
(strokeDashArray/strokeDashOffset 在不同 API 版本上的支持情况和是否参与隐式动画,建议对照自己的目标版本文档确认,我没有在全部版本上验证过。如果不支持隐式动画,可以用 displaySync 逐帧推进偏移量。)
六、三个版本对比
| CSS | SCSS | Lottie 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-panel→band-00→background,越靠前越在上层band-00的shapes里,ty:"tm"和ty:"gr"是平级的tm的o关键帧带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 亮条就跟着变。
更多推荐




所有评论(0)