一、从需求出发:我们要做一个什么样的仪表盘?

大家好,我是你们的老朋友。今天咱们来聊一个实战性很强的话题——自定义仪表盘View。

说实话,我在做Android开发的头两年,看到那些酷炫的自定义View,总觉得遥不可及。后来真正啃透了View的绘制流程,才发现其实没那么神秘。今天我就带着大家,从零开始打造一个完整的仪表盘控件。

这个仪表盘长什么样呢?简单描述一下:

  • 一个半圆形的刻度盘,从0到100
  • 有刻度线和数字标注
  • 一根指针,可以旋转
  • 支持触摸拖动指针
  • 还带一个平滑的动画效果

说白了,就是把我们之前学的测量、布局、绘制、触摸事件、动画,全部串起来。你想想看,一个View能玩的花样,基本都在这了。

核心知识点一览:

  • onMeasure() — 确定View的大小
  • onLayout() — 确定子View的位置(虽然本例没有子View)
  • onDraw() — 绘制刻度、数字、指针
  • onTouchEvent() — 处理触摸拖动
  • ValueAnimator / ObjectAnimator — 实现平滑动画

二、先画一张图,理清整体架构

动手写代码之前,我习惯先画一张架构图。这能帮我把思路理清楚,避免写到一半发现方向错了。

仪表盘View架构图 DashboardView extends View onMeasure() onDraw() onTouchEvent() 测量宽高 处理wrap_content / match_parent 绘制刻度、数字、指针 使用Canvas和Paint 触摸事件处理 计算角度,更新指针位置 ValueAnimator 实现平滑动画

这张图把整个流程串起来了。你看,从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