如何创建一个动态SVG图片

摘要

这篇文章介绍了如何在页面上展示一个动态图片,以电池的电量为例,并进行了一定的拓展,覆盖了一些常见的场景

前言

最近项目里面需要在页面上展示一个拓扑图,包含了一些节点和连线。每个节点都包含一张图片来代表该节点的含义。一般来说,我们只要使用静态的 SVG 图片就可以了。但有些时候我们希望节点的内容是动态的,比如我们项目的拓扑图里有一个元素是电池。我们希望根据电池剩余电量来填充不同的比例,就像我们在手机上看到的一样。那么使用特定的 SVG 图片显然无法满足我们的需求了。那么针对这种情况我们应该如何解决呢

快速实现

以我们项目中的电池场景为例,既然我需要根据电量填充不同的比例,那直接多弄几张图片,从空电到满电,然后根据电量展示对应的图片不就行了。正好 Google Fonts 为 Material Icons 提供了完整的一套图片

那我们只需要将0-100的电量分成若干份,然后展示对应的图片即可。相信大家都会这种方法,具体代码就不展示了

这种方法的好处当然是开发简单快速,如果项目比较赶这个方法是最高效的。但是缺点也很明显,一是受制于图标库,一旦没找到合适的图标,那就得让UI设计师出这些图,增加了工作量;二是我们在页面上引入了大量不必要的图片,这样可能会影响性能,增加打包的体积,降低打包的速度;三是数据的处理不是线性的,如果客户要求更加细化的区分就无法满足

基于以上几点,寻找一个更加优雅的办法势在必行

动态SVG

上面这几个 SVG 看起来只是填充的高度不同,所以如果我们能在上面直接把这个高度变成变量,然后再通过 state 维护这个高度就可以实现我们的目标了。那么我们先来看看这个已有的静态电池图片,经过格式化之后大概长这样

svg 复制代码
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 20 20" viewBox="0 0 20 20">
  <g>
    <rect fill="none" height="20" width="20" x="0"/>
  </g>
  <g>
    <path d="
      M13,3.5
      c0.55,0,1,0.45,1,1
      V17
      c0,0.55-0.45,1-1,1
      H7
      c-0.55,0-1-0.45-1-1
      V4.5
      c0-0.55,0.45-1,1-1
      h1.5V2
      h3v1.5
      H13z
      M12.5,5
      h-5v8
      h5V5z      
    "/>
  </g>
</svg>

整个图片是用 path 来实现的,所以可读性比较差,想在这里直接修改几乎没有可能。看来我们需要自己手动绘制一个 SVG 图片。经过一些尝试,一个简单版本的电池图片完成了

svg 复制代码
<svg
  xmlns='http://www.w3.org/2000/svg'
  viewBox='0 0 64 64'
  width='64'
  height='64'
>
  <!-- 电池帽 -->
  <rect x='26' y='0' width='12' height='4' fill='currentColor'></rect>
  <!-- 电池外壳 -->
  <rect
    x='14'
    y='6'
    width='36'
    height='56'
    rx='4'
    ry='4'
    fill='none'
    stroke='currentColor'
    stroke-width='4'
  ></rect>
  <!-- 电池填充 -->
  <rect
    x='14'
    y='40'
    width='36'
    height='20'
    fill='currentColor'
  ></rect>
</svg>

现在我们只需要把最下面的矩形里面的 y 和 height 变成 state,然后动态更新就可以了

通常我都是通过 svgr 将图片变成组件直接引入的

tsx 复制代码
import Battery2 from './battery-2.svg'
...
<Battery2 width={24} height={24} fill='black' />

但是很不幸,这种方式似乎无法改变最下方矩形的高度和位置,所以,我不得不创建一个组件,把 SVG 的所有内容放进去

tsx 复制代码
const DynamicBattery: React.FC = () => {
  return (
    <svg
      xmlns='http://www.w3.org/2000/svg'
      viewBox='0 0 64 64'
      width='64'
      height='64'
    >
      ...
    </svg>
  )
}

这个时候再控制高度和位置就容易多了,同时也将外部的 level 传入组件中

tsx 复制代码
interface DynamicBatteryFillProps {
  level: number
}

const DynamicBatteryFill: React.FC<DynamicBatteryFillProps> = ({ level }) => {
  const fillRef = useRef<SVGRectElement | null>(null)

  useEffect(() => {
    if (fillRef.current) {
      // 动态设置电池填充高度,加0.5像素避免白边
      const newHeight = (level / 100) * 52 + 0.5
      // 计算 y 坐标,保持填充在底部,加0.5像素避免白边
      const newY = 60 - newHeight + 0.5
      fillRef.current.setAttribute('height', `${newHeight}`)
      fillRef.current.setAttribute('y', `${newY}`)
    }
  }, [level])

  return (
    <svg
      xmlns='http://www.w3.org/2000/svg'
      viewBox='0 0 64 64'
      width='64'
      height='64'
    >
      ...
      <rect
        ref={fillRef}
        x='16'
        y='60'
        width='32'
        height='0'
        fill='currentColor'
      ></rect>
    </svg>
  )
}

使用的时候只需要传入 level 即可,如果需要改变颜色,只需要在父组件设置字体颜色即可

tsx 复制代码
<DynamicBattery level={50} />
<div className='text-green-500'>
 <DynamicBattery level={50} />
</div>

进阶功能

