【Day27】反馈元件 - Progress circle

元件介绍

Progress circle 跟上一篇 Progress bar 一样是能够展示当前进度的元件。只是在外观上面以圆形替代长条形,好处是在宽度不够的排版空间当中能够节省空间。

参考设计 & 属性分析

我们知道昨天提到的的 Antd progress bar 结构上长这样:

<div className="ant-progress-inner">
  <div className="ant-progress-bg" />
</div>

那我们今天来偷看一下 progress circle 的结构:

<div className="ant-progress-inner">
  <svg className="ant-progress-circle">
    <path className="ant-progress-circle-trail" ...>
    <path className="ant-progress-circle-path" ...>
    ....(略)
  </svg>
</div>

看起来真的没有 progress bar 那麽单纯了是不是呢?
但至少我们找到了一个关键,就是他使用了 SVG 来画甜甜圈。

如果甜甜圈要用 <div /> 来做,或许也不是做不到,但可能实现起来又更复杂,可以参考下面这篇。
https://www.oxxostudio.tw/articles/201503/css-pie-chart.html

主要的原理就是使用半圆型,然後根据不同的角度、进度,来遮蔽掉不需要的部分,只露出我们需要的角度范围,就能够做到进度条的效果。

但是进度条又更复杂一点,因为他不是纯扇型,他是一个甜甜圈,中间的部分需要挖空,当然我们不可能真的把他挖空,所以能够用到的方法也是想办法把中间遮起来。

大家想想看,如果做一个元件需要常常这样遮遮掩掩的,在变换一些使用情境的时候是不是很有可能会露出马脚呢?就像小时候不懂事说了一个谎或掩盖了一件秘密,未来在面对各种情境的时候就很容易露出破绽一样,我们在使用这个元件的时後,使用情境上面就会变得比较局限,或是比较容易破版被抓到。

因此,使用 SVG 来画 Progress Circle 就成为我们这次的选择。

SVG 简介:

SVG 是可缩放向量图形(Scalable Vector Graphics,SVG)。是基於 XML ,用於描述二维向量图形的一种图形格式,而 SVG 也是由 W3C 所制定的开放标准,老早就成为网页标准。

要画出一个 SVG 图,我们需要先定义出图片的可视区域大小,width=“300” height=“300” 就表示我们定义了一个 300x300 的视区,与 HTML 和 CSS 比较不同的地方,SVG 本身定义这些属性是没有单位的,不过基本上就是以「像素 px」为单位。

<svg width="300" height="300" ...>
  ...
</svg>

我们来看一下 SVG 要怎麽画圆型,SVG 提供了 <circle /> 这个标签来给我们使用,要给哪些参数才有办法定义出一个圆形呢?其实我们只需要圆心的座标以及半径长度就能够定义出一个圆形了:

  • 标签名称: circle
  • 圆心座标: cx, cy
  • 半径长度: r
<circle cx="80.141" cy="73.446" r="44" ...... />

上面的这个圆心座标 cxcy 是在 SVG 可视范围内的座标。

circle 上面有一些属性是我们等一下会用到的。
首先要介绍的是 stroke,这个词有点接近於 CSS 的 border,是用来描述描边的属性。
若直接对 stroke 指定一个色票,那就会是这个描边的颜色,例如我们要让 progress 的 rail 是浅灰色,我们可以这样做:

.progress-circle__rail {
  stroke: #EEE;
}

描边属性除了颜色之外,我们也能够指定他的宽度,这边使用的是 stroke-width

再来我们要介绍的是 stroke-dasharray,是用来把 stroke 做成虚线的效果,线段会被拆成线段、空白、线段、空白.....,如下面这样:

<svg width="300" height="300" style="background: #FFF;">
  <circle
    r="50"
    cx="150"
    cy="150"
    fill="#FFF"
    stroke="#aaa"
    stroke-width="12"
    stroke-dasharray='20'
  />
</svg>

如上显示,线段被拆成 20px 的线段,再空 20px 的空白,不断的循环。

stroke-dasharray 除了可以放单一值之外,我们可以观察到他的属性命名是 *-array,表示他可以像是 array 一样来使用,例如说,我们也可以给两个值,其中第一个值是线段长度,第二个值是空白长度,如下:

stroke-dasharray='20 5'

那我们就能够看到下面这样的效果:

stroke-dasharray 还有其他更进阶的用法,我们可以参考 MDN Web Docs 上面的定义,本篇因为不会用到,所以就不详述。
https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-dasharray

都已经破梗破到这里了,大家是否有参透 progress circle 进度条的原理了呢?

如果还没有参透,我再来给一个提示,如果我把 stroke-dasharray 指定为下面这样如何:

stroke-dasharray='20 999999'

我指定第一个描边线段为 20px,之後就是一个超级大的空白间距,我用 999999 来表示,那我们就会有下面这样的效果:

上图是我在 SVG 里面再加上一个 circle 当作底色,方便我们观察这个描边线段。

如果我们能够指定描边的线段长度以及线段间距的空白长度,那就表示我们可以把第一个描边线段当作 progress track,藉由改变这个线段的长度,我们就可以用来表示 progress 的进度了!

