HarmonyOS 6.0 自定义绘图表盘与仪表
车载仪表盘、健康数据面板、运动数据环形图——这些不是普通图表,而是需要"仪表感"的视觉组件。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。
更多推荐



所有评论(0)