经过以上步骤,我们已经实现了根据电量动态展示电池图片的功能了。不过这仅仅是最基本的功能,现实场景可能会更加复杂,比如

  • 电池的样式有多种,有的是填充的,有的是留白边的,有的是一格一格的
  • 电池的颜色根据电量不同,比如低于20%展示红色,高于展示绿色
  • 电池充电时需要有充电动画

基于以上需求,我对基础的组件进行了拓展。大多数都是针对 SVG 内容和一些 state 的处理,所以就不赘述了,直接上代码

tsx 复制代码
import clsx from 'clsx'
import { FC, useEffect, useRef, useState } from 'react'

interface DynamicBatteryProps {
  /** 电池样式类型,填充、留白、格状 */
  type?: 'fill' | 'gap' | 'grid'
  /** 电量百分比 */
  level?: number
  /** 是否渐变 */
  gradient?: boolean
  /** 是否正在充电 */
  charging?: boolean
}

const DynamicBattery: FC<DynamicBatteryProps> = ({
  type = 'fill',
  level = 0,
  gradient = false,
  charging = false,
}) => {
  const fillRef = useRef<SVGRectElement | null>(null)
  const timerRef = useRef<NodeJS.Timeout>()

  const [fillLevel, setFillLevel] = useState(0)

  useEffect(() => {
    setFillLevel(level)
  }, [level])

  useEffect(() => {
    if (fillRef.current) {
      const newHeight =
        type === 'fill' ? (fillLevel / 100) * 52 + 0.5 : (fillLevel / 100) * 46
      const newY = type === 'fill' ? 60 - newHeight + 0.5 : 57 - newHeight

      fillRef.current.setAttribute('height', `${newHeight}`)
      fillRef.current.setAttribute('y', `${newY}`)
    }
  }, [type, fillLevel])

  useEffect(() => {
    if (timerRef.current) {
      clearInterval(timerRef.current)
    }

    if (charging) {
      if (type === 'grid') {
        timerRef.current = setInterval(() => {
          setFillLevel((level) => {
            if (level < 100) {
              return Math.min(level + 20, 100)
            }

            return 0
          })
        }, 400)
      } else {
        timerRef.current = setInterval(() => {
          setFillLevel((level) => {
            if (level < 100) {
              return level + 1
            }

            return 0
          })
        }, 20)
      }
    }

    return () => {
      if (timerRef.current) {
        clearInterval(timerRef.current)
      }
    }
  }, [type, charging])

  return (
    <svg
      xmlns='http://www.w3.org/2000/svg'
      viewBox='0 0 64 64'
      width='64'
      height='64'
    >
      <rect
        className={clsx({
          'fill-red-500': gradient && fillLevel <= 20,
          'fill-green-500': !gradient || fillLevel > 20,
        })}
        x='26'
        y='0'
        width='12'
        height='4'
        fill='currentColor'
      ></rect>
      <rect
        className={clsx({
          'stroke-red-500': gradient && fillLevel <= 20,
          'stroke-green-500': !gradient || fillLevel > 20,
        })}
        x='14'
        y='6'
        width='36'
        height='56'
        rx='4'
        ry='4'
        fill='none'
        stroke='currentColor'
        strokeWidth='4'
      ></rect>
      <rect
        className={clsx({
          'fill-red-500': gradient && fillLevel <= 20,
          'fill-green-500': !gradient || fillLevel > 20,
        })}
        id='battery-fill'
        ref={fillRef}
        x={type === 'fill' ? '14' : '19'}
        y={type === 'fill' ? '57' : '60'}
        width={type === 'fill' ? '36' : '26'}
        height='0'
        fill='currentColor'
      ></rect>
      {type === 'grid' ? (
        <>
          <rect x='19' y='47.5' width='26' height='2' fill='white'></rect>
          <rect x='19' y='38' width='26' height='2' fill='white'></rect>
          <rect x='19' y='28.5' width='26' height='2' fill='white'></rect>
          <rect x='19' y='19' width='26' height='2' fill='white'></rect>
        </>
      ) : null}
    </svg>
  )
}

export default DynamicBattery

展示效果如下

上面是一个通用的组件,考虑到实际开发中我们可能只用到一两种情况,所以我为每种情况都单独写了个组件,所有代码都放在了 github.com/sxm0617-lem... 。启动项目后有一些演示,还可以自己尝试玩一下,感兴趣的可以自行查看

总结

以上就是如何让SVG图片可以根据数据动态变化的方法了,仅供参考。如果有不完善的地方或者有更好的方法,欢迎指出

相关推荐
低代码布道师37 分钟前
第二篇:脚手架搭建 — React 和 Express 的搭建
前端·react.js·express
前端熊猫40 分钟前
React Router 6的学习
javascript·学习·react.js
Domain-zhuo7 小时前
React和Vue.js的相似性和差异性是什么?
前端·vue.js·flutter·react.js·前端框架
stormsha9 小时前
解决 Nginx 部署 React 项目时的重定向循环问题
前端·nginx·react.js
kjl5365661 天前
React和Vue中暴露子组件的属性和方法给父组件用,并且控制子组件暴露的颗粒度的做法
javascript·vue.js·react.js
等一场春雨1 天前
react antd tabs router 基础管理后台模版
前端·javascript·react.js
m0_748234901 天前
React 和 Vue _使用区别
javascript·vue.js·react.js
小纯洁w1 天前
React Scan(自动检测渲染周期,通过视觉提示突出显示导致性能问题的组件)的介绍和使用方法
前端·react.js·前端框架
wayne2141 天前
ReactNative接入广告平台三方库推荐
javascript·react native·react.js