车载仪表盘、健康数据面板、运动数据环形图——这些不是普通图表,而是需要"仪表感"的视觉组件。Progress组件太简单,Canvas绘图太底层,两者的折中方案是用Shape+Path或Canvas画自定义表盘。这篇从最简单的环形仪表到复杂的多指针刻度盘,把自定义绘图的完整方案讲清楚。

环形仪表基础

最基础的仪表盘:一个弧线表示数值范围,一个填充弧表示当前值,中间显示数字。
在这里插入图片描述

用Canvas实现:

private settings: RenderingContextSettings = new RenderingContextSettings(true);
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);

Canvas(this.context)
  .width(200)
  .height(200)
  .onReady(() => {
    this.drawGauge(72, 100);
  })

private drawGauge(value: number, total: number): void {
  let ctx: CanvasRenderingContext2D = this.context;
  let centerX: number = 100;
  let centerY: number = 100;
  let radius: number = 70;
  let lineWidth: number = 14;

  ctx.clearRect(0, 0, 200, 200);

  // 背景弧
  ctx.beginPath();
  ctx.arc(centerX, centerY, radius, 0.75 * Math.PI, 0.25 * Math.PI);
  ctx.strokeStyle = '#e0e0e0';
  ctx.lineWidth = lineWidth;
  ctx.lineCap = 'round';
  ctx.stroke();

  // 数值弧
  let ratio: number = value / total;
  let startAngle: number = 0.75 * Math.PI;
  let totalAngle: number = 1.5 * Math.PI;
  let endAngle: number = startAngle + ratio * totalAngle;

  ctx.beginPath();
  ctx.arc(centerX, centerY, radius, startAngle, endAngle);
  ctx.strokeStyle = '#1a73e8';
  ctx.lineWidth = lineWidth;
  ctx.lineCap = 'round';
  ctx.stroke();

  // 中间数字
  ctx.fillStyle = '#333333';
  ctx.font = 'bold 36px sans-serif';
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillText(value.toString(), centerX, centerY - 8);

  ctx.fillStyle = '#999999';
  ctx.font = '12px sans-serif';
  ctx.fillText('km/h', centerX, centerY + 18);
}

0.75π到0.25π画一个3/4圆弧(从左下到右下,跳过底部1/4)。这是仪表盘最常见的弧度范围——底部留空显示标签。

刻度线

仪表盘不能只有弧线,还要有刻度线标记数值区间:

private drawTicks(ctx: CanvasRenderingContext2D, centerX: number, centerY: number,
                  radius: number, startAngle: number, totalAngle: number,
                  tickCount: number): void {
  let innerRadius: number = radius + 10;
  let outerRadius: number = radius + 20;

  for (let i: number = 0; i <= tickCount; i++) {
    let angle: number = startAngle + (i / tickCount) * totalAngle;
    let isMajor: boolean = i % 5 === 0;

    let inner: number = isMajor ? innerRadius : innerRadius + 5;
    let outer: number = outerRadius;

    let x1: number = centerX + Math.cos(angle) * inner;
    let y1: number = centerY + Math.sin(angle) * inner;
    let x2: number = centerX + Math.cos(angle) * outer;
    let y2: number = centerY + Math.sin(angle) * outer;

    ctx.beginPath();
    ctx.moveTo(x1, y1);
    ctx.lineTo(x2, y2);
    ctx.strokeStyle = isMajor ? '#333333' : '#cccccc';
    ctx.lineWidth = isMajor ? 2 : 1;
    ctx.stroke();

    // 主刻度标注数字
    if (isMajor) {
      let labelRadius: number = outer + 12;
      let lx: number = centerX + Math.cos(angle) * labelRadius;
      let ly: number = centerY + Math.sin(angle) * labelRadius;
      let labelValue: number = Math.round((i / tickCount) * 100);
      ctx.fillStyle = '#666666';
      ctx.font = '10px sans-serif';
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      ctx.fillText(labelValue.toString(), lx, ly);
    }
  }
}

tickCount=50画50个刻度(每5个一个大刻度)。大刻度更长更粗,小刻度短而细。大刻度旁标注数字。

指针

仪表盘最经典的视觉元素——指针。用三角形绘制:

private drawNeedle(ctx: CanvasRenderingContext2D, centerX: number, centerY: number,
                   value: number, total: number, startAngle: number, totalAngle: number): void {
  let ratio: number = value / total;
  let angle: number = startAngle + ratio * totalAngle;
  let needleLength: number = 60;

  let tipX: number = centerX + Math.cos(angle) * needleLength;
  let tipY: number = centerY + Math.sin(angle) * needleLength;

  let baseAngle1: number = angle + Math.PI / 2;
  let baseAngle2: number = angle - Math.PI / 2;
  let baseWidth: number = 4;

  let bx1: number = centerX + Math.cos(baseAngle1) * baseWidth;
  let by1: number = centerY + Math.sin(baseAngle1) * baseWidth;
  let bx2: number = centerX + Math.cos(baseAngle2) * baseWidth;
  let by2: number = centerY + Math.sin(baseAngle2) * baseWidth;

  ctx.beginPath();
  ctx.moveTo(tipX, tipY);
  ctx.lineTo(bx1, by1);
  ctx.lineTo(bx2, by2);
  ctx.closePath();
  ctx.fillStyle = '#e53935';
  ctx.fill();

  // 中心圆点
  ctx.beginPath();
  ctx.arc(centerX, centerY, 6, 0, 2 * Math.PI);
  ctx.fillStyle = '#333333';
  ctx.fill();
}

指针是三角形:尖端指向数值角度,底边在中心点。baseAngle垂直于指针方向,底边宽度4px。中心圆点覆盖指针底部。

颜色分段

速度表的颜色分段——绿→黄→红:

private drawColorSegments(ctx: CanvasRenderingContext2D, centerX: number, centerY: number,
                          radius: number, lineWidth: number,
                          startAngle: number, totalAngle: number): void {
  let segments: { ratio: number; color: string }[] = [
    { ratio: 0.4, color: '#43a047' },
    { ratio: 0.7, color: '#ff9800' },
    { ratio: 1.0, color: '#e53935' }
  ];

  let prevAngle: number = startAngle;
  for (let i: number = 0; i < segments.length; i++) {
    let endAngle: number = startAngle + segments[i].ratio * totalAngle;
    ctx.beginPath();
    ctx.arc(centerX, centerY, radius, prevAngle, endAngle);
    ctx.strokeStyle = segments[i].color;
    ctx.lineWidth = lineWidth;
    ctx.lineCap = 'butt';
    ctx.stroke();
    prevAngle = endAngle;
  }
}

040%绿色,4070%橙色,70~100%红色。每段画一段弧,lineCap设butt避免段之间重叠。

渐变色仪表

单一颜色太无聊,渐变色更高级:

private drawGradientArc(ctx: CanvasRenderingContext2D, centerX: number, centerY: number,
                        radius: number, lineWidth: number,
                        startAngle: number, endAngle: number): void {
  let steps: number = 60;
  let angleStep: number = (endAngle - startAngle) / steps;

  for (let i: number = 0; i < steps; i++) {
    let ratio: number = i / steps;
    let a1: number = startAngle + i * angleStep;
    let a2: number = a1 + angleStep + 0.01;

    let r: number = Math.round(26 + ratio * (227 - 26));
    let g: number = Math.round(115 + ratio * (57 - 115));
    let b: number = Math.round(232 + ratio * (53 - 232));

    ctx.beginPath();
    ctx.arc(centerX, centerY, radius, a1, a2);
    ctx.strokeStyle = 'rgb(' + r + ',' + g + ',' + b + ')';
    ctx.lineWidth = lineWidth;
    ctx.lineCap = 'butt';
    ctx.stroke();
  }
}

把弧线分成60段,每段颜色从蓝(26,115,232)到红(227,57,53)线性插值。段之间加0.01弧度重叠避免间隙。

多环仪表

健康面板常见:外环步数、中环运动时间、内环消耗热量:

private drawMultiRing(ctx: CanvasRenderingContext2D, centerX: number, centerY: number): void {
  let rings: { radius: number; value: number; total: number; color: string; label: string }[] = [
    { radius: 80, value: 7500, total: 10000, color: '#1a73e8', label: '步数' },
    { radius: 60, value: 35, total: 60, color: '#43a047', label: '运动(分)' },
    { radius: 40, value: 280, total: 400, color: '#ff9800', label: '千卡' }
  ];

  for (let i: number = 0; i < rings.length; i++) {
    let ring = rings[i];
    let lineWidth: number = 12;

    // 背景环
    ctx.beginPath();
    ctx.arc(centerX, centerY, ring.radius, 0, 2 * Math.PI);
    ctx.strokeStyle = '#e8e8e8';
    ctx.lineWidth = lineWidth;
    ctx.stroke();

    // 数值环
    let ratio: number = ring.value / ring.total;
    let startAngle: number = -Math.PI / 2;
    let endAngle: number = startAngle + ratio * 2 * Math.PI;

    ctx.beginPath();
    ctx.arc(centerX, centerY, ring.radius, startAngle, endAngle);
    ctx.strokeStyle = ring.color;
    ctx.lineWidth = lineWidth;
    ctx.lineCap = 'round';
    ctx.stroke();
  }

  // 中心文字
  ctx.fillStyle = '#333333';
  ctx.font = 'bold 20px sans-serif';
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillText('75%', centerX, centerY);
}

