使用Taro实现微信小程序仪表盘:使用canvas实现仪表盘(有仪表盘背景,也可以用于Web等)

在微信小程序开发中,仪表盘是数据可视化的重要载体,而环形进度条与背景图的重叠效果更是提升UI质感的关键。但当设计师给出这样的需求时------固定背景图上叠加动态环形进度条,进度条需支持双渐变色彩和流畅动画------许多开发者会陷入困境:Canvas绘制的精度问题、不同设备的像素适配、渐变色彩的自然过渡,每一个环节都可能成为技术卡点。

本文将以Taro框架为基础,通过完整的技术方案,带你解决这些痛点。我们会从基础的Canvas绘制原理讲起,逐步深入到双渐变实现、动画优化和跨设备适配,最终呈现一个视觉效果惊艳的仪表盘组件。无论你是初涉小程序开发的新手,还是希望提升UI实现能力的资深开发者,都能从中找到有价值的实战经验。

需求分析与技术选型

让我们先明确目标效果。一个典型的仪表盘界面通常包含:一张固定的背景图片(可能包含刻度、单位等静态元素),以及一个或多个动态显示数据的环形进度条。进度条需要覆盖在背景图的特定位置,并且能够根据数据变化实时更新。更复杂的场景还会要求进度条具有渐变色、动画效果,以及在不同尺寸的设备上保持清晰的显示效果。

在微信小程序中实现这样的效果,有几种常见的技术路径:

  1. 使用原生组件组合:通过view和css实现简单的环形进度条,但难以处理复杂的渐变和动画效果。
  2. 使用第三方UI组件库:如Vant Weapp等提供的进度条组件,虽然开发速度快,但定制化程度有限,难以完美匹配设计需求。
  3. 使用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绘制可能会变得复杂,导致性能问题。以下是一些优化建议:

  1. 减少绘制频率:避免在短时间内频繁调用绘制函数。可以使用节流(throttle)或防抖(debounce)技术,控制绘制频率。
  2. 合理使用离屏Canvas:对于复杂但不常变化的元素(如背景图),可以绘制到离屏Canvas,需要时再绘制到主Canvas,减少重复计算。
  3. 及时清除定时器和事件监听:在组件卸载时,确保清除所有未完成的动画帧和定时器,避免内存泄漏。

复制

scss 复制代码
useEffect(() => {
  // 初始化代码...

  // 组件卸载时的清理函数
  return () => {
    if (animationFrameId) {
      cancelAnimationFrame(animationFrameId);
    }
    if (timerId) {
      clearTimeout(timerId);
    }
  };
}, []);
  1. 简化路径计算:在绘制复杂图形时,尽量减少不必要的路径点,优化绘制算法。
  2. 使用硬件加速:虽然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绘制开始,逐步实现了环形进度条的双渐变效果、背景图重叠、动画优化和像素比适配,最终封装了一个功能完善的仪表盘组件。这个过程中,我们解决了以下关键技术问题:

  1. Canvas绘制精度:通过像素比适配,确保图形在不同设备上都清晰显示。
  2. 渐变色彩实现:使用Canvas的渐变API和颜色插值技术,实现了丰富的色彩效果。
  3. 图层管理:通过Canvas的绘制顺序控制,实现了背景图与进度条的精确重叠。
  4. 动画性能:使用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;
相关推荐
掘金安东尼2 小时前
VSCode V1.107 发布(2025 年 11 月)
前端·visual studio code
一只小阿乐2 小时前
前端vue3 web端中实现拖拽功能实现列表排序
前端·vue.js·elementui·vue3·前端拖拽
AAA阿giao2 小时前
从“操纵绳子“到“指挥木偶“:Vue3 Composition API 如何彻底改变前端开发范式
开发语言·前端·javascript·vue.js·前端框架·vue3·compositionapi
TextIn智能文档云平台2 小时前
图片转文字后怎么输入大模型处理
前端·人工智能·python
专注前端30年2 小时前
在日常开发项目中Vue与React应该如何选择?
前端·vue.js·react.js
文刀竹肃2 小时前
DVWA -XSS(DOM)-通关教程-完结
前端·安全·网络安全·xss
lifejump2 小时前
Pikachu | XSS
前端·xss
进击的野人2 小时前
Vue 组件与原型链:VueComponent 与 Vue 的关系解析
前端·vue.js·面试
馬致远3 小时前
Vue todoList案例 优化之本地存储
前端·javascript·vue.js