在微信小程序开发中,仪表盘是数据可视化的重要载体,而环形进度条与背景图的重叠效果更是提升UI质感的关键。但当设计师给出这样的需求时------固定背景图上叠加动态环形进度条,进度条需支持双渐变色彩和流畅动画------许多开发者会陷入困境:Canvas绘制的精度问题、不同设备的像素适配、渐变色彩的自然过渡,每一个环节都可能成为技术卡点。
本文将以Taro框架为基础,通过完整的技术方案,带你解决这些痛点。我们会从基础的Canvas绘制原理讲起,逐步深入到双渐变实现、动画优化和跨设备适配,最终呈现一个视觉效果惊艳的仪表盘组件。无论你是初涉小程序开发的新手,还是希望提升UI实现能力的资深开发者,都能从中找到有价值的实战经验。
需求分析与技术选型
让我们先明确目标效果。一个典型的仪表盘界面通常包含:一张固定的背景图片(可能包含刻度、单位等静态元素),以及一个或多个动态显示数据的环形进度条。进度条需要覆盖在背景图的特定位置,并且能够根据数据变化实时更新。更复杂的场景还会要求进度条具有渐变色、动画效果,以及在不同尺寸的设备上保持清晰的显示效果。
在微信小程序中实现这样的效果,有几种常见的技术路径:
- 使用原生组件组合:通过view和css实现简单的环形进度条,但难以处理复杂的渐变和动画效果。
- 使用第三方UI组件库:如Vant Weapp等提供的进度条组件,虽然开发速度快,但定制化程度有限,难以完美匹配设计需求。
- 使用Canvas绘制:这是最灵活也最具挑战性的方案,能够实现各种复杂的视觉效果,但需要开发者手动处理绘制逻辑、动画帧和设备适配。
考虑到需求中的双渐变和重叠效果,我们选择Canvas绘制作为核心技术方案。而Taro框架的跨端能力和对React语法的支持,能让我们的代码更易于维护和扩展。
Canvas绘制基础:环形进度条的实现原理
在开始复杂的实现之前,我们需要先掌握Canvas绘制环形进度条的基本原理。一个环形进度条本质上是由两个圆弧组成的:一个背景圆弧和一个前景圆弧。前景圆弧的长度根据进度值动态变化,从而形成进度展示的效果。
首先,我们需要在Taro组件中创建一个Canvas上下文。在Taro中,可以通过useRef钩子来获取Canvas实例,并在useEffect钩子中初始化绘制环境:
复制
ini
import { useRef, useEffect } from 'react';
import Taro from '@tarojs/taro';
const Dashboard = () => {
const canvasRef = useRef(null);
let ctx = null;
useEffect(() => {
const initCanvas = async () => {
// 获取Canvas实例
const canvas = canvasRef.current;
if (!canvas) return;
// 获取Canvas上下文
ctx = Taro.createCanvasContext('dashboardCanvas', canvas);
// 设置Canvas尺寸(后面会详细讲解像素比适配)
const { width, height } = await Taro.getSystemInfoSync();
const dpr = Taro.getSystemInfoSync().pixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
ctx.scale(dpr, dpr);
// 初始绘制
drawProgress(0);
};
initCanvas();
}, []);
// 绘制进度条的函数
const drawProgress = (progress) => {
// 清除画布
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
// 绘制背景圆弧
drawArc(0, '#e5e5e5');
// 绘制前景圆弧(进度)
drawArc(progress, '#007aff');
};
// 绘制圆弧的函数
const drawArc = (progress, color) => {
const centerX = canvasWidth / 2;
const centerY = canvasHeight / 2;
const radius = 100; // 圆弧半径
const startAngle = -Math.PI / 2; // 起始角度(12点方向)
const endAngle = startAngle + 2 * Math.PI * progress; // 结束角度
ctx.beginPath();
ctx.arc(centerX, centerY, radius, startAngle, endAngle, false);
ctx.lineWidth = 10; // 圆弧宽度
ctx.strokeStyle = color;
ctx.stroke();
};
return (
<Canvas ref={canvasRef} canvasId="dashboardCanvas" style={{ width: '100%', height: '100%' }} />
);
};
export default Dashboard;
这段代码展示了绘制环形进度条的基本框架。我们通过arc方法绘制圆弧,其中startAngle和endAngle参数控制圆弧的起始和结束位置。注意,Canvas的角度是以弧度为单位的,且0弧度对应3点方向,所以我们将起始角度设为-Math.PI / 2(即12点方向)。
双渐变效果:从单色到多彩的进阶
基础的单色环形进度条已经实现,但设计师往往要求更丰富的色彩效果。双渐变------即进度条从一种颜色平滑过渡到另一种颜色------能极大提升视觉吸引力。实现这一效果的关键在于Canvas的createLinearGradient或createRadialGradient方法。
以下是实现双渐变环形进度条的代码改造:
复制
ini
// 修改drawArc函数,支持渐变颜色
const drawArc = (progress, startColor, endColor) => {
const centerX = canvasWidth / 2;
const centerY = canvasHeight / 2;
const radius = 100;
const startAngle = -Math.PI / 2;
const endAngle = startAngle + 2 * Math.PI * progress;
const lineWidth = 10;
// 创建渐变
const gradient = ctx.createLinearGradient(
centerX - radius,
centerY - radius,
centerX + radius,
centerY + radius
);
gradient.addColorStop(0, startColor);
gradient.addColorStop(1, endColor);
ctx.beginPath();
ctx.arc(centerX, centerY, radius, startAngle, endAngle, false);
ctx.lineWidth = lineWidth;
ctx.strokeStyle = gradient; // 使用渐变作为描边颜色
ctx.lineCap = 'round'; // 可选:设置线帽为圆形,使进度条两端更圆润
ctx.stroke();
};
// 调用时传入渐变的起始色和结束色
drawArc(progress, '#4cd964', '#5ac8fa');
这里使用了线性渐变createLinearGradient,参数分别是渐变起点和终点的坐标。通过addColorStop方法,我们可以定义渐变的颜色节点。0表示渐变的起始点,1表示渐变的结束点。
但线性渐变可能无法满足所有设计需求。有时我们需要的是沿着圆弧方向的渐变,即从起始角度的颜色渐变到结束角度的颜色。这种情况下,线性渐变和径向渐变都无法直接实现,需要更复杂的计算。
一种解决方案是使用颜色插值:将进度条分成多个小段,每段使用不同的颜色,通过颜色值的线性插值实现渐变效果。以下是一个简化的实现:
复制
ini
// 颜色插值函数
const colorLerp = (a, b, t) => {
const hexToRgb = (hex) => {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return { r, g, b };
};
const rgbToHex = (r, g, b) => {
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
};
const { r: r1, g: g1, b: b1 } = hexToRgb(a);
const { r: r2, g: g2, b: b2 } = hexToRgb(b);
const r = Math.round(r1 + (r2 - r1) * t);
const g = Math.round(g1 + (g2 - g1) * t);
const b = Math.round(b1 + (b2 - b1) * t);
return rgbToHex(r, g, b);
};
// 分段绘制渐变圆弧
const drawGradientArc = (progress, startColor, endColor) => {
const centerX = canvasWidth / 2;
const centerY = canvasHeight / 2;
const radius = 100;
const startAngle = -Math.PI / 2;
const totalAngle = 2 * Math.PI * progress;
const lineWidth = 10;
const segments = 50; // 将圆弧分成50段绘制
for (let i = 0; i < segments; i++) {
const segmentAngle = totalAngle / segments;
const currentStartAngle = startAngle + i * segmentAngle;
const currentEndAngle = currentStartAngle + segmentAngle;
const t = i / segments; // 0到1之间的值,用于颜色插值
const currentColor = colorLerp(startColor, endColor, t);
ctx.beginPath();
ctx.arc(centerX, centerY, radius, currentStartAngle, currentEndAngle, false);
ctx.lineWidth = lineWidth;
ctx.strokeStyle = currentColor;
ctx.lineCap = 'butt'; // 线段端点设为直角,避免段与段之间的间隙
ctx.stroke();
}
};
这种方法通过将圆弧分成多个小段,每段使用插值得到的颜色,从而模拟出沿圆弧方向的渐变效果。分段数量越多,渐变效果越平滑,但也会增加绘制开销。在实际项目中,需要根据性能需求和视觉效果进行权衡。
背景图重叠与定位:精确控制绘制层级
实现了环形进度条后,下一步是将其与背景图重叠。在Taro中,有两种常见的实现方式:一种是使用绝对定位将Canvas覆盖在图片上方,另一种是直接在Canvas中绘制背景图。
第一种方案实现简单,但可能存在层级和事件穿透的问题。第二种方案更灵活,能够精确控制绘制顺序和位置,是我们推荐的做法。
复制
javascript
// 在Canvas中绘制背景图
const drawBackground = async () => {
return new Promise((resolve) => {
const img = Taro.createImage();
img.src = '/images/dashboard-bg.png'; // 背景图路径
img.onload = () => {
// 绘制背景图,使其充满整个Canvas
ctx.drawImage(img, 0, 0, canvasWidth, canvasHeight);
resolve();
};
img.onerror = (err) => {
console.error('背景图加载失败', err);
resolve(); // 即使失败也继续执行后续绘制
};
});
};
// 修改初始化绘制流程
const initCanvas = async () => {
// ... 前面的Canvas初始化代码 ...
// 先绘制背景图,再绘制进度条
await drawBackground();
drawProgress(0);
};
// 更新进度时,需要先重绘背景图,再绘制新的进度
const updateProgress = (progress) => {
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
drawBackground().then(() => {
drawArc(progress, '#4cd964', '#5ac8fa');
// 如果使用了分段渐变,这里调用drawGradientArc
});
};
这里需要注意图片加载的异步性。我们将图片加载封装在一个Promise中,确保背景图加载完成后再进行后续绘制。在更新进度时,也需要重新绘制背景图,以清除上一次的进度条绘制结果。
背景图的定位是另一个需要注意的问题。如果背景图不是充满整个Canvas,而是需要定位到特定位置,或者只显示部分区域,就需要调整drawImage的参数:
arduino
// drawImage的完整参数
ctx.drawImage(
img,
sx, sy, sWidth, sHeight, // 源图像的裁剪区域
dx, dy, dWidth, dHeight // 目标Canvas上的绘制区域
);
通过精确控制这些参数,我们可以实现背景图的任意定位和缩放。例如,如果仪表盘的背景图只需要显示中间的圆形区域,可以这样处理:
ini
// 假设背景图是一个200x200的正方形,我们只需要中间100x100的圆形区域
const sx = 50, sy = 50; // 源图像裁剪起点
const sWidth = 100, sHeight = 100; // 裁剪宽度和高度
const dx = canvasWidth/2 - 50, dy = canvasHeight/2 - 50; // 绘制位置(居中)
const dWidth = 100, dHeight = 100; // 绘制宽度和高度
ctx.drawImage(img, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);
缓动动画:让进度更新更自然
静态的进度条已经能够展示数据,但流畅的动画效果能极大提升用户体验。实现动画的核心是通过requestAnimationFrame或setTimeout等方法,逐步更新进度值并重新绘制。
以下是一个基础的动画实现:
ini
// 动画函数
const animateProgress = (start, end, duration = 1000) => {
const startTime = Date.now();
const step = () => {
const now = Date.now();
const elapsed = now - startTime;
const t = Math.min(elapsed / duration, 1); // 计算时间进度,限制在0-1之间
// 使用缓动函数使动画更自然
const progress = easeOutQuad(t) * (end - start) + start;
// 更新进度
updateProgress(progress);
if (t < 1) {
// 继续下一帧动画
requestAnimationFrame(step);
}
};
// 开始动画
requestAnimationFrame(step);
};
// 缓动函数:easeOutQuad(先快后慢)
const easeOutQuad = (t) => t * (2 - t);
这里使用了requestAnimationFrame来实现平滑动画,它会根据浏览器的刷新频率自动调整绘制时机,通常是每秒60次。相比setTimeout,能提供更流畅的动画效果和更低的性能消耗。
缓动函数easeOutQuad使动画呈现出先快后慢的效果,比线性变化更符合人的视觉习惯。除了easeOutQuad,常用的缓动函数还有easeIn(先慢后快)、easeInOut(先慢后快再慢)等。你可以根据需要选择合适的缓动函数,或者自定义缓动效果。
以下是一些常用的缓动函数实现:
javascript
// 线性(无缓动)
const linear = (t) => t;
// 先慢后快
const easeInQuad = (t) => t * t;
// 先快后慢再快
const easeInOutCubic = (t) => {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
};
在实际项目中,你可能需要为不同的数据变化场景设置不同的动画时长。例如,小幅度的进度变化使用较短的动画,而大幅度的变化使用较长的动画,以保持视觉上的协调。
像素比适配:解决模糊问题
在高分辨率屏幕上,Canvas绘制的图形可能会出现模糊现象。这是因为不同设备的像素密度(DPR,Device Pixel Ratio)不同,一个CSS像素可能对应多个物理像素。为了让绘制的图形在所有设备上都保持清晰,我们需要进行像素比适配。
ini
// 在初始化Canvas时考虑像素比
const initCanvas = async () => {
const canvas = canvasRef.current;
if (!canvas) return;
const systemInfo = await Taro.getSystemInfo();
const dpr = systemInfo.pixelRatio || 1; // 获取设备像素比
const { windowWidth, windowHeight } = systemInfo;
// 设置Canvas的实际尺寸(物理像素)
canvas.width = windowWidth * dpr;
canvas.height = windowHeight * dpr;
// 设置Canvas的CSS尺寸(逻辑像素)
canvas.style.width = `${windowWidth}px`;
canvas.style.height = `${windowHeight}px`;
// 缩放Canvas上下文,使绘制坐标与CSS像素对应
ctx.scale(dpr, dpr);
// 后续的绘制代码...
};
通过将Canvas的实际尺寸设置为CSS尺寸乘以像素比,我们确保了Canvas有足够的物理像素来显示清晰的图像。然后通过scale方法缩放绘图上下文,使得我们在代码中仍然可以使用CSS像素进行坐标计算,简化了绘制逻辑。
除了Canvas本身的适配,背景图也需要考虑像素比。如果背景图是位图,需要提供不同分辨率的版本,根据设备像素比选择加载。在Taro中,可以使用@tarojs/taro的getSystemInfo方法获取设备信息,然后动态选择合适的图片资源。
复制
kotlin
// 根据像素比选择不同分辨率的背景图
const getBackgroundImageUrl = (dpr) => {
if (dpr >= 3) {
return '/images/dashboard-bg@3x.png';
} else if (dpr >= 2) {
return '/images/dashboard-bg@2x.png';
} else {
return '/images/dashboard-bg.png';
}
};
// 在drawBackground中使用
const img = Taro.createImage();
img.src = getBackgroundImageUrl(dpr);
通过这些适配措施,我们可以确保仪表盘在各种设备上都能呈现出清晰、锐利的视觉效果。
性能优化:避免过度绘制与内存泄漏
随着功能的丰富,Canvas绘制可能会变得复杂,导致性能问题。以下是一些优化建议:
- 减少绘制频率:避免在短时间内频繁调用绘制函数。可以使用节流(throttle)或防抖(debounce)技术,控制绘制频率。
- 合理使用离屏Canvas:对于复杂但不常变化的元素(如背景图),可以绘制到离屏Canvas,需要时再绘制到主Canvas,减少重复计算。
- 及时清除定时器和事件监听:在组件卸载时,确保清除所有未完成的动画帧和定时器,避免内存泄漏。
复制
scss
useEffect(() => {
// 初始化代码...
// 组件卸载时的清理函数
return () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
if (timerId) {
clearTimeout(timerId);
}
};
}, []);
- 简化路径计算:在绘制复杂图形时,尽量减少不必要的路径点,优化绘制算法。
- 使用硬件加速:虽然Canvas本身已经是GPU加速的,但避免在Canvas上叠加过多其他元素,以免触发复合层合并,影响性能。
通过这些优化措施,我们可以在保证视觉效果的同时,保持小程序的流畅运行。
完整组件封装与使用示例
将以上所有功能整合起来,我们可以封装一个通用的仪表盘组件。以下是组件的核心代码结构:
ini
import { useRef, useEffect, useState } from 'react';
import Taro, { Canvas } from '@tarojs/components';
import { getSystemInfo } from '@tarojs/taro';
const Dashboard = ({
backgroundImage, // 背景图路径
radius = 100, // 进度条半径
lineWidth = 10, // 进度条宽度
startColor = '#4cd964', // 进度条起始颜色
endColor = '#5ac8fa', // 进度条结束颜色
value = 0, // 当前进度值(0-1)
duration = 1000, // 动画时长
easing = 'easeOutQuad' // 缓动函数
}) => {
const canvasRef = useRef(null);
const [ctx, setCtx] = useState(null);
const [dpr, setDpr] = useState(1);
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
let animationFrameId = null;
// 初始化Canvas
useEffect(() => {
const initCanvas = async () => {
const systemInfo = await getSystemInfo();
const dpr = systemInfo.pixelRatio || 1;
const { windowWidth, windowHeight } = systemInfo;
const canvas = canvasRef.current;
if (!canvas) return;
// 设置Canvas尺寸
canvas.width = windowWidth * dpr;
canvas.height = windowHeight * dpr;
canvas.style.width = `${windowWidth}px`;
canvas.style.height = `${windowHeight}px`;
const ctx = Taro.createCanvasContext('dashboard', canvas);
ctx.scale(dpr, dpr);
setCtx(ctx);
setDpr(dpr);
setCanvasSize({ width: windowWidth, height: windowHeight });
// 初始绘制
drawDashboard(value);
};
initCanvas();
// 清理函数
return () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
};
}, []);
// 当value变化时触发动画
useEffect(() => {
if (ctx) {
animateValue(value);
}
}, [value, ctx]);
// 绘制整个仪表盘
const drawDashboard = (progress) => {
if (!ctx) return;
// 清除画布
ctx.clearRect(0, 0, canvasSize.width, canvasSize.height);
// 绘制背景图
drawBackground().then(() => {
// 绘制进度条
drawProgress(progress);
});
};
// 绘制背景图
const drawBackground = () => {
return new Promise((resolve) => {
if (!backgroundImage) {
resolve();
return;
}
const img = Taro.createImage();
img.src = backgroundImage;
img.onload = () => {
ctx.drawImage(img, 0, 0, canvasSize.width, canvasSize.height);
resolve();
};
img.onerror = () => {
console.error('背景图加载失败');
resolve();
};
});
};
// 绘制进度条
const drawProgress = (progress) => {
// 这里实现前面讲过的环形进度条绘制逻辑,支持双渐变
// ...
};
// 动画更新进度值
const animateValue = (targetValue) => {
// 这里实现前面讲过的动画逻辑,使用缓动函数
// ...
};
return (
<Canvas ref={canvasRef} canvasId="dashboard" style={{ width: '100%', height: '100%' }} />
);
};
export default Dashboard;
使用这个组件非常简单,只需在父组件中引入并传入必要的参数:
javascript
import Dashboard from './components/Dashboard';
const Home = () => {
const [progress, setProgress] = useState(0.75); // 75%的进度
return (
<Dashboard backgroundImage="/images/dashboard-bg.png" radius={120} lineWidth={15} startColor="#ff9500" endColor="#ff2d55" value={progress} duration={1500} />
);
};
通过这种封装,我们得到了一个灵活、可复用的仪表盘组件,能够满足各种数据可视化需求。
总结与扩展
通过本文的讲解,我们从基础的Canvas绘制开始,逐步实现了环形进度条的双渐变效果、背景图重叠、动画优化和像素比适配,最终封装了一个功能完善的仪表盘组件。这个过程中,我们解决了以下关键技术问题:
- Canvas绘制精度:通过像素比适配,确保图形在不同设备上都清晰显示。
- 渐变色彩实现:使用Canvas的渐变API和颜色插值技术,实现了丰富的色彩效果。
- 图层管理:通过Canvas的绘制顺序控制,实现了背景图与进度条的精确重叠。
- 动画性能:使用requestAnimationFrame和缓动函数,实现了流畅且视觉舒适的动画效果。
这个仪表盘组件还可以进一步扩展,例如支持多个进度条、添加数值文本显示、实现交互功能(如点击不同区域显示不同数据)等。以下是一些可能的扩展方向:
- 多进度条支持:通过传入一个进度值数组,绘制多个同心或不同位置的环形进度条。
- 文本显示:在进度条中心或其他位置绘制数值文本,显示当前进度的具体数值。
- 交互功能:监听Canvas的触摸事件,实现点击、滑动等交互操作。
- 主题定制:支持自定义背景图、进度条颜色、尺寸等,适应不同的设计风格。
希望本文的技术方案能够帮助你解决实际项目中的问题,也欢迎你在此基础上进行创新和优化。如果你有更好的实现方法或扩展思路,欢迎在评论区交流分享!
不绘制背景图的版本:
js
import React, { useEffect, useRef } from 'react';
import { Canvas, View } from '@tarojs/components';
import Taro from '@tarojs/taro';
interface ArcProgressProps {
percentage: number; // 0-100
width?: number; // 组件宽度 (px)
height?: number; // 组件高度 (px)
strokeWidth?: number; // 进度条宽度 (px)
startAngle?: number; // 开始角度(角度制,3点钟方向为0,顺时针)
endAngle?: number; // 结束角度
colorStart?: string; // 渐变起始色
colorEnd?: string; // 渐变结束色
trackColor?: string; // 轨道颜色
centerXOffset?: number; // 中心点X轴偏移 (px)
centerYOffset?: number; // 中心点Y轴偏移 (px)
animate?: boolean; // 是否开启动画
animationDuration?: number; // 动画时长 (ms)
knobRadius?: number; // 旋钮半径 (px)
trackColorStart?: string; // 轨道渐变起始色
trackColorEnd?: string; // 轨道渐变结束色
radius?: number; // 自定义半径 (px)
}
const ArcProgress: React.FC<ArcProgressProps> = ({
percentage,
width = 265,
height = 160,
strokeWidth = 12,
startAngle = 160,
endAngle = 380,
colorStart = '#2F86F6',
colorEnd = '#00CE61',
centerXOffset = 0,
centerYOffset = 0,
animate = true,
animationDuration = 1000,
knobRadius = 7.5, // 默认旋钮半径
trackColorStart = '#E3ECFC', // 轨道渐变起始色
trackColorEnd = '#E3F6FC', // 轨道渐变结束色
radius = 90, // 默认半径
}) => {
// 生成唯一 canvas id
const canvasId = useRef(`canvas-${Math.random().toString(36).slice(2, 9)}`);
// 动画当前进度
const [currentPercentage, setCurrentPercentage] = React.useState(
animate ? 0 : percentage
);
// 动画逻辑
useEffect(() => {
if (!animate) {
setCurrentPercentage(percentage);
return;
}
let startTime: number | null = null;
let animationFrameId: number;
const startValue = 0; // 总是从0开始,或者可以改为从上次位置开始
const changeValue = percentage - startValue;
const animateStep = (timestamp: number) => {
if (!startTime) startTime = timestamp;
const progress = timestamp - startTime;
const duration = animationDuration;
// Ease-out cubic
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
let percent = Math.min(progress / duration, 1);
const easedProgress = easeOutCubic(percent);
const currentValue = startValue + changeValue * easedProgress;
setCurrentPercentage(currentValue);
if (progress < duration) {
// 使用 Taro 的 requestAnimationFrame 或者 window
// 在小程序中通常需要 canvas 实例的 requestAnimationFrame,但在 React 组件逻辑中
// 我们可以使用全局 requestAnimationFrame (Taro 适配)
animationFrameId = requestAnimationFrame(animateStep);
}
};
animationFrameId = requestAnimationFrame(animateStep);
return () => {
if (animationFrameId) cancelAnimationFrame(animationFrameId);
};
}, [percentage, animate, animationDuration]);
useEffect(() => {
const draw = () => {
const query = Taro.createSelectorQuery();
query
.select(`#${canvasId.current}`)
.fields({ node: true, size: true })
.exec((res) => {
if (!res[0] || !res[0].node) return;
const canvas = res[0].node;
const ctx = canvas.getContext('2d');
// 处理像素比,保证清晰度
const dpr = Taro.getSystemInfoSync().pixelRatio;
const canvasWidth = res[0].width;
const canvasHeight = res[0].height;
canvas.width = canvasWidth * dpr;
canvas.height = canvasHeight * dpr;
// 计算缩放比例:将 Canvas 坐标系从"实际像素"映射回"设计稿尺寸"
// 这样我们可以直接使用 props 中的 width/height/strokeWidth 进行绘制,
// Canvas 会自动帮我们要绘制的内容缩放到适应容器大小。
const scaleX = canvasWidth / width;
const scaleY = canvasHeight / height;
ctx.scale(dpr * scaleX, dpr * scaleY);
// --- 绘制逻辑开始 (使用设计稿坐标) ---
// 默认底部居中:
// X轴中心 = width / 2
// Y轴中心 = height
// 加上偏移量允许微调
const centerX = width / 2 + centerXOffset;
const centerY = height + centerYOffset;
// 半径计算:
// 使用设计稿宽度计算,预留描边宽度
// const radius = (width - strokeWidth * 2) / 2;
// 角度转换:角度 -> 弧度
const startRad = (startAngle * Math.PI) / 180;
const endRad = (endAngle * Math.PI) / 180;
// 计算当前进度角度
const totalAngle = endAngle - startAngle;
const p = Math.min(Math.max(currentPercentage, 0), 100);
const currentAngle = startAngle + (totalAngle * p) / 100;
const currentRad = (currentAngle * Math.PI) / 180;
// 清空画布 (使用设计稿尺寸)
ctx.clearRect(0, 0, width, height);
// 1. 绘制轨道 (Track)
// 轨道使用渐变:trackColorStart -> trackColorEnd
ctx.beginPath();
ctx.arc(centerX, centerY, radius, startRad, endRad);
ctx.lineCap = 'round';
ctx.lineWidth = strokeWidth; // 直接使用 props 的 strokeWidth
const trackStartX = centerX + radius * Math.cos(startRad);
const trackStartY = centerY + radius * Math.sin(startRad);
const trackEndX = centerX + radius * Math.cos(endRad);
const trackEndY = centerY + radius * Math.sin(endRad);
const trackGradient = ctx.createLinearGradient(
trackStartX,
trackStartY,
Math.abs(trackEndX - trackStartX) < 1 ? trackStartX + 1 : trackEndX,
Math.abs(trackEndY - trackStartY) < 1 ? trackStartY : trackEndY
);
trackGradient.addColorStop(0, trackColorStart);
trackGradient.addColorStop(1, trackColorEnd);
ctx.strokeStyle = trackGradient;
ctx.stroke();
// 2. 绘制进度条 (Progress)
// 计算渐变:从起点到当前的终点
const startX = centerX + radius * Math.cos(startRad);
const startY = centerY + radius * Math.sin(startRad);
const endX = centerX + radius * Math.cos(currentRad);
const endY = centerY + radius * Math.sin(currentRad);
const gradient = ctx.createLinearGradient(
startX,
startY,
Math.abs(endX - startX) < 1 ? startX + 1 : endX,
Math.abs(endY - startY) < 1 ? startY : endY
);
gradient.addColorStop(0, colorStart);
gradient.addColorStop(1, colorEnd);
ctx.beginPath();
ctx.arc(centerX, centerY, radius, startRad, currentRad);
ctx.lineCap = 'round';
ctx.lineWidth = strokeWidth;
ctx.strokeStyle = gradient;
ctx.stroke();
// 3. 绘制尾部旋钮 (Knob)
const knobX = centerX + radius * Math.cos(currentRad);
const knobY = centerY + radius * Math.sin(currentRad);
ctx.save(); // 保存状态
// 3.1 阴影配置
ctx.shadowColor = 'rgba(0, 128, 255, 0.2)';
ctx.shadowBlur = 8; // 自动被 scale 影响
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
// 3.2 渐变背景
const gradientSize = knobRadius * 1.5;
const kGradient = ctx.createLinearGradient(
knobX + gradientSize,
knobY + gradientSize,
knobX - gradientSize,
knobY - gradientSize
);
kGradient.addColorStop(0, '#2BB99D');
kGradient.addColorStop(1, '#2EF5D4');
ctx.beginPath();
ctx.arc(knobX, knobY, knobRadius, 0, 2 * Math.PI);
ctx.fillStyle = kGradient;
ctx.fill();
// 3.3 白色边框
ctx.strokeStyle = '#FFFFFF';
ctx.lineWidth = 2; // 自动被 scale 影响
ctx.stroke();
ctx.restore(); // 恢复状态
});
};
// 使用 requestAnimationFrame 优化绘制频率,或者简单的 setTimeout
// 在动画过程中,currentPercentage 变化频繁,直接调用 draw
draw();
}, [
currentPercentage, // 依赖 currentPercentage 触发重绘
width,
height,
strokeWidth,
startAngle,
endAngle,
colorStart,
colorEnd,
centerXOffset,
centerYOffset,
animate,
animationDuration,
knobRadius,
trackColorStart,
trackColorEnd,
radius,
]);
return (
<View
style={{
width: Taro.pxTransform(width),
height: Taro.pxTransform(height),
position: 'relative',
}}
>
<Canvas
type='2d'
id={canvasId.current}
style={{ width: '100%', height: '100%' }}
/>
</View>
);
};
export default ArcProgress;