三个同心环,不同半径、颜色、数值。中心显示最关键的数据。

动画驱动

仪表盘数值变化时要有动画效果,不能跳变:

@State displayValue: number = 0
@State targetValue: number = 0
private animTimer: number = -1

setGaugeValue(value: number): void {
  this.targetValue = value;
  let start: number = this.displayValue;
  let diff: number = value - start;
  let duration: number = 800;
  let startTime: number = Date.now();

  if (this.animTimer !== -1) {
    clearInterval(this.animTimer);
  }

  this.animTimer = setInterval(() => {
    let elapsed: number = Date.now() - startTime;
    if (elapsed >= duration) {
      this.displayValue = this.targetValue;
      clearInterval(this.animTimer);
      this.animTimer = -1;
    } else {
      let progress: number = elapsed / duration;
      let eased: number = 1 - Math.pow(1 - progress, 3);
      this.displayValue = start + diff * eased;
    }
    this.context.clearRect(0, 0, 200, 200);
    this.drawGauge(this.displayValue, 100);
  }, 16);
}

1 - (1-t)^3 是easeOutCubic缓动——先快后慢。16ms一帧约60fps。displayValue是当前动画位置,targetValue是目标值。setInterval在aboutToDisappear中清理。

Shape组件替代Canvas

Canvas需要手动管理RenderingContext,代码量大。简单仪表可以用Shape+Path:

Shape() {
  Path()
    .commands('M 30 100 A 70 70 0 0 1 170 100')
    .stroke(Color.Gray)
    .strokeWidth(14)
    .fill(Color.Transparent)

  Path()
    .commands(this.buildArcCommands(72))
    .stroke('#1a73e8')
    .strokeWidth(14)
    .strokeLineCap(LineCapStyle.Round)
    .fill(Color.Transparent)
}
.width(200)
.height(200)

private buildArcCommands(value: number): string {
  let ratio: number = value / 100;
  let startAngle: number = 0.75 * Math.PI;
  let endAngle: number = startAngle + ratio * 1.5 * Math.PI;
  let startX: number = 100 + 70 * Math.cos(startAngle);
  let startY: number = 100 + 70 * Math.sin(startAngle);
  let endX: number = 100 + 70 * Math.cos(endAngle);
  let endY: number = 100 + 70 * Math.sin(endAngle);
  let largeArc: number = ratio > 0.5 ? 1 : 0;
  return 'M ' + startX + ' ' + startY + ' A 70 70 0 ' + largeArc + ' 1 ' + endX + ' ' + endY;
}

SVG path的A命令画弧线。参数:rx ry x-rotation large-arc-flag sweep-flag x y。largeArcFlag在弧度超过π时设1。sweepFlag=1表示顺时针。

Shape比Canvas简单但灵活度低——不能画刻度、指针、渐变段。只适合简单的环形仪表。

踩坑清单

问题 原因 解决
弧线起点不对 角度0在3点方向 用0.75π从左下方开始
刻度线方向不对 cos/sin参数用错 x=cos, y=sin
指针三角形扭曲 底边角度计算错误 垂直于指针方向±90°
渐变弧有间隙 段之间没重叠 加0.01弧度重叠
动画卡顿 setInterval没清 aboutToDisappear中clearInterval
数值跳变 没做动画过渡 easeOutCubic插值
Canvas空白 在onReady之前绘图 移到onReady回调
Shape弧线不显示 SVG A命令参数错 检查largeArc和sweep
重绘残影 没clearRect 每次draw前clearRect
文字模糊 Canvas未开抗锯齿 RenderingContextSettings(true)

自定义仪表的核心是"弧线+刻度+指针+数字"四层叠加。Canvas灵活但代码多,Shape简单但限制大。选择取决于你要不要画刻度和指针——要画就用Canvas,只画弧线就用Shape。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