Android 自定义汽车仪表

532 阅读2分钟

一、前言

本篇也是仪表自定义项目,其实仪表需要的无非是高中初中的一些数学基础知识,连矩阵都用不上,当然你可能会说Canvas.translate不就是矩阵转换么 ?

这么说其实也没啥问题,关键是你会自己写个矩阵去做乘法么?

二、实现

2.1 效果预览

2.2 实现逻辑

我们绘制的时候一定要注意角度,在Android Canvas坐标系中,坐标轴是向下为y轴正方向,因此,他的旋转是顺时针的,因此要在旋转为 135度的位置开始绘制。

另外文字的绘制我们使用的是旋转Canvas,其实还有方法就是drawTextOnPath,当然,这里简单的做法还是旋转Canvas。

三、 代码实现

3.1 初始化

这里我们自定义AutoMeterView要注意的一个问题是,整个圆弧的区域角度为270度。

  private float sumDegree = (360 - 90.0f);

另一方面,我们这里为了方便绘制,将Canvas先逆时针旋转 (90 + 45) 度,使得X坐标系对齐开始绘制位置

   canvas.rotate(90 + 45);

旋转Canvas 显然要简单的多,但其实还有可以利用Math#atan2实现

3.2 文字绘制

为了让文字绘制出现旋转效果,我们每次都需要旋转一定的角度,旋转角度计算公式如下

float perDegree = sumDegree / 100;

下面是文字绘制的核心代码

        saveCount = canvas.save();
        mPaint.setStyle(Paint.Style.FILL);
        canvas.translate(width / 2F, height / 2F);
        canvas.rotate(-90 - 45);
        //该方法旋转的是坐标系,而不是图层

        for (int i = 0; i <= 10; i++) {
            if (i > 0) {
                canvas.rotate(perDegree * 10);
                //每次旋转坐标系一定的位置
            }
            mPaint.setColor(getPaintColor(i * 10));
            String text = String.valueOf(i * 10);
            mPaint.getTextBounds(text, 0, text.length(), bounds);
            float textBaseline = innerRadius - strokeWidth - bounds.height() + getTextPaintBaseline(mPaint);
            canvas.drawText(text, 0 - bounds.width() / 2F, -textBaseline, mPaint);
            //从y轴负方向开始绘制

        }
        canvas.restoreToCount(saveCount);

3.3 指针绘制

制定绘制一定要放在后面做,这样做的原因防止被文字遮挡住指针

       saveCount = canvas.save();
        canvas.translate(width / 2F, height / 2F);
        mPaint.setColor(getPaintColor(degreeOffset));
        int tail = (int) dpTopx(outlineWidth);
        double degreeRadianDelta = Math.toRadians(90 + 45 + degreeOffset * perDegree);
        float startX = (float) (-tail * Math.cos(degreeRadianDelta));
        float startY = (float) (-tail * Math.sin(degreeRadianDelta));
        float endX = (float) ((innerRadius - tail) * Math.cos(degreeRadianDelta));        float endY = (float) ((innerRadius - tail) * Math.sin(degreeRadianDelta));
        canvas.drawLine(startX, startY, endX, endY, mPaint);

        canvas.drawCircle(0, 0, tail / 2, mPaint);

        canvas.restoreToCount(saveCount);

3.4 动画

为了让指针偏移的过程中出现抖动回弹效果,我们这里利用动画来实现角度的更新

    mDegreeAnimator = ValueAnimator.ofFloat(this.degreeOffset, bounceOffset, targetDegreeOffset);
        mDegreeAnimator.setDuration(500);
        mDegreeAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                postUpdateDegree((Float) animation.getAnimatedValue());
            }
        });
        mDegreeAnimator.start();

3.5 完整逻辑

下面是完整代码,测量和布局基本逻辑和之前的一样

public class AutoMeterView extends View {
    private DisplayMetrics displayMetrics;
    private TextPaint mPaint;
    private float lineWidth = 10;
    private float outlineWidth = 20;
    private float textSize = 16;
    private float sumDegree = (360 - 90.0f);
    Rect bounds = new Rect();
    RectF rectF = new RectF();
    float degreeOffset = 0;

