SVG动画实践

July 16, 2023

SVG基本介绍

SVG:可缩放矢量图形,矢量图意味着图像能够被无限放大而不失真或者降低质量,并且可以方便地修改内容。SVG代码是XML语言进行描述,可以结合CSS以及JAVASCRIPT进行交互。

SVG有一些预定义的形状元素,可以供开发者使用

矩形 <rect>

圆形 <circle>

椭圆 <ellipse>

线 <line>

折线 <polyline>

多边形 <polygon>

路径 <path>

基本用法:

<svg xmlns="http://www.w3.org/2000/svg" version="1.1">
  <rect width="300" height="100" style="fill:rgb(0,0,255);stroke-width:1;stroke:rgb(0,0,0)" />
</svg>

SVG动画

SVG除了可绘制矢量图形之外,更重要的特点是动画。其自带的<animate>元素,也可以实现动画开发。相比CSS ANIMATION, SVG动画能被更好地“操控”, 丰富动画的表现形式。但如果是更复杂的动画,还是需要使用或借助其他库来完成。

以下场景的动画,可以优先考虑使用SVG动画:

  • 描边动画:沿着一个固定的路径去填充、描边
  • 路径动画:绕着一个路径去运动

描边动画

很常见的一种描边动画就是圆环倒计时,随着时间,圆环中的蓝色部分逐渐填满整个圆环,或者消失直至整个圆环都是灰色。

proto

import React, { useRef, useEffect } from 'react';

const animateRef = useRef(null);

// 动画重新开始
// animateRef.current?.beginElement();
//

// 监听动画结束
useEffect(() => {
  animateRef?.current?.addEventListener('endEvent', () => {
      handleClose();
    });
    return () => {
      animateRef?.current?.removeEventListener('endEvent', () => {
        handleClose();
      });
    };
}, [handleClose]);

return <svg width="37" height="37" preserveAspectRatio="xMinYMin meet" viewBox="0,0,37,37">
  <circle cx="18" cy="18" r="17" strokeWidth="3" stroke={closeColor} fill="none" transform="matrix(0,-1,1,0,0,36)" strokeOpacity="0.2" />
  <circle cx="18" cy="18" r="17" strokeWidth="3" stroke={closeColor} fill="none" transform="matrix(0,-1,1,0,0,36)" strokeDasharray="0 106" fillOpacity="1">
    <animate attributeName="stroke-dasharray" begin="0s" dur="8s" from="106" to="0" fill="freeze" fillOpacity="0" ref={animateRef} />
  </circle>
</svg>

<svg>标签定义了一个宽高为37单位的svg区域,其中视图区域viewBox从(0,0)开始,宽高37个单位。 但在实际效果中会发现viewBox被svg viewport截掉,因此需要设置一个缩放属性,即preserveAspectRatio

preserveAspectRatio="xMinYMin meet"

preserveAspectRatio属性的值由两个值组成,“xMidYMid” 和 “meet”。 第1个值表示,viewBox如何与SVG viewport对齐;第2个值表示,如何维持高宽比。

第一个值的组合方式:x(Min/Mid/Max)Y(Min/Mid/Max) Min表示靠近原点(0,0)对齐,Mid表示svg viewport中心对齐,Max则是远离原点的对齐。前面的x/Y表示x/Y轴的对齐方式。 xMinYMin即:viewport和viewBox左边对齐,viewport和viewBox上边缘对齐。

第二值的选择有:meet/slice/none meet:保持纵横比缩放viewBox适应viewport slice: 保持纵横比同时比例小的方向放大填满viewport(通常会被截) none: 扭曲纵横比以充分适应viewport

分享一位大佬的文章,充分解释了preserveAspectRatio属性值以及示例: 理解SVG viewport,viewBox,preserveAspectRatio缩放

第一个<circle>元素实现了半透明部分的圆环,样式半径为17,粗细为3单位,透明度为0.2。 第二个<circle>元素实现了白色部分的圆环,样式半径为17,粗细为3单位,透明度为1。

因为圆环的动画起点是从0度逆时针开始,想要调整角度/顺逆时针动画可以通过transform属性或者css scale进行翻转。

因此路径动画的实现主要是通过包裹一个<animate />元素,在<animate />元素中定义动画的变化

路径动画

一个元素(物体)沿着特定的路径(轨迹)进行运动。 使用<animationMotion>元素,定义一个path, 就可以使一个元素沿着路径运动。

import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
const point = [
  [178, 565],
  [281, 530],
  [386, 511],
  [526, 551],
  [600, 506],
  [605, 376],
  [632, 280],
  [591, 220],
  [576, 160],
  [476, 118],
  [371, 171],
  [256, 163],
  [126, 168],
  [124, 218],
  [103, 288],
  [71, 402],
  [105, 488]
];

export function SvgAnimation() {
  const line = point.reduce((pre, cur,i) => {
    return i === 0 ? `M ${cur[0]} ${cur[1]}` : `${pre} L ${cur[0]} ${cur[1]}`;
  }, '');

  const [boatPath, setBoatPath] = useState(`${line} Z`);

  return (
      <svg width="100%" height="100%" viewBox="0 0 720 600" preserveAspectRatio="xMinYMin meet">
          <rect x="0" y="0" width="40" height="40" fill="rgba(0, 255, 255, 0.5)">

          <animateMotion
            dur="10s"
            rotate="auto" // 在路径移动的同时,是否旋转元素
            fill="freeze"
            restart="whenNotActive" // 动画执行完成了才能再重新开始动画
          >
            <mpath xlinkHref="#boat-path"/> // 复用路径
          </animateMotion>
        </rect>
        <path id="boat-path"  d={boatPath} fill="none" stroke="green" />
      </svg>
    );
  }

