效果图
实现思路
首先拆解这个View,可以分成四个部分来绘制
- 外圆刻度部分,包含最外面的刻度圆和里面对应的数值,此外圆分为八个等份,每等份中包含五个小等份,所以总共需要40个刻度。这里不是一个完整的圆,此外圆部分占一个完整圆的240度
- 内圆刻度部分,此处总共有100个刻度,与外圆刻度保持着对应关系,进度发生改变时需要改变对应部分的颜色,超过外圆刻度6部分的颜色需要变成红色
- 指针部分,指针由一个中间的圆和一条线段组成,运动范围为0~8的刻度之间,因为此仪表占圆的240度,所以指针的运动范围也就是0~240度
- 数值部分,这个数值需要根据外圆刻度来进行计算,当刻度数值大于6时则显示为红色
具体实现
对于View的宽高测量、View里的dp
转px
数值适配等知识,在之前的一篇博客中都有讲解,需要了解的可以去看看
(1)跳过onMeasure
方法的说明,直接进入onDraw
绘制流程
@Override
protected void onDraw(Canvas canvas) {
drawArcScale(canvas);
drawArcInside(canvas);
drawInsideSumText(canvas);
drawPointer(canvas);
}
- 绘制外刻度圆及数值
View的最外圆刻度并不是一个完整的圆,而是一个扇形,而且这个扇形的起始点是在左下角的,如何来计算这个起始点和终点的位置呢
一个半圆是180度,然后在这个半圆里,刻度1~7这地方,总共有五等份,也就是说一等分占圆的30度,那么起点的度数就是180度,终点的度数为240度
canvas.drawArc(new RectF(scaleWidth, scaleWidth, mWidth - scaleWidth, mHeight - scaleWidth), 180, 240, false, mScalePaint);
通过以上的方法绘制出的扇形是以View最左边开始的,所以还需要让它移动到左下角
我们在绘制时,可以先调用canvas.rotate()
方法把画布逆时针旋转30度
canvas.rotate(-30, mWidth / 2, mHeight / 2);
这样旋转30度后,我们以最左边180度的地方为起点绘制的扇形就会移动到左下角了,其实本可以直接通过指定度数为150~270的方式直接绘制出扇形,但是由于还要绘制那些刻度线,刻度线的起始坐标在左边时才好计算,而要移到刻度0那个位置则比较麻烦,所以这里直接把画布旋转到那个位置,这样就可以避免复杂的计算了。
canvas.drawLine(scaleWidth, mHeight / 2, DensityUtil.dip2px(mContext, 15), mHeight / 2, mScalePaint);
虽然移动了画布,但绘制线条的坐标还是以View的最左边为基准
绘制完刻度后接着就要把数字刻度画出来,这数字刻度的画法需要使用到canvas.rotate()
方法和canvas.translate()
方法的配合
首先需要调用canvas.translate()
方法暂时把View的圆点移动到需要绘制数值刻度的地方
canvas.translate(DensityUtil.dip2px(mContext, 15) + textWidth + DensityUtil.dip2px(mContext, 5), mHeight / 2);
然后就需要把之前被旋转的角度再次旋转回去,让文字竖直显示,这也是为什么要使用canvas.translate
方法移动View圆点到数值刻度绘制的地方的原因,因为只需要旋转数值刻度这一块区域就可以了,不能影响到其它地方
绘制外刻度圆及数值的全部代码
/**
* 画外圆和文字刻度
*/
private void drawArcScale(Canvas canvas) {
canvas.save();
canvas.rotate(-30, mWidth / 2, mHeight / 2);
// 最外圆的线条宽度,避免线条粗时被遮蔽
float scaleWidth = mScalePaint.getStrokeWidth();
canvas.drawArc(new RectF(scaleWidth, scaleWidth, mWidth - scaleWidth, mHeight - scaleWidth), 180, 240, false, mScalePaint);
// 定义文字旋转回的角度
int rotateValue = 30;
// 总八个等分,每等分5个刻度,所以总共需要40个刻度
for (int i = 0; i <= 40; i++) {
if (i % 5 == 0) {
canvas.drawLine(scaleWidth, mHeight / 2, DensityUtil.dip2px(mContext, 15), mHeight / 2, mScalePaint);
// 画文字
String text = String.valueOf(i / 5);
Rect textBound = new Rect();
mScaleTextPaint.getTextBounds(text, 0, text.length(), textBound); // 获取文字的矩形范围
int textWidth = textBound.right - textBound.left; // 获得文字宽度
int textHeight = textBound.bottom - textBound.top; // 获得文字高度
canvas.save();
canvas.translate(DensityUtil.dip2px(mContext, 15) + textWidth + DensityUtil.dip2px(mContext, 5), mHeight / 2); // 移动画布的圆点
if (i == 0) {
// 如果刻度为0,则旋转度数为30度
canvas.rotate(rotateValue);
} else {
// 大于0的刻度,需要逐渐递减30度
canvas.rotate(rotateValue);
}
rotateValue = rotateValue - 30;
canvas.drawText(text, -textWidth / 2, textHeight / 2, mScaleTextPaint);
canvas.restore();
} else {
canvas.drawLine(scaleWidth, mHeight / 2, DensityUtil.dip2px(mContext, 10), mHeight / 2, mScalePaint);
}
canvas.rotate(6, mWidth / 2, mHeight / 2);
}
canvas.restore();
}
这里每次画一个刻度都使用canvas.rotate(6, mWidth / 2, mHeight / 2)
方法旋转了6度,那么为什么偏偏是旋转6度呢,因为我们的View占一个圆的240度,而刻度数为40个,所以240除以40得到6度
- 绘制内刻度
内刻度总共需要100个刻度,通过计算可以得知,外刻度圆是180~240度,实际上就是跨越了240度,所以用100除以240得到旋转的角度2.4f,也就是每画一个刻度,就需要旋转2.4度
/**
* 画内圆刻度
*/
private void drawArcInside(Canvas canvas) {
canvas.save();
canvas.rotate(-30, mWidth / 2, mHeight / 2);
for (int i = 0; i < 100; i++) {
if (mInsideProgress > i) {
// 大于外圆刻度6时显示红色
if (i <= 75) {
mInsidePaint.setColor(insideCircleColor);
} else {
mInsidePaint.setColor(Color.RED);
}
} else {
mInsidePaint.setColor(Color.LTGRAY);
}
canvas.drawLine(DensityUtil.dip2px(mContext, 40), mHeight / 2, DensityUtil.dip2px(mContext, 50), mHeight / 2, mInsidePaint);
canvas.rotate(2.4f, mWidth / 2, mHeight / 2);
}
canvas.restore();
}
当内圆刻度大于外圆刻度6的时候,也就是内圆大于75刻度值时,需要使用红色标识刻度,这个75是如何得来的呢
当进度到达外刻度6~8时,显示为红色,此时的计算方式为100/8=12.5, 也就是这八个等份中每等份12.5,通过6*12.5得75,得知需要在75开始时改变内刻度颜色
- 绘制指针
指针就是一个直线加中间一个圆形组成
/**
* 画指针
*/
private void drawPointer(Canvas canvas) {
canvas.save();
// 初始时旋转到0的位置
canvas.rotate(-30, mWidth / 2, mHeight / 2);
canvas.rotate(mProgress, mWidth / 2, mHeight / 2);
canvas.drawLine(mWidth / 2 - DensityUtil.dip2px(mContext, 60), mHeight / 2, mWidth / 2, mHeight / 2, mPointerPaint);
canvas.drawCircle(mWidth / 2, mHeight / 2, DensityUtil.dip2px(mContext, 5), mPointerPaint);
canvas.restore();
}
- 绘制中间文字数值
文字数值在外圆刻度大于6、内圆刻度大于75时,需要变成红色来显示
/**
* 画内部数值
*/
private void drawInsideSumText(Canvas canvas) {
canvas.save();
if (mInsideProgress > 75) {
mTextPaint.setColor(Color.RED);
} else {
mTextPaint.setColor(Color.BLACK);
}
// 获取文字居中显示需要的参数
String showValue = String.valueOf(value);
Rect textBound = new Rect();
mTextPaint.getTextBounds(showValue, 0, showValue.length(), textBound); // 获取文字的矩形范围
float textWidth = textBound.right - textBound.left; // 获得文字宽
float textHeight = textBound.bottom - textBound.top; // 获得文字高
canvas.drawText(showValue, mWidth / 2 - textWidth / 2, mHeight / 2 + textHeight + DensityUtil.dip2px(mContext, 45), mTextPaint);
canvas.restore();
}
(2)设置进度
对外提供一个方法用于改变View的指针和内部刻度显示
/**
* 设置进度
*/
public void setProgress(int progress) {
// 内部刻度的进度
this.mInsideProgress = progress;
// 指针显示的进度
this.mProgress = (float) progress * 2.4f;
// 设置中间文字显示的数值
this.value = (float) (progress * 0.08);
invalidate();
}
传递过来的参数progress
的范围是0~100,所以有些需要进行一下处理才可以使用到View中
1. 内部刻度:总共有100个刻度标识,所以可以直接用progress
2. 指针进度:View总共的度数为240度,所以需要计算的方式就是240除以100,得2.4度
3. 文字刻度:需要用100来表示0~8,所以计算方式是8除以100,得0.08
源码
/**
* 圆形仪表盘
* Created by zhuwentao on 2017-08-26.
*/
public class CircleMeterView extends View {
// 外圆刻度画笔
private Paint mScalePaint;
// 外圆刻度数值画笔
private Paint mScaleTextPaint;
// 内圆画笔
private Paint mInsidePaint;
// 中间数值画笔
private Paint mTextPaint;
// 指针画笔
private Paint mPointerPaint;
// View宽
private float mWidth;
// View高
private float mHeight;
// 外刻度圆进度
private float mProgress = 0;
// 内刻度圆进度
private float mInsideProgress = 0;
// 中间显示的数值
private float value = 0;
private int scaleColor;
private int scaleTextColor;
private int insideCircleColor;
private int textSize;
private int textColor;
private int pointerColor;
private Context mContext;
public CircleMeterView(Context context) {
this(context, null);
}
public CircleMeterView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public CircleMeterView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
// 获取用户配置属性
TypedArray tya = context.obtainStyledAttributes(attrs, R.styleable.CircleMeter);
scaleColor = tya.getColor(R.styleable.CircleMeter_scaleColor, Color.BLUE);
scaleTextColor = tya.getColor(R.styleable.CircleMeter_scaleTextColor, Color.BLACK);
insideCircleColor = tya.getColor(R.styleable.CircleMeter_insideCircleColor, Color.BLUE);
textSize = tya.getDimensionPixelSize(R.styleable.CircleMeter_textSize2, 36);
textColor = tya.getColor(R.styleable.CircleMeter_textColor2, Color.BLACK);
pointerColor = tya.getColor(R.styleable.CircleMeter_pointerColor, Color.RED);
tya.recycle();
initUI();
}
private void initUI() {
mContext = getContext();
// 刻度圆画笔
mScalePaint = new Paint();
mScalePaint.setAntiAlias(true);
mScalePaint.setStrokeWidth(DensityUtil.dip2px(mContext, 2));
mScalePaint.setColor(scaleColor);
mScalePaint.setStrokeCap(Paint.Cap.ROUND);
mScalePaint.setStyle(Paint.Style.STROKE);
// 刻度文字画笔
mScaleTextPaint = new Paint();
mScaleTextPaint.setAntiAlias(true);
mScaleTextPaint.setStrokeWidth(DensityUtil.dip2px(mContext, 2));
mScaleTextPaint.setColor(scaleTextColor);
mScaleTextPaint.setTextSize(24);
mScaleTextPaint.setStyle(Paint.Style.FILL);
// 中间值的画笔
mTextPaint = new Paint();
mTextPaint.setAntiAlias(true);
mTextPaint.setStrokeWidth(DensityUtil.dip2px(mContext, 2));
mTextPaint.setTextSize(36);
mTextPaint.setColor(textColor);
mTextPaint.setStrokeJoin(Paint.Join.ROUND);
mTextPaint.setStyle(Paint.Style.FILL);
// 内部扇形刻度画笔
mInsidePaint = new Paint();
mInsidePaint.setAntiAlias(true);
mInsidePaint.setStrokeWidth(DensityUtil.dip2px(mContext, 1));
mInsidePaint.setColor(insideCircleColor);
mInsidePaint.setStyle(Paint.Style.FILL);
// 指针画笔
mPointerPaint = new Paint();
mPointerPaint.setAntiAlias(true);
mPointerPaint.setStrokeWidth(DensityUtil.dip2px(mContext, 5));
mPointerPaint.setColor(pointerColor);
mPointerPaint.setStrokeCap(Paint.Cap.ROUND);
mPointerPaint.setStyle(Paint.Style.FILL_AND_STROKE);
}
@Override
protected void onDraw(Canvas canvas) {
drawArcScale(canvas);
drawArcInside(canvas);
drawInsideSumText(canvas);
drawPointer(canvas);
}
/**
* 画外圆和文字刻度
*/
private void drawArcScale(Canvas canvas) {
canvas.save();
canvas.rotate(-30, mWidth / 2, mHeight / 2);
// 最外圆的线条宽度,避免线条粗时被遮蔽
float scaleWidth = mScalePaint.getStrokeWidth();
canvas.drawArc(new RectF(scaleWidth, scaleWidth, mWidth - scaleWidth, mHeight - scaleWidth), 180, 240, false, mScalePaint);
// 定义文字旋转回的角度
int rotateValue = 30;
// 总八个等分,每等分5个刻度,所以总共需要40个刻度
for (int i = 0; i <= 40; i++) {
if (i % 5 == 0) {
canvas.drawLine(scaleWidth, mHeight / 2, DensityUtil.dip2px(mContext, 15), mHeight / 2, mScalePaint);
// 画文字
String text = String.valueOf(i / 5);
Rect textBound = new Rect();
mScaleTextPaint.getTextBounds(text, 0, text.length(), textBound); // 获取文字的矩形范围
int textWidth = textBound.right - textBound.left; // 获得文字宽度
int textHeight = textBound.bottom - textBound.top; // 获得文字高度
canvas.save();
canvas.translate(DensityUtil.dip2px(mContext, 15) + textWidth + DensityUtil.dip2px(mContext, 5), mHeight / 2); // 移动画布的圆点
if (i == 0) {
// 如果刻度为0,则旋转度数为30度
canvas.rotate(rotateValue);
} else {
// 大于0的刻度,需要逐渐递减30度
canvas.rotate(rotateValue);
}
rotateValue = rotateValue - 30;
canvas.drawText(text, -textWidth / 2, textHeight / 2, mScaleTextPaint);
canvas.restore();
} else {
canvas.drawLine(scaleWidth, mHeight / 2, DensityUtil.dip2px(mContext, 10), mHeight / 2, mScalePaint);
}
canvas.rotate(6, mWidth / 2, mHeight / 2);
}
canvas.restore();
}
/**
* 画内圆刻度
*/
private void drawArcInside(Canvas canvas) {
canvas.save();
canvas.rotate(-30, mWidth / 2, mHeight / 2);
for (int i = 0; i < 100; i++) {
if (mInsideProgress > i) {
// 大于外圆刻度6时显示红色
if (i <= 75) {
mInsidePaint.setColor(insideCircleColor);
} else {
mInsidePaint.setColor(Color.RED);
}
} else {
mInsidePaint.setColor(Color.LTGRAY);
}
canvas.drawLine(DensityUtil.dip2px(mContext, 40), mHeight / 2, DensityUtil.dip2px(mContext, 50), mHeight / 2, mInsidePaint);
canvas.rotate(2.4f, mWidth / 2, mHeight / 2);
}
canvas.restore();
}
/**
* 画内部数值
*/
private void drawInsideSumText(Canvas canvas) {
canvas.save();
if (mInsideProgress > 75) {
mTextPaint.setColor(Color.RED);
} else {
mTextPaint.setColor(Color.BLACK);
}
// 获取文字居中显示需要的参数
String showValue = String.valueOf(value);
Rect textBound = new Rect();
mTextPaint.getTextBounds(showValue, 0, showValue.length(), textBound); // 获取文字的矩形范围
float textWidth = textBound.right - textBound.left; // 获得文字宽
float textHeight = textBound.bottom - textBound.top; // 获得文字高
canvas.drawText(showValue, mWidth / 2 - textWidth / 2, mHeight / 2 + textHeight + DensityUtil.dip2px(mContext, 45), mTextPaint);
canvas.restore();
}
/**
* 画指针
*/
private void drawPointer(Canvas canvas) {
canvas.save();
// 旋转到0的位置
canvas.rotate(-30, mWidth / 2, mHeight / 2);
canvas.rotate(mProgress, mWidth / 2, mHeight / 2);
canvas.drawLine(mWidth / 2 - DensityUtil.dip2px(mContext, 60), mHeight / 2, mWidth / 2, mHeight / 2, mPointerPaint);
canvas.drawCircle(mWidth / 2, mHeight / 2, DensityUtil.dip2px(mContext, 5), mPointerPaint);
canvas.restore();
}
/**
* 设置进度
*/
public void setProgress(int progress) {
// 内部刻度的进度
this.mInsideProgress = progress;
// 指针显示的进度
this.mProgress = (float) progress * 2.4f;
// 设置中间文字显示的数值
this.value = (float) (progress * 0.08);
invalidate();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int myWidthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
int myWidthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
int myHeightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
int myHeightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
// 获取宽度
if (myWidthSpecMode == MeasureSpec.EXACTLY) {
mWidth = myWidthSpecSize;
} else {
// wrap_content
mWidth = DensityUtil.dip2px(mContext, 120);
}
// 获取高度
if (myHeightSpecMode == MeasureSpec.EXACTLY) {
mHeight = myHeightSpecSize;
} else {
// wrap_content
mHeight = DensityUtil.dip2px(mContext, 120);
}
// 设置该view的宽高
setMeasuredDimension((int) mWidth, (int) mHeight);
}
}
总结
- 画布工具为我们提供的
canvas.rotate()
方法,指定的角度正数为顺时针旋转,负数为逆时针旋转,善用它可以让我们省去很多坐标位置计算,但要注意使用旋转后对之后绘制的影响,最好是使用canvas.save()
和canvas.restore()
方法避免影响到之后的绘制。 - 画布
canvas.translate()
方法,通过移动View原点坐标的方式来让画笔移动到想让它去的地方,移动后我们再通过canvas
绘制上去的图形,就是以移动后的坐标做为View的原点,它和canvas.rotate()
方法组合,可以减轻很多的计算负担,为了避免和之后绘制的模块有影响,通常需要使用canvas.save()
和canvas.restore()
方法把需要进行移动修改的地方包裹起来。 - 当以View的宽高做为圆直径时,要避免绘制最外围线条时圆与正方形相接的四个顶点被遮蔽,所以绘制前要通过
Paint.getStrokeWidth()
方法确定好外圆线条的宽度,再在这个基础上开始绘制。