由於我们的进度是 0% ~ 100%,因此对应到这个线段的长度就是 0px ~ 圆周长px

介面设计

属性 说明 类型 默认值
className 客制化样式 string
value 进度 number 0
themeColor 主题配色,primary、secondary 或是自己传入色票 primary, secondary, 色票 primary
strokeColor 定义 track 渐层颜色 { 'xx%': 'value' }[] {}
isClockwise track 是否为顺时针方向,若 false 则为逆时针方向 boolean true

元件实作

透过上面的分析,我们知道 progress circle 首先需要定义出 SVG 的可视范围,并且里面有两个元素,一个是 rail,一个是 track,因此我们的结构如下:

<svg width="..." height="...">
  <circle className="progress-circle__rail" />
  <circle className="progress-circle__track" />
</svg>

进度

跟前篇 ProgressBar 一样,我们需要把 value 限制在 0% ~ 100%,避免不必要的困扰:

const formatValue = (value) => {
  if (value > 100) {
    return 100;
  }
  if (value < 0) {
    return 0;
  }
  return value;
};

前面分析有提到,进度 0% ~ 100%,是对应到 progress 虚线长度 0px ~ 圆周长px,因此我们需要计算圆周长,公式我就是使用国中小的数学来计算圆周长:

const perimeter = radius * 2 * Math.PI; // 圆周长

再来,我们的到圆周长之後,按照给定 progress 的 value,我们一比例算出该进度的弧长:

const argLength = perimeter * (formatValue(value) / 100); // 弧长

拿到弧长之後,我们就可以画出进度条了:

const INFINITE = 999999;

.progress-circle__track {
  {...略}
  stroke-dasharray: ${(props) => props.$argLength} ${INFINITE};
}

那我们在 <ProgressCircle /> 传入 value,经过上述步骤,就能够得到下面的效果:

<ProgressCircle value={20} />

但这个进度条的起始点是从三点钟方向,我们希望他是从十二点钟方向开始,所以我们要对他做一点旋转:

svg {
  transform: rotate(-90deg);
}

这样看起来就正常多了:

数值资讯

我们常看到数值资讯被放在圈圈里面,因此这边做法也很简单,直接用 position: absolute; 把数值资讯放在圆圈中心就可以了:

const Info = styled.div`
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;

  .progress-circle__value {
    font-size: ${(props) => props.$size / 4}px;
  }
  .progress-circle__percent-sign {
    font-size: ${(props) => props.$size / 6}px;
  }
`;


<Info
  className="progress-circle__info"
  $size={size}
>
  <span className="progress-circle__value">{value}</span>
  <span className="progress-circle__percent-sign">%</span>
</Info>

到目前为止,一个看起来模有样的 ProgressCircle 就完成了:

在前面有提到,我们用 formatValue 这个 function 来把 progress track 的值限制在合理范围内,但是数值资讯我们可以依照输入的显示没关系,下面也展示一下各种数值的结果:

<ProgressCircle />
<ProgressCircle value={25} />
<ProgressCircle value={50} />
<ProgressCircle value={75} />
<ProgressCircle value={100} />
<ProgressCircle value={120} />

改变主题颜色

这边的做法都跟之前一样,我们就不再仔细说明,直接展示结果,表示我们用之前的方法一样可以做到:

渐层颜色

记得我们前篇的 ProgressBar 的渐层处理,是直接使用 background 属性:

background: linear-gradient(45deg, #FF8E53 30%, #FE6B8B 90%);

但是在 SVG 里面,渐层的处理需要透过别的手段。
详细的部分我们可以参考 MDN Web Docs 的说明:
https://developer.mozilla.org/zh-TW/docs/Web/SVG/Tutorial/Gradients

主要的意思是说, SVG 提供我们两个属性 fill 以及 stroke 有设置渐层的方法,渐层的类型有两种,一个是「线形渐层(linearGradient)」,一个是「放射形渐层(radialGradient)」。

以线性渐层为例,我们首先需要在 <defs /> 元素里面创建一个 <linearGradient /> 元素,然後在里面定义要从什麽颜色渐层到什麽颜色:

<linearGradient id="linearGradient">
  <stop offset="0%" stop-color="red"/>
  <stop offset="100%" stop-color="blue"/>
</linearGradient>

接着有一个关键步骤,就是 <linearGradient /> 需要设置 id 属性,这是为了让 stroke 可以引用这个渐层,假设我们 id 的设置是这样:

<linearGradient id="linearGradient">
  ...
</linearGradient>

那我们在 stroke 当中要引用这个渐层,就会需要这样做:

stroke: url(#linearGradient);

那我们模仿 Antd 对於渐层颜色的设置,我们定义一个 props ,他可以接受下面这样的物件:

const strokeColor = {
  '0%': '#108ee9',
  '100%': '#87d068',
};

<ProgressCircle strokeColor={strokeColor} value={25} />

在 strokeColor 当中,key 的部分就是渐层的 offset,value 的部分就是渐层的颜色 stop-color,因此透过迭代,我们就能够实现透过 props 传入来定义渐层的功能:

<svg width="..." height="...">
  {strokeColor && (
    <defs>
      <linearGradient
        id="linearGradient"
      >
        {
          Object.keys(strokeColor || {}).map((offset) => (
            <stop
              key={offset}
              offset={offset}
              stopColor={strokeColor[offset]}
            />
          ))
        }
      </linearGradient>
    </defs>
  )}
  <circle className="progress-circle__rail" ... />
  <circle className="progress-circle__track" ... />
</svg>

效果就会如下面这样:

进度顺时针、逆时针

今天我们想要设置一个参数可以让我们决定我们的进度条需要是顺时针生长,还是逆时针生长:

<ProgressCircle isClockwise={false} value={25} />

那这个方法也很简单,如果原本预设是顺时针生长,想要变成逆时针,只要水平翻转就可以了,水平翻转的方法我们是用 css transform 来做:

transform: rotateY(180deg);

整体作法如下示意:

const counterClockwiseStyle = css`
  .progress-circle__progress {
    transform: rotateY(180deg);
  }
`;

const StyledProgressCircle = styled.div`
  ...略
  ${(props) => (props.$isClockwise ? null : counterClockwiseStyle)}
`;

<StyledProgressCircle
  ...
  $isClockwise={isClockwise}
>
  <span className="progress-circle__progress">
    <svg width="..." height="...">
      <defs>...</defs>
      <circle className="progress-circle__rail" ... />
      <circle className="progress-circle__track" ... />
    </svg>
  </span>
</StyledProgressCircle>

改变 circle 大小

假设今天我们想要透过下面这样的方式来改变 progress circle 的大小:

const ResizeProgressCircle = styled(ProgressCircle)`
  width: ${(props) => props.$size}px;
  height: ${(props) => props.$size}px;
`;

<ResizeProgressCircle $size={60} ... />

我们是用 css className 覆写的方式来做,整体架构如下:

<StyledProgressCircle
  ref={progressCircleRef}
  className={className}
>
  <svg width="..." height="...">
    <defs>...</defs>
    <circle className="progress-circle__rail" ... />
    <circle className="progress-circle__track" ... />
  </svg>
</StyledProgressCircle>

所以简单来说,我想要操作的是 <StyledProgressCircle /> 这个方形元素的大小,但是他的 children,也就是我们的 SVG 图,我希望他可以自动跟着他的 parent 元素来变大变小。

那做法就是我透过 useRef 这个 hook 来操作 <StyledProgressCircle />,藉此取得他的大小,然後把它存成参数之後, SVG 图的大小就要跟着这个参数来动:

const progressCircleRef = useRef();
const [size, setSize] = useState(0);

const handleUpdateSize = useCallback(() => {
  const currentElem = progressCircleRef.current;
  setSize(currentElem.clientWidth);
}, []);

useEffect(() => {
  handleUpdateSize();
  window.addEventListener('resize', handleUpdateSize);
  return () => {
    window.removeEventListener('resize', handleUpdateSize);
  };
}, [handleUpdateSize]);

我们用 size 这个 state 来记录我们所取得的元件大小,接着,因为他是一个方形元件,所以我希望方形边长的一半,就等於圆形的半径,当然,我们要扣除 stroke-width 所占用的宽度,因此我得到的半径会是这样:

const defaultStrokeWidth = size * 0.08;
const radius = (size - defaultBorderWidth) / 2;

由於我希望元件放大缩小的时候, progress stroke width 也会跟着改变,才不会造成元件变太大而 progress circle 看起来很细,或是元件变太小而 progres circle 看起来很粗的状况。

那我们计算出 radius,就能够带入我们的 circle 上了:

<circle
  className="progress-circle__rail"
  r={radius}
  cx={size / 2}
  cy={size / 2}
/>

下面就是我们展示的成果:

<ResizeProgressCircle value={87} $size={60} />
<ResizeProgressCircle value={87} $size={100} />
<ResizeProgressCircle value={87} $size={200} />


ProgressCircle 元件原始码:
Source code

Storybook:
ProgressCircle

参考

https://codepen.io/JMChristensen/pen/Ablch?editors=1111

https://wcc723.github.io/svg/2014/06/15/svg-css-stroke-animation/

https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-dasharray


<<:  Day#25 寻找其他使用者(2) 资料结构

>>:  Semantic search BM25 COVID-19 dataset 自然语言BM25搜寻新冠文献资料

语音服务-文字转换语音范例(text-to-speech)

今天反过来,试试文字转换语音的范例 跟前天的语音服务-语音转换文字范例(from-file)一样 把...

EP01 - 开始建置流程之前

英国面包、法国面包、德国面包通通都有, 就是没有属於日本的面包既然如此今後只好自己创造, 这故事就...

Day19:链接串列(Linked List)

链接串列(Linked List) 链接串列是一种线性表,使用Pointer串接资料,好处是找到目标...

Python 生成 Windows 执行档教学 (Pyinstaller, PowerShell)

壹、前言 将 python 程序封装,使用者将更便於使用,而不需担心设定 python 环境 mac...

【後转前要多久】# Day10 CSS - CSS常用属性I (文字、背景)

常用CSS属性 color 字体颜色 color: red; color: #AA2; font-s...