渐变色路径动画
2025-04-04 · 7min最近几天在学 p5js,突然想起之前遇到过一个需求:渐变色的流光路径动画。
看似非常简单需求,但其实要完全实现出来却并不简单。
常规路径动画
如果只是单纯的路径动画,可以使用 stroke-dasharray 和 stroke-dashoffset 来做 CSS 动画实现:
但是需求还要求是渐变色的,当时想着这不简单?给 svg 加上渐变属性不就可以了吗。
<svg width="248" height="105" viewBox="0 0 248 105" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M142 1L2 104H248" stroke="url(#paint0_linear_1504_37)" />
<defs>
<linearGradient id="paint0_linear_1504_37" x1="140" y1="1.00001" x2="248" y2="104" gradientUnits="userSpaceOnUse">
<stop stop-color="#4ADE80" stop-opacity="0" />
<stop offset="0.913462" stop-color="#4ADE80" />
</linearGradient>
</defs>
</svg>
结果出来动画是这样的:
是的,没错,svg 的渐变是作用于整段 path 中的,并不会跟着 stroke-dasharray 和 stroke-dashoffset 进行动画效果。
流光边框方案
既然 stroke-dasharray 和 stroke-dashoffset 方案行不通,那我就用流光边框的思路来实现不就行了?
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 360deg;
}
.flow-border {
width: 248px;
height: 105px;
mask: url("/images/path-animation/path.svg");
background-image: conic-gradient(from var(--angle) at 50% 50%,
#4ade80 0deg,
#4ade8000 60deg,
transparent 60deg 120deg,
#4ade80 120deg,
#4ade8000 180deg,
transparent 180deg 240deg,
#4ade80 240deg,
#4ade8000 300deg,
transparent 300deg 360deg);
animation: rotate linear 2s infinite;
}
@keyframes rotate {
from {
--angle: 360deg
}
to {
--angle: 0deg
}
}
流光边框实现方案有很多种,我这里是直接使用路径的 svg 作为 CSS mask,元素背景色用 conic-gradient 填充,动画方面则是通过 @property 定义角度属性,这样就可以用 CSS 动画来旋转背景了。
但很快就发现了问题,这个方案没办法准确控制线段长度,而且因为本质是旋转角度进行动画,所以导致整个路径动画并不是匀速的。而且这个方案也只能用在简单两段折线路径上,如果路径是三段折线,或者更复杂的螺旋线,根本没法解决。
Canvas 方案
我对前端动画的开发原则,能用 Pure CSS 解决的,绝对不用 Javascript 去解决,但很明显现在这个看似简单的路径动画,仅仅因为要求是渐变色,就已经无法用 CSS 来解决了,必须得用 canvas 来解决。
动画思路
考虑到颜色要求渐变,并且路径是不确定的,那就不能使用 lineTo 和线形渐变将线进行分段了。那就索性将线拆分成点,只要获取到路径上点坐标,就可以实现让点在路径上运动了。这里直接取路径 SVG 的 width 和 height 就好。
线的构成
先处理点如何构成线,因为我们需要的线不仅有长度,还有宽度。所以这里的点,其实是指一个半径为线宽点一半的圆。点的间隔需要自己进行调整,个人认为线宽的 0.5 倍时,渐变色效果跟实际上最为接近。
颜色处理我用了 chroma.ts 进行过渡。
上边是线性渐变线的效果,下边是点构成的线。
import { scale } from "chroma.ts";
const pointNum = Math.ceil(100 / (6 * 0.5));
const colorScale = scale(["#4ade8000", "4ade80"])
// ctx.value.globalCompositeOperation = "multiply"
for (let i = 0; i < pointNum; i++) {
const percent = i / pointNum;
const color = colorScale(percent).hex("rgba");
ctx.value.beginPath();
ctx.value.arc(70 + percent * 100, 60, 3, 0, 2 * Math.PI);
ctx.value.fillStyle = color;
ctx.value.fill();
ctx.value.closePath()
}
获取路径点坐标
解决完点如何构成线后,需要获取路径点了。
路径点可以通过 SVGPathElement 的 getTotalLength 和 getPointAtLength 获取。我们可以将 SVG 的 path 参数用于离屏渲染一个 SVG,通过 dom 调用相关的所需的方法:
// 路径参数
const path = "M142 1L2 104H248";
// 线段宽度
const strokeWidth = 6;
// 线长度;若线+间隔总长度大于路径长度,则均等衰减线长度
const length = 30;
// 线数量
const lineNumber = 3
// 线间隔
const lineGap = 30
// 点密度
const dotsDensity = strokeWidth * 0.4;
/**
* @description 创建SVGPathElement
*/
const svgContainer = document.createElement("div");
svgContainer.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg">
<path d="${path}"/>
</svg>`;
const pathElement = svgContainer.querySelector("path")! as SVGPathElement;
// path 总长度
const totalLength = pathElement.getTotalLength();
// 点的总数
const dotsNum = Math.ceil(totalLength / dotsDensity);
// 预设路径点
const dots = [];
for (let i = 0; i < dotsNum; i++) {
const steps = i / dotsNum;
const pos = this.pathElement.getPointAtLength(steps * totalLength);
const dot = {
x: pos.x,
y: pos.y,
};
dots.push(dot);
}
构建动画线类
有了构建渐变线的方法跟动画坐标点后,就可以开始进行动画部分了,canvas 动画一般都会构建一个对象类用于存储变量状态。
import { scale, type Scale } from "chroma.ts";
/**
* @description 路径点
*/
interface Dot {
x: number;
y: number;
}
interface EffectLineOption {
/**
* @description 起始进度
*/
startProgress: number;
/**
* @description 构成线的点数量
*/
dotNumber: number;
color: string[];
/**
* @description 线宽度
*/
strokeWidth: number;
}
/**
* @description 动画线类
*/
export class EffectLine {
/**
* @description 起始进度
*/
startProgress: number;
/**
* @description 构成线的点数量
*/
dotNumber: number;
/**
* @description 颜色插值获取方法
*/
colorScale: Scale;
/**
* @description 线宽度
*/
strokeWidth: number;
constructor({
startProgress,
dotNumber,
color,
strokeWidth,
}: EffectLineOption) {
this.startProgress = startProgress;
this.dotNumber = dotNumber;
this.colorScale = scale(color);
this.strokeWidth = strokeWidth;
console.log(startProgress);
}
public draw(
progress: number,
pathDots: Dot[],
ctx: CanvasRenderingContext2D
) {
if (ctx) {
const currentProgress = progress + this.startProgress;
/**
* @description 大于等于再画
*/
if (currentProgress >= 0) {
const offsetPath = Math.floor(currentProgress) * pathDots.length;
const tailIndex =
Math.ceil(currentProgress * pathDots.length) - offsetPath;
for (let i = 0; i < this.dotNumber; i++) {
let posIdx = tailIndex - i;
let hide = false;
if (posIdx < 0) {
posIdx = pathDots.length + posIdx;
hide = currentProgress <= 1;
}
const dot = pathDots[posIdx];
if (!hide && dot) {
ctx.beginPath();
ctx.arc(dot.x, dot.y, this.strokeWidth / 2, 0, 2 * Math.PI);
ctx.fillStyle = this.colorScale(i / this.dotNumber).hex("rgba");
ctx.fill();
ctx.closePath();
}
}
}
}
}
}
接下来只需要在 requestAnimationFrame 更新调用 draw 方法就可以实现动画。
能制造出单个渐变路径动画后,那多段的就简单了,只需根据自己的需求计算好每段的 startProgress 即可,复杂的路径动画也只需替换 path 参数即可。