🚀 用Canvas绘制一个高可配置的圆形进度条
- 问题分析与拆解
- 第一步,初始化一些默认参数,处理canvas模糊问题
- 第二步,定义绘制函数
- 1. 定义绘制主函数入口,该函数汇集了我们定义的其它绘制方法
- 2. 定义绘制圆环函数
- 3. 定义绘制小圆球函数
- 4. 定义绘制进度百分比文字函数
- 5.绘制标题
- 第三步,制作动画
问题分析与拆解
- 首先背景渐变圆是静态,需要先把这个圆绘制出来,他是具有背景色,且没有动画;
- 外侧深橘色的也是一个圆,只不过它的背景色为透明色,并且是会进行动画的;
- 绘制小球,小球是需要跟随深橘色圆一起做动画的;
- 绘制圆中心的进度数字,且数字也是带有动画的;
- 绘制圆形进度条的标题;
- 需要先把静态的东西绘制出来,最后考虑动画;
- 动画使用requestAnimationFrame对canva进行擦除绘制,就这样不断的擦除重新绘制就会产生动画。
我这里使用React组件来呈现,该组件接受props如下,且这些props都存在默认值。当然也可以完全脱离React,只不过需要把这些参数定义在绘制类中。
export interface CircularProgressBarProps {
/**
* 进度条粗细
*/
lineWidth: number;
/**
* 当前进度条粗细
*/
outsideLineWidth: number;
/**
* #ffdfb3
*/
color: string;
/**
* 圆形进度条外颜色
* #FFB54D
*
*/
outsideColor: string;
/**
* 圆形进度条内颜色(渐变)
*/
insideColor:
| {
/**
* #fff | white
*/
inner: string;
/**
* rgba(255, 247, 230, 0.3)
*/
middle: string;
/**
* rgba(255, 230, 188, 0.6)
*/
out: string;
}
| string;
/**
* 百分比%
* 单位%
* 60
*/
percent: number | string;
/**
* 圆内数值,为空时,取percent
*/
insideValue?: number | string;
/**
* 显示百分号
*/
showPercentSign: boolean;
/**
* 动画速度
* 0.01
*/
stepSpeed: number;
/**
* 百分比数值样式
* 500 28px PingFangSC-Regular, PingFang SC
*/
percentageFont: string;
/**
* 百分比数值填充颜色
* #1A2233
*/
percentageFillStyle: string;
/**
* 是否显示小圆圈
*/
isDrawSmallCircle: boolean;
/**
* 小圆圈半径
*/
smallCircleR: number;
/**
* 小圆圈边框
*/
smallCircleLineWidth: number;
/**
* 小圆圈填充颜色
* #fff
*/
smallCircleFillStyle: string;
/**
* 是否显示文本
*/
isDrawText: boolean;
/**
* 文本字体样式
* 14px Microsoft YaHei
*/
textFont: string;
/**
* 字体颜色
* #999
*/
textFillStyle: string;
/**
* 文本内容
*/
textContent: string;
}
第一步,初始化一些默认参数,处理canvas模糊问题
定义一个类,需要做一些初始化工作。
- 该类的构造函数接受canvas元素和绘制进度条需要的一些参数。
- 进度条存在一些默认配置,比如圆的横纵向坐标、圆的半径、绘制一整个圆需要360度。
- canvas和svg不一样,canvas是位图,在dpr高的屏幕下会模糊的,所以需要解决这个问题。即:原始尺寸 = css尺寸 * dpr。只要保证该等式成立,canvas就是清晰的,当然这个公式也适用于图片,一样的原理。
class CanvasChart {
ctx: CanvasRenderingContext2D;
width: number;
height: number;
circleDefaultConfig: CircleConfig;
config: CircularProgressBarProps;
constructor(ctx: HTMLCanvasElement, config: CircularProgressBarProps) {
const dpr = window.devicePixelRatio;
const { smallCircleR, smallCircleLineWidth } = config;
const { width: cssWidth, height: cssHeight } = ctx.getBoundingClientRect();
this.ctx = ctx.getContext("2d") as CanvasRenderingContext2D;
ctx.style.width = `${cssWidth}px`;
ctx.style.height = `${cssHeight}px`;
ctx.width = Math.round(dpr * cssWidth);
ctx.height = Math.round(dpr * cssHeight);
this.ctx.scale(dpr, dpr);
this.width = cssWidth;
this.height = cssHeight;
this.config = config;
// 圆形进度条默认配置
this.circleDefaultConfig = {
x: this.width / 2,
y: this.height / 2,
radius:
this.width > this.height
? this.height / 2 - smallCircleR - smallCircleLineWidth
: this.width / 2 - smallCircleR - smallCircleLineWidth,
startAngle: 0,
endAngle: 360,
speed: 0,
};
}
}
这里看下如何解决canvas在高dpr下模糊问题:若样式尺寸为500,宽高都为500
dpr为1; 样式尺寸为500,原始尺寸为500
dpr为2; 样式尺寸为500,原始尺寸为1000
当 dpr为1时,canvas尺寸不会变化,所以矩形的位置为 (100, 100, 100, 100)
当 dpr为2时,canvas画布会放大2倍,也就是 (1000, 1000),矩形的位置为(100, 100, 100, 100)
但是canva尺寸会适应样式尺寸,所以会缩小2倍。使用横坐标也就是 1个css像素等于2个canvas像素
所以会看到矩形会绘制在 css像素为(50, 50)的位置,且大小也变成了50。为了使得无论dpr为多少时,我们看到的效果都是一样的,所以需要缩放canvas为dpr
比如放大2倍 1个css像素就等于1个canvas像素
或者每次定义位置的时候 使用坐标乘以dpr也可以实现一样的效果
第二步,定义绘制函数
1. 定义绘制主函数入口,该函数汇集了我们定义的其它绘制方法
绘制入口,用来调用绘制函数,绘制前需要清除画布,通过重新绘制来达到动画效果。然后根据条件值来决定是否渲染其它元素。
因为深橘色圆环、小圆球、百分比文字是具有动画的,所以需要根据percent
数值动态生成弧度值来绘制深橘色进度条(即 _endAngle = _startAngle + (percent / 100) * holeCicle
)和小圆球,根据百分比来绘制百分比文字。
// 绘制圆形进度条
drawCircularProgressBar = (percent: number | string) => {
const { width, height, ctx } = this;
const {
outsideColor,
percentageFont,
percentageFillStyle,
isDrawSmallCircle,
isDrawText,
showPercentSign,
textFont,
textFillStyle,
textContent,
outsideLineWidth,
insideValue = percent,
} = this.config;
ctx.clearRect(0, 0, width, height);
// 背景的圆环
this.drawCircle(this.config);
// 有色的圆环
const holeCicle = 2 * Math.PI;
// 处理渐变色
// const gnt1 = ctx.createLinearGradient(radius * 2, radius * 2, 0, 0);
// gnt1.addColorStop(0, '#FF8941');
// gnt1.addColorStop(0.3, '#FF8935');
// gnt1.addColorStop(1, '#FFC255');
// 从-90度的地方开始画,把起始点改成数学里的12点方向
const _startAngle = -0.5 * Math.PI;
let _endAngle = -0.5 * Math.PI;
if (typeof percent === "number") {
_endAngle = _startAngle + (percent / 100) * holeCicle;
}
this.drawCircle(
{
...this.config,
lineWidth: outsideLineWidth,
insideColor: "transparent",
color: outsideColor,
},
_startAngle,
_endAngle
);
// 绘制小圆球
isDrawSmallCircle && this.drawSmallCircle(this.config, percent);
// 绘制百分比
this.drawPercentage({
percentageFont,
percentageFillStyle,
insideValue,
showPercentSign,
percent,
});
// 绘制文字
isDrawText &&
this.drawText({
textFont,
textFillStyle,
textContent,
});
};
2. 定义绘制圆环函数
绘制一个圆,使用ctx.arc
,需要圆弧的坐标、半径、起始弧度和结束弧度、填充色(支持渐变和普通色彩)、描边色。
需要通过此函数来绘制两个圆弧。一个是静态的填充色是渐变的圆;另外一个动态的圆弧,用来根据弧度的变化来生成动画,且填充色为透明色。
// 绘制圆曲线
drawCircle = (
config: CircularProgressBarProps,
_startAngle?: number,
_endAngle?: number
) => {
const { ctx } = this;
const { x, y, radius, startAngle, endAngle } = this.circleDefaultConfig;
const { lineWidth, color, insideColor } = config;
const startRadian = (_startAngle ??= startAngle);
const endRadian = (_endAngle ??= endAngle);
let fillStyle;
if (typeof insideColor === "string") {
fillStyle = insideColor;
} else {
const grd = ctx.createRadialGradient(x, y, 5, x, y, radius);
const { inner, middle, out } = insideColor;
grd.addColorStop(0, inner);
grd.addColorStop(0.5, middle);
grd.addColorStop(1, out);
fillStyle = grd;
}
ctx.beginPath();
ctx.arc(x, y, radius, startRadian, endRadian, false);
ctx.fillStyle = fillStyle;
ctx.fill();
ctx.lineWidth = lineWidth;
ctx.strokeStyle = color;
ctx.lineCap = "round";
ctx.stroke();
ctx.closePath();
};
3. 定义绘制小圆球函数
绘制小圆球,小圆球是具有动画的,唯一是需要注意的就是这个小圆圈是在外层圆上面的,所以小圆球的坐标位置是动态计算的。我在代码中输出了坐标的计算公式。
如果仔细阅读代码的话,我想你看到了
angle - 90
。那么这里为什么减去90?
Canvas 中,角度是从圆的右侧(即 3 点钟方向)开始,逆时针方向为正。
角度起始点:
在数学上,标准的极坐标系中,角度是从 x 轴的正方向(即右侧)开始计算的。
在 Canvas 中,角度也是从 x 轴的正方向开始,逆时针方向为正。
圆的绘制起始位置:
在许多情况下,尤其是在绘制进度条等图形时,我们希望从圆的顶部(即 12 点钟方向)开始绘制。
圆的顶部对应的角度是 -90 度(或 270 度),因为它在 x 轴的正方向逆时针旋转了 90 度。
调整角度:
为了使绘制的起点从顶部开始,需要将计算的角度减去 90 度。
例如,如果我们计算出一个角度 angle,这个角度是从 x 轴的正方向开始的,为了使其从顶部开始,我们需要减去 90 度,即angle - 90
。
// 绘制小圆球
drawSmallCircle = (config: CircularProgressBarProps, percent: number) => {
const { ctx, startAngle, endAngle } = this;
const { x, y, radius } = this.circleDefaultConfig;
// 圆弧的角度
const angle = Number(percent / 100) * 360;
// 圆心坐标:(x0, y0)
// 半径:r
// 弧度:a => 圆弧计算公式:(角度 * Math.PI) / 180
// 则圆上任一点为:(x1, y1)
// x1 = x0 + r * cos(a)
// y1 = y0 + r * sin(a)
const { smallCircleR, smallCircleLineWidth, smallCircleFillStyle } = config;
const x1 = x + radius * Math.cos(((angle - 90) * Math.PI) / 180);
const y1 = y + radius * Math.sin(((angle - 90) * Math.PI) / 180);
ctx.beginPath();
ctx.arc(x1, y1, smallCircleR, startAngle, endAngle);
ctx.lineWidth = smallCircleLineWidth;
ctx.fillStyle = smallCircleFillStyle;
ctx.fill();
ctx.stroke();
ctx.closePath();
};
4. 定义绘制进度百分比文字函数
绘制文字,需要注意文字位于圆的正中央,Canvas提供了计算文字尺寸的API,且通过画布的宽高,可以轻松的计算出文字的坐标位置。
绘制百分号,我这里绘制的百分号大小为文字大小的一半,这样显示效果更美观。然后就是计算调整百分号的位置了。
// 绘制百分比
drawPercentage = ({
percentageFont,
percentageFillStyle,
insideValue,
showPercentSign,
percent,
}: {
percentageFont: string;
percentageFillStyle: string;
insideValue: number | string;
showPercentSign: boolean;
percent: string | number;
}) => {
const { ctx, width, height } = this;
ctx.font = percentageFont;
ctx.fillStyle = percentageFillStyle;
const ratioStr = `${(parseFloat(`${percent}`)).toFixed(0)}`;
const text = ctx.measureText(ratioStr);
ctx.fillText(
ratioStr,
width / 2 - text.width / 2,
height / 2 + (text.width * Number(showPercentSign)) / ratioStr.length / 2
);
if (showPercentSign) {
const reg = /(\d)+(px)/;
const persentFont = percentageFont.replace(reg, (a) => {
const fontSize = a.split("").slice(0, -2);
return `${(Number(fontSize.join("")) * 0.5).toFixed(0)}px`;
});
ctx.font = persentFont;
ctx.fillStyle = percentageFillStyle;
const percentStr = "%";
const percentText = ctx.measureText(percentStr);
ctx.fillText(
percentStr,
width / 2 + text.width / 4 + (percentText.width * 2) / 3,
height / 2 + text.width / ratioStr.length / 2 - 2
);
}
};
5.绘制标题
// 绘制文字
drawText = ({
textFont,
textFillStyle,
textContent,
}: {
textFont: string;
textFillStyle: string;
textContent: string;
}) => {
const { ctx, width, height } = this;
const measureText = ctx.measureText(textContent);
ctx.font = textFont;
ctx.fillStyle = textFillStyle;
ctx.fillText(textContent, width / 2 - measureText.width / 2, height * 0.75);
};
第三步,制作动画
这也是最后一步,动画需要从0 到 percent
通过requestAnimationFrame来实现,还需要定义一个步长,该步长可以控制动画的执行速度。
const makeAnimation = (config: CircularProgressBarProps) => {
const { percent } = config;
const id = window.requestAnimationFrame(() => {
this.makeAnimation(config);
});
this.drawCircularProgressBar(this.speed);
if (this.speed >= +percent) {
this.drawCircularProgressBar(percent);
window.cancelAnimationFrame(id);
this.speed = 0;
return;
}
this.speed += this.stepSpeed;
};