Loading...

渐变色路径动画

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 参数即可。