代码里react元素就会沿着path定义的路径移动

其中的rotate属性,用于定义元素相对于移动路线的方向

  • auto:元素会根据路径的方向自动旋转
  • auto-reverse: 元素会根据路径的方向加上180度的角度自动旋转
  • 某个数字: 元素以这个数字为旋转角度沿着路径移动

restart属性:值有always/whenNotActive/never

  • always: 可以中断动画重新开始
  • whenNotActive: 动画结束才能重新开始
  • never: 动画都不能重新开始

控制移动的路径动画

上一个路径动画的例子是自执行的动画,如果想要通过js控制svg元素沿着路径1/n步移动,可以参考下面的代码:

import { useState, useRef, useCallback, useEffect, useMemo } from 'react';

export const point = [
  [178, 565],
  [281, 530],
  [386, 511],
  [526, 551],
  [600, 506],
  [605, 376],
  [632, 280],
  [591, 220],
  [576, 160], 
  [476, 118],
  [371, 171],
  [256, 163],
  [126, 168],
  [124, 218],
  [103, 288],
  [71, 402],
  [105, 488]
];
export function SvgDemo(props) {
  const { defaultPosIdx = 0, onBoatMoveFinish} = props;
  const line = point.reduce((pre, cur,i) => {
    return i === 0 ? `M ${cur[0]} ${cur[1]}` : `${pre} L ${cur[0]} ${cur[1]}`;
  }, '');

  const _boatPoints = point.map(item => { // 将点对为正下方
    return [item[0] - 50, item[1] - 80];
  });

  const boatline = point.reduce((pre, cur,i) => {
    return i === 0 ? `M ${cur[0]} ${cur[1]}` : `${pre} L ${cur[0]} ${cur[1]}`;
  }, '');

  const realPath = `${boatline} Z`;
  const boatPath = `${line} Z`;

  const [posIdx, setPosIdx] = useState(defaultPosIdx);
  const [boatPosition, setBoatPosition] = useState({ x: _boatPoints?.[defaultPosIdx]?.[0], y: _boatPoints?.[defaultPosIdx]?.[1] });

  // const [idx, setIdx] = useState(0);

  const originPosition = useRef(defaultPosIdx);
  const destination = useRef(defaultPosIdx);
  const times = useRef(0);
  const isInit = useRef(true);
  const animateXRef = useRef(null);
  const animateYRef = useRef(null);

  const prevIdx = useMemo(() => {
    if (isInit.current) {
      isInit.current = false;
      return defaultPosIdx;
    }
    return posIdx === 0 ? _boatPoints.length - 1 : posIdx - 1;
  }, [posIdx, defaultPosIdx]);

  const runBoatAnimation = useCallback(() => {
    if (!times.current) {
      onBoatMoveFinish?.(posIdx);
      return;
    }
    times.current -= 1;
    setBoatPosition(() => {
      const nextIdx = (posIdx + 1) % _boatPoints.length;
      const nextPos = _boatPoints?.[nextIdx];
      const newPosition = { x: nextPos[0], y: nextPos[1] };
      animateYRef?.current?.beginElement();
      animateXRef?.current?.beginElement();
      return newPosition;
    });
    setPosIdx((pre) => {
      return (pre + 1) % _boatPoints.length;
    });
  }, [posIdx, _boatPoints, onBoatMoveFinish]);

  const moveBoat = (step) => {
    originPosition.current = posIdx;
    times.current = step;
    const des = (originPosition.current + times.current) % _boatPoints.length;
    destination.current = des;
    runBoatAnimation();
  };


  useEffect(() => {
    animateXRef?.current?.addEventListener('endEvent', runBoatAnimation);
    return () => {
      animateXRef?.current?.removeEventListener('endEvent', runBoatAnimation);
    };
  }, [runBoatAnimation]);


  return (
    <div className="list-container">
      <button onClick={() => {moveBoat(6)}}>next</button>
      <svg width="100%" height="100%" viewBox="0 0 720 600" preserveAspectRatio="xMinYMin meet">
        <image 
          x={boatPosition.x}
          y={boatPosition.y}
          width="100"
          height="100"
          fill="rgba(0, 255, 255, 0.5)"
          xlinkHref="https://picnew13.photophoto.cn/20181228/shiliangshouhuikatonglunchuan-31308405_1.jpg"
        >
          <animate
            ref={animateXRef}
            attributeName="x"
            from={_boatPoints[prevIdx]?.[0]}
            to={_boatPoints[posIdx]?.[0]}
            fill="freeze"
            repeatCount="1"
            dur="3s" // 这里可以修改动画持续时间
          />
          <animate
            ref={animateYRef}
            attributeName="y"
            from={_boatPoints[prevIdx]?.[1]}
            to={_boatPoints[posIdx]?.[1]}
            fill="freeze"
            dur="3s" // 这里可以修改动画持续时间
          />
        </image>
      <path d={boatPath} fill="none" stroke="green"/>
      <path d={realPath} fill="none" stroke="none"/>
    </svg>
    </div>
  )
}

Profile picture

百事可乐

Let it snow, let it snow, let it snow