    private ValueAnimator mDegreeAnimator;

    public AutoMeterView(Context context) {
        this(context, null);
    }

    public AutoMeterView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public AutoMeterView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        displayMetrics = context.getResources().getDisplayMetrics();
        initPaint();
    }

    private void initPaint() {
        // 实例化画笔并打开抗锯齿
        mPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
        mPaint.setAntiAlias(true);
        mPaint.setTextSize(dpTopx(textSize));

    }

    private float dpTopx(float dp) {
        return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics());
    }

    public void setSumDegree(float sumDegree) {
        this.sumDegree = sumDegree;
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);

        if (widthMode != MeasureSpec.EXACTLY) {
            widthSize = displayMetrics.widthPixels / 2;
        }

        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        if (heightMode != MeasureSpec.EXACTLY) {
            heightSize = displayMetrics.widthPixels / 2;
        }
        
        //来个正方形大小
        widthSize = heightSize = Math.min(widthSize, heightSize);

        setMeasuredDimension(widthSize, heightSize);
    }


    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);

    }


    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        int width = getWidth();
        int height = getHeight();

        mPaint.setStyle(Paint.Style.STROKE);

        mPaint.setStrokeWidth(dpTopx(outlineWidth));
        float strokeWidth = mPaint.getStrokeWidth();

        float outRadius = (float) (Math.min(width / 2, height / 2));
        float innerRadius =  (outRadius - 3 * strokeWidth);
        float innerDotRadius =  (outRadius - 2 * strokeWidth);

        float perDegree = sumDegree / 100;

        int saveCount = canvas.save();
        canvas.translate(width / 2F, height / 2F);
        canvas.rotate(90 + 45);


        mPaint.setColor(0xff54d68c);
        rectF.set(-width / 2F + strokeWidth / 2, -height / 2F + strokeWidth / 2, width / 2F - strokeWidth / 2, height / 2F - strokeWidth / 2);
        canvas.drawArc(rectF, 0, 20 * perDegree, false, mPaint);

        mPaint.setColor(0xffff9922);
        canvas.drawArc(rectF, 20 * perDegree, 60 * perDegree, false, mPaint);

        mPaint.setColor(Color.RED);
        canvas.drawArc(rectF, 80 * perDegree, 20 * perDegree, false, mPaint);

        mPaint.setStrokeWidth(dpTopx(lineWidth));
        strokeWidth = mPaint.getStrokeWidth();
        outRadius = (int) (Math.min(width / 2, height / 2));
        innerRadius = (int) (outRadius - 4 * strokeWidth);
        innerDotRadius = (int) (outRadius - 3 * strokeWidth);

        for (int i = 0; i <= 100; i++) {
            float innerLineRadius = innerDotRadius;
            if (i % 10 == 0) {
                mPaint.setStrokeWidth(strokeWidth / 4);
                innerLineRadius = innerRadius;
            } else {
                mPaint.setStrokeWidth(strokeWidth / 5);
            }

            float startX = (float) (innerLineRadius * Math.cos(Math.toRadians(i * perDegree)));
            float startY = (float) (innerLineRadius * Math.sin(Math.toRadians(i * perDegree)));

            float endX = (float) (outRadius * Math.cos(Math.toRadians(i * perDegree)));
            float endY = (float) (outRadius * Math.sin(Math.toRadians(i * perDegree)));

            mPaint.setColor(getPaintColor(i));
            canvas.drawLine(startX, startY, endX, endY, mPaint);

        }

        canvas.restoreToCount(saveCount);

        saveCount = canvas.save();
        mPaint.setStyle(Paint.Style.FILL);
        canvas.translate(width / 2F, height / 2F);
        canvas.rotate(-90 - 45);
        //该方法旋转的是坐标系,而不是图层

        for (int i = 0; i <= 10; i++) {
            if (i > 0) {
                canvas.rotate(perDegree * 10);
                //每次旋转坐标系一定的位置
            }
            mPaint.setColor(getPaintColor(i * 10));
            String text = String.valueOf(i * 10);
            mPaint.getTextBounds(text, 0, text.length(), bounds);
            float textBaseline = innerRadius - strokeWidth - bounds.height() + getTextPaintBaseline(mPaint);
            canvas.drawText(text, 0 - bounds.width() / 2F, -textBaseline, mPaint);
            //从y轴负方向开始绘制

        }
        canvas.restoreToCount(saveCount);

        saveCount = canvas.save();
        canvas.translate(width / 2F, height / 2F);
        mPaint.setColor(getPaintColor(degreeOffset));
        int tail = (int) dpTopx(outlineWidth);
        double degreeRadianDelta = Math.toRadians(90 + 45 + degreeOffset * perDegree);
        float startX = (float) (-tail * Math.cos(degreeRadianDelta));
        float startY = (float) (-tail * Math.sin(degreeRadianDelta));
        float endX = (float) ((innerRadius - tail) * Math.cos(degreeRadianDelta));        float endY = (float) ((innerRadius - tail) * Math.sin(degreeRadianDelta));
        canvas.drawLine(startX, startY, endX, endY, mPaint);

        canvas.drawCircle(0, 0, tail / 2, mPaint);

        canvas.restoreToCount(saveCount);

    }

    private int getPaintColor(float degreeOffset) {
        if (degreeOffset <= 20) {
            return 0xff54d68c;
        } else if (degreeOffset > 20 && degreeOffset < 80) {
            return 0xffff9922;
        } else {
            return Color.RED;
        }
    }

    /**
     * 基线到中线的距离=(Descent+Ascent)/2-Descent
     * 注意,实际获取到的Ascent是负数。公式推导过程如下:
     * 中线到BOTTOM的距离是(Descent+Ascent)/2,这个距离又等于Descent+中线到基线的距离,即(Descent+Ascent)/2=基线到中线的距离+Descent。
     */
    public static float getTextPaintBaseline(Paint p) {
        Paint.FontMetrics fontMetrics = p.getFontMetrics();
        return (fontMetrics.descent - fontMetrics.ascent) / 2 - fontMetrics.descent;
    }


    public void setDegreeOffset(float targetDegreeOffset) {
        if (targetDegreeOffset < 0) {
            targetDegreeOffset = 0;
        }
        if (targetDegreeOffset > 100) {
            targetDegreeOffset = 100;
        }

        if (this.degreeOffset == targetDegreeOffset) {
            return;
        }

        if (getWidth() == 0 || getHeight() == 0) {
            this.degreeOffset = targetDegreeOffset;
            return;
        }

        if (mDegreeAnimator != null) {
            mDegreeAnimator.cancel();
        }
        float perDegree = sumDegree / 100;

        float bounceOffset = 0;
        if (this.degreeOffset > targetDegreeOffset) {
            bounceOffset = targetDegreeOffset - perDegree * 5;
            if (bounceOffset < 0) {
                bounceOffset = 0;
            }
        } else {
            bounceOffset = targetDegreeOffset + perDegree * 5;
            if (bounceOffset > 100) {
                bounceOffset = 100;
            }
        }
        mDegreeAnimator = ValueAnimator.ofFloat(this.degreeOffset, bounceOffset, targetDegreeOffset);
        mDegreeAnimator.setDuration(500);
        mDegreeAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                postUpdateDegree((Float) animation.getAnimatedValue());
            }
        });
        mDegreeAnimator.start();
    }
    private void postUpdateDegree(float animatedValue) {
        this.degreeOffset = animatedValue;
        postInvalidate();
    }
}

以上是完整逻辑

四、总结

本篇到这里就结束了,其实仪表的画法比较简单,主要涉及到旋转、平移等操作,另外,对于文本的绘制,需要对文字的区域进行测量,找到相应的基线。

本篇是Animator + 刻度表,掌握动画与三角函数的结合运动,希望对你有所帮助。