摘要
这篇文章介绍了如何在页面上展示一个动态图片,以电池的电量为例,并进行了一定的拓展,覆盖了一些常见的场景
前言
最近项目里面需要在页面上展示一个拓扑图,包含了一些节点和连线。每个节点都包含一张图片来代表该节点的含义。一般来说,我们只要使用静态的 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图片可以根据数据动态变化的方法了,仅供参考。如果有不完善的地方或者有更好的方法,欢迎指出