一、从需求出发:我们要做一个什么样的仪表盘?
大家好,我是你们的老朋友。今天咱们来聊一个实战性很强的话题——自定义仪表盘View。
说实话,我在做Android开发的头两年,看到那些酷炫的自定义View,总觉得遥不可及。后来真正啃透了View的绘制流程,才发现其实没那么神秘。今天我就带着大家,从零开始打造一个完整的仪表盘控件。
这个仪表盘长什么样呢?简单描述一下:
- 一个半圆形的刻度盘,从0到100
- 有刻度线和数字标注
- 一根指针,可以旋转
- 支持触摸拖动指针
- 还带一个平滑的动画效果
说白了,就是把我们之前学的测量、布局、绘制、触摸事件、动画,全部串起来。你想想看,一个View能玩的花样,基本都在这了。
核心知识点一览:
- onMeasure() — 确定View的大小
- onLayout() — 确定子View的位置(虽然本例没有子View)
- onDraw() — 绘制刻度、数字、指针
- onTouchEvent() — 处理触摸拖动
- ValueAnimator / ObjectAnimator — 实现平滑动画
二、先画一张图,理清整体架构
动手写代码之前,我习惯先画一张架构图。这能帮我把思路理清楚,避免写到一半发现方向错了。
这张图把整个流程串起来了。你看,从onMeasure确定尺寸,到onDraw绘制内容,再到onTouchEvent处理交互,最后用动画收尾。每一步都有它的职责。
三、开始写代码:从测量开始
我个人习惯,写自定义View的第一步永远是测量。为什么?因为后面的绘制和触摸都依赖于正确的尺寸。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
// 默认尺寸:300dp
int defaultSize = dp2px(300);
int width, height;
if (widthMode == MeasureSpec.EXACTLY) {
width = widthSize;
} else if (widthMode == MeasureSpec.AT_MOST) {
width = Math.min(defaultSize, widthSize);
} else {
width = defaultSize;
}
if (heightMode == MeasureSpec.EXACTLY) {
height = heightSize;
} else if (heightMode == MeasureSpec.AT_MOST) {
height = Math.min(defaultSize, heightSize);
} else {
height = defaultSize;
}
// 仪表盘是正方形的,取宽高中较小的值
int size = Math.min(width, height);
setMeasuredDimension(size, size);
}
小提示:这里我取了宽高中较小的值,保证仪表盘是正方形。我在项目中遇到过,如果不做这个处理,在ConstraintLayout里用match_parent会导致仪表盘被拉伸成椭圆形,那画面简直没法看。
四、绘制:刻度、数字、指针
绘制部分是最有意思的。说白了,就是用Canvas在画布上画东西。我把它拆成三个步骤:
4.1 绘制刻度线
private void drawScale(Canvas canvas) {
canvas.save();
// 将画布旋转到起始角度
canvas.rotate(START_ANGLE, centerX, centerY);
float totalAngle = SWEEP_ANGLE; // 180度
int totalTicks = 20; // 20个大刻度
float angleStep = totalAngle / totalTicks;
for (int i = 0; i <= totalTicks; i++) {
// 大刻度画长线,小刻度画短线
if (i % 5 == 0) {
// 大刻度
canvas.drawLine(centerX, topPadding, centerX, topPadding + longTickLength, tickPaint);
} else {
// 小刻度
canvas.drawLine(centerX, topPadding, centerX, topPadding + shortTickLength, tickPaint);
}
canvas.rotate(angleStep, centerX, centerY);
}
canvas.restore();
}
4.2 绘制数字
private void drawNumbers(Canvas canvas) {
canvas.save();
canvas.rotate(START_ANGLE, centerX, centerY);
float totalAngle = SWEEP_ANGLE;
int totalTicks = 20;
float angleStep = totalAngle / totalTicks;
for (int i = 0; i <= totalTicks; i += 5) {
String text = String.valueOf(i * 5); // 0, 25, 50, 75, 100
float textWidth = numberPaint.measureText(text);
float textHeight = numberPaint.descent() - numberPaint.ascent();
// 计算数字的位置
float x = centerX - textWidth / 2;
float y = topPadding + longTickLength + textHeight;
canvas.drawText(text, x, y, numberPaint);
canvas.rotate(angleStep * 5, centerX, centerY);
}
canvas.restore();
}
4.3 绘制指针
private void drawPointer(Canvas canvas) {
canvas.save();
canvas.rotate(currentAngle, centerX, centerY);
// 指针是一个三角形
Path path = new Path();
path.moveTo(centerX - pointerWidth / 2, centerY - pointerLength);
path.lineTo(centerX + pointerWidth / 2, centerY - pointerLength);
path.lineTo(centerX, centerY - pointerBaseOffset);
path.close();
canvas.drawPath(path, pointerPaint);
// 画一个圆点作为指针的轴
canvas.drawCircle(centerX, centerY, pointerRadius, pointerPaint);
canvas.restore();
}
注意:canvas.rotate()的旋转中心一定要设置正确。我曾经犯过一个错误,忘记传旋转中心坐标,结果指针绕着左上角转,调试了半天才发现问题。
五、触摸事件:让指针动起来
触摸事件的处理,核心就是计算手指位置对应的角度。
@Override
public boolean onTouchEvent(MotionEvent event) {
float x = event.getX();
float y = event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_MOVE:
// 计算手指相对于圆心的角度
float dx = x - centerX;
float dy = y - centerY;
// 注意:Math.atan2返回的是弧度,需要转成角度
float angle = (float) Math.toDegrees(Math.atan2(dy, dx));
// 调整角度范围,使其符合仪表盘的起始和结束角度
angle = normalizeAngle(angle);
// 限制角度范围
if (angle < START_ANGLE) angle = START_ANGLE;
if (angle > START_ANGLE + SWEEP_ANGLE) angle = START_ANGLE + SWEEP_ANGLE;
currentAngle = angle;
invalidate();
return true;
}
return super.onTouchEvent(event);
}
private float normalizeAngle(float angle) {
// 将角度从[-180, 180]转换到[0, 360]
if (angle < 0) {
angle += 360;
}
return angle;
}
你想想看,这里最关键的是atan2这个函数。它返回的是从x轴正方向逆时针旋转的角度。但我们的仪表盘是从左下角到右下角的半圆,所以需要做角度映射。
六、动画:让指针平滑转动
触摸拖动是直接跳转的,不够优雅。加上动画就完美了。
public void animateToValue(float targetValue) {
float targetAngle = START_ANGLE + (targetValue / 100f) * SWEEP_ANGLE;
ValueAnimator animator = ValueAnimator.ofFloat(currentAngle, targetAngle);
animator.setDuration(800);
animator.setInterpolator(new DecelerateInterpolator());
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
currentAngle = (float) animation.getAnimatedValue();
invalidate();
}
});
animator.start();
}
关键点:
- 使用DecelerateInterpolator,指针会先快后慢,看起来更自然
- 动画过程中不断调用invalidate()触发重绘
- 动画时长800ms是我试下来比较舒服的节奏
七、完整的使用示例
// 在Activity或Fragment中使用
DashboardView dashboard = findViewById(R.id.dashboard);
dashboard.setMaxValue(100);
dashboard.setCurrentValue(75);
// 带动画的设置
dashboard.animateToValue(85);
八、避坑指南
做这个自定义View的过程中,我踩过不少坑。分享几个典型的:
- Canvas的save/restore一定要成对出现 — 我有一回忘了restore,结果后面的绘制全乱套了
- 触摸事件要返回true — 否则后续的MOVE和UP事件收不到
- 动画记得取消 — 如果View被销毁了动画还在跑,会引发内存泄漏
我的习惯:在onDetachedFromWindow()里调用animator.cancel(),确保动画被清理掉。
九、总结
这个仪表盘View,把View绘制流程的五个核心环节都走了一遍。你想想看,从测量到绘制,从触摸到动画,每一步都有它的意义。掌握了这个套路,以后遇到再复杂的自定义View,心里也有底了。
嗯,今天就聊到这。代码我已经上传到GitHub了,大家可以去下载跑一跑。有什么问题欢迎在评论区交流。
公众号:蓝海资料掘金营,微信deep3321