自定义控件之实现KPI轮盘跑动和数字跑动
在项目开发的过程中,很多数据都容易被固定死(也是直接设置数据),动态效果偶尔只有在切换或者加载的过程中才会有那么的一点变化体验,在PC web端,时常能看到数据的跑动,逐步变化过程,这样给使用者使用起来数据更加的动态化,专业化。然而,如果把这些动态化实现在Android端,就使得APP的体验效果增强,同时也会提高用户的黏合度和使用频次。
如下图:实现KPI轮盘指针跑动,数字跑动效果,在加载数据时,能看到数据的变化过程。
分析上图的半圆和指针跑动,数值跑动效果,显然系统提供的控件是无法满足的,那么就只能手动自定义现实。
首先,先来分析需求:
1.根据KPI个数来绘制弧形线段颜色并连接成180度半圆,同时也绘制指针,其次给指针添加逐步变化动画。
2.文字跑动可以使用线程实现,但是使用线程做动画,会影响性能开销,所以改使用动画会更流畅一些。
需求分析完成后,既是自定义控件,那么得定义一些属性,方便在布局时使用,使view操作简单一些:
定义所需要的属性有以下几个:
<declare-styleable name="Statistics_View">
<!--圆形的半径-->
<attr name="radian" format="integer" />
<!--弧形的宽度-->
<attr name="strokeWidth" format="integer" />
<!--指针的宽度-->
<attr name="pointerWidth" format="integer" />
<!--动画执行时间-->
<attr name="sv_duration" format="integer" />
<!--指针颜色-->
<attr name="pointerColor" format="color" />
</declare-styleable>
属性定义完成后,通过第三个构造获取定义的属性并初始化画笔:
成员变量部分:
private int angle;//角度值
private int mRadian; //弧度为值
private Paint mPaint;
// mPointerPaint;//画笔
private int[] mColors = {0xffDF0D30, 0xffF69729, 0xffD4D03B, 0xff3AEC26, 0xff5AFFEF};//弧形线段默认颜色
private int mStrokeWidth;// 半圆形边缘宽度
private int mPointerWidth;//指针宽度
private int currentNum;//记录值动画执行过程中的跑动值
private int duration;//动画时长
private int mColor;
public StatisticsView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
TypedArray array = context.getTheme().obtainStyledAttributes(attrs, R.styleable.Statistics_View, defStyle, 0);
int count = array.getIndexCount();
for (int i = 0; i < count; i++) {
int attr = array.getIndex(i);
if (attr == R.styleable.Statistics_View_radian) {
mRadian = array.getInt(attr, 180);
} else if (attr == R.styleable.Statistics_View_strokeWidth) {
mStrokeWidth = array.getInt(attr, 5);
} else if (attr == R.styleable.Statistics_View_pointerWidth) {
mPointerWidth = array.getInt(attr, 5);
} else if (attr == R.styleable.Statistics_View_sv_duration) {
duration = array.getInt(attr, 1000);
} else if (attr == R.styleable.Statistics_View_pointerColor) {
mColor = array.getColor(attr, getResources().getColor(R.color.color_red));
}
}
array.recycle();
initPaint();
}
private void initPaint() {
// mPointerPaint = new Paint();
// mPointerPaint.setColor(mColor);
// mPointerPaint.setAntiAlias(true);
// mPointerPaint.setStyle(Paint.Style.FILL);
mPaint = new Paint();
mPaint.setAntiAlias(true);
mPaint.setDither(true);
//设置圆形边缘平滑填充
mPaint.setStyle(Paint.Style.STROKE);
}
因为View的宽高是何种使用形式,我们不确定,所以需要测试一下view常用的宽高使用方式:如wrap_content类型、match_parent类型,固定值(100dp),或者是设置了margin和pading值
重写之前先了解MeasureSpec(测试尺寸)的specMode(测量模式),一共三种类型:
EXACTLY:一般是设置了明确的值或者是MATCH_PARENT
AT_MOST:表示子布局限制在一个最大值内,一般为WARP_CONTENT
UNSPECIFIED:表示子布局想要多大就多大,很少使用
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int resultWidth;
// 获取宽度测量规格中的mode
int modeWidth = MeasureSpec.getMode(widthMeasureSpec);
// 获取宽度测量规格中的size
int sizeWidth = MeasureSpec.getSize(widthMeasureSpec);
//如果是明确值或者match_parent
if (modeWidth == MeasureSpec.EXACTLY) {
//直接赋值指定,如300dp
resultWidth = sizeWidth;
} else {//如果值是wrap
// 如果设置了padding或者margin值
resultWidth = getPaddingLeft() + getMeasuredWidth() + getPaddingRight();
//wrap类型
if (modeWidth == MeasureSpec.AT_MOST) {
resultWidth = Math.min(resultWidth, sizeWidth);
}
}
int resultHeight;
int modeHeight = MeasureSpec.getMode(heightMeasureSpec);
int sizeHeight = MeasureSpec.getSize(heightMeasureSpec);
if (modeHeight == MeasureSpec.EXACTLY) {
resultHeight = sizeHeight;
} else {
resultHeight = getPaddingTop() + sizeHeight + getPaddingBottom();
if (modeHeight == MeasureSpec.AT_MOST) {
resultHeight = Math.min(resultHeight, sizeHeight);
}
}
// 测量尺寸
setMeasuredDimension(resultWidth, resultHeight);
}
完成测量后,在onDraw里面使用父类初始化完成后传递给子类的画板绘制弧形线段颜色和半圆
@Override
protected void onDraw(Canvas canvas) {
//绘制弧形线段连接成半圆
drawSemicircle(canvas);
//绘制指针
drawPointer(canvas);
}
/**
* 画指针
*
* @param canvas
*/
private void drawPointer(Canvas canvas) {
int height = getHeight();
int width = getWidth();
//指针起始中心点:取x轴宽的一半为,Y轴高的顶点,得到半圆的中心位置
float startX = width / 2;
float startY = height;
//设置指针的宽度和颜色
mPaint.setStrokeWidth(mPointerWidth);
mPaint.setColor(mColor);
//X轴由于有弧形线段宽度,所以指针经过时需要用弧形宽度3倍的距离,这样就能使指针在弧形内部而不超出弧形范围转动
//Y轴的高是整个圆的高不变
float stopX = mStrokeWidth * 3;
float stopY = height;
// 设置指针角度的变量值,通过值来不停的改变,rotate使指针沿着设定的值范围内转动
canvas.rotate(currentNum, startX, startY);
// 绘制指针
canvas.drawLine(startX, startY, stopX, stopY, mPaint);
// 绘制指针点心圆点
// canvas.drawCircle(startX, height, stopX, mPointerPaint);
}
/**
* 画半圆
*
* @param canvas
*/
private void drawSemicircle(Canvas canvas) {
int width = getWidth();
int height = getHeight();
//为了避免边缘被遮挡,设置left,top,right三边的padding为5
float padding = 5;
float left = padding;
float top = padding;
float right = width - padding;
float bottom = height * 2;
//设置半圆宽度
mPaint.setStrokeWidth(mStrokeWidth);
//构建一个四边正方形,设置好正方形的宽高
RectF rectF = new RectF(left, top, right, bottom);
//设置半圆弧形线段渲染颜色,dis_move=180/5=36
int dis_move = mRadian / mColors.length;
//根据颜色个数值来绘制不同弧形线段并着色
for (int i = 0; i < mColors.length; i++) {
mPaint.setColor(mColors[i]);
//在第一条线段起,每下一条接着上一条结束位置处渲染开始
int startAngle = mRadian + (dis_move * i);
//在正方形内绘制半圆
canvas.drawArc(rectF, startAngle, dis_move, false, mPaint);
}
}
至此,半圆和指针会长结束,接下来使指针转动,使用线程也可以,但是使用值动画会流程一些。
原理:ValueAnimator是一个值动画,可以根据起始值和结束值来执行,在设定的时长内完成设定好的起始值到结束值之间的变化,通过监听,并回调返回这些值在变化中的变量值从而来达到更新指针目的。
/**
* 开始执行动画
*
* @return
*/
public void startAnim() {
ValueAnimator intAnimator = new ValueAnimator().ofInt(0, angle);
intAnimator.setDuration(duration);//一秒内完成动画
intAnimator.addUpdateListener(this);
intAnimator.start();
}
@Override
public void onAnimationUpdate(ValueAnimator animation) {
//返回的值通过转换后异步刷新ondraw方法
currentNum = (int) animation.getAnimatedValue();
StatisticsView.this.postInvalidate();
}
完整的实现半圆和指针跑动效果的代码
/**
* Create by bob on 2018/10/18
*/
public class StatisticsView extends View implements ValueAnimator.AnimatorUpdateListener {
private int angle;//角度值
private int mRadian; //弧度为值
private Paint mPaint;
// mPointerPaint;//画笔
private int[] mColors = {0xffDF0D30, 0xffF69729, 0xffD4D03B, 0xff3AEC26, 0xff5AFFEF};//弧形线段默认颜色
private int mStrokeWidth;// 半圆形边缘宽度
private int mPointerWidth;//指针宽度
private int currentNum;//记录值动画执行过程中的跑动值
private int duration;//动画时长
private int mColor;
public StatisticsView(Context context) {
this(context, null);
}
public StatisticsView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public StatisticsView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
TypedArray array = context.getTheme().obtainStyledAttributes(attrs, R.styleable.Statistics_View, defStyle, 0);
int count = array.getIndexCount();
for (int i = 0; i < count; i++) {
int attr = array.getIndex(i);
if (attr == R.styleable.Statistics_View_radian) {
mRadian = array.getInt(attr, 180);
} else if (attr == R.styleable.Statistics_View_strokeWidth) {
mStrokeWidth = array.getInt(attr, 5);
} else if (attr == R.styleable.Statistics_View_pointerWidth) {
mPointerWidth = array.getInt(attr, 5);
} else if (attr == R.styleable.Statistics_View_sv_duration) {
duration = array.getInt(attr, 1000);
} else if (attr == R.styleable.Statistics_View_pointerColor) {
mColor = array.getColor(attr, getResources().getColor(R.color.color_red));
}
}
array.recycle();
initPaint();
}
private void initPaint() {
// mPointerPaint = new Paint();
// mPointerPaint.setColor(mColor);
// mPointerPaint.setAntiAlias(true);
// mPointerPaint.setStyle(Paint.Style.FILL);
mPaint = new Paint();
mPaint.setAntiAlias(true);
mPaint.setDither(true);
//设置圆形边缘平滑填充
mPaint.setStyle(Paint.Style.STROKE);
}
/**
* 设置指针角度值
*
* @param value
* @return
*/
public StatisticsView setAngleValue(int value) {
this.angle = value;
return this;
}
/**
* 颜色资源数组
*
* @param colorArrayId
* @return
*/
public StatisticsView setColors(int colorArrayId) {
this.mColors = getContext().getResources().getIntArray(colorArrayId);
return this;
}
/**
* 设置动画时长
*
* @param duration 毫秒
* @return
*/
public StatisticsView setDuration(int duration) {
this.duration = duration;
return this;
}
/**
* 设置弧度值
*/
public StatisticsView setRadian(int radian) {
this.mRadian = radian;
return this;
}
// /**
// * 设置指针和指针中心圆点颜色
// *
// * @param resColor
// * @return
// */
// public StatisticsView setPointerAndArcColor(int resColor) {
// int color = getResources().getColor(resColor);
// mPointerPaint.setColor(color);
// mPaint.setColor(color);
// return this;
// }
//
// /**
// * 设置指针中心圆点颜色
// *
// * @param resColor
// * @return
// */
// public StatisticsView setPointerArcColor(int resColor) {
// int color = getResources().getColor(resColor);
// mPointerPaint.setColor(color);
// return this;
// }
/**
* 设置指针颜色
*
* @param resColor
* @return
*/
public StatisticsView setPointerColor(int resColor) {
int color = getResources().getColor(resColor);
mPaint.setColor(color);
return this;
}
/**
* 设置半圆环厚度
*
* @param strokeWidth
* @return
*/
public StatisticsView setStrokeWidth(int strokeWidth) {
this.mStrokeWidth = strokeWidth;
return this;
}
/**
* 刷新View
*
* @return
*/
public StatisticsView refresh() {
invalidate();
return this;
}
/**
* 开始执行动画
*
* @return
*/
public void startAnim() {
ValueAnimator intAnimator = new ValueAnimator().ofInt(0, angle);//设置起始值到终点值
intAnimator.setDuration(duration);//一秒内完成动画
intAnimator.addUpdateListener(this);
intAnimator.start();
}
@Override
public void onAnimationUpdate(ValueAnimator animation) {
currentNum = (int) animation.getAnimatedValue();
StatisticsView.this.postInvalidate();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int resultWidth;
// 获取宽度测量规格中的mode
int modeWidth = MeasureSpec.getMode(widthMeasureSpec);
// 获取宽度测量规格中的size
int sizeWidth = MeasureSpec.getSize(widthMeasureSpec);
//如果是明确值
if (modeWidth == MeasureSpec.EXACTLY) {
//直接赋值指定,如300dp
resultWidth = sizeWidth;
} else {//如果值是wrap或者match_parent
// 设置padding值
resultWidth = getPaddingLeft() + getMeasuredWidth() + getPaddingRight();
//wrap类型
if (modeWidth == MeasureSpec.AT_MOST) {
resultWidth = Math.min(resultWidth, sizeWidth);
}
}
int resultHeight;
int modeHeight = MeasureSpec.getMode(heightMeasureSpec);
int sizeHeight = MeasureSpec.getSize(heightMeasureSpec);
if (modeHeight == MeasureSpec.EXACTLY) {
resultHeight = sizeHeight;
} else {
resultHeight = getPaddingTop() + sizeHeight + getPaddingBottom();
if (modeHeight == MeasureSpec.AT_MOST) {
resultHeight = Math.min(resultHeight, sizeHeight);
}
}
// 测量尺寸
setMeasuredDimension(resultWidth, resultHeight);
}
@Override
protected void onDraw(Canvas canvas) {
//绘制弧形线段连接成半圆
drawSemicircle(canvas);
//绘制指针
drawPointer(canvas);
}
/**
* 画指针
*
* @param canvas
*/
private void drawPointer(Canvas canvas) {
int height = getHeight();
int width = getWidth();
//指针起始中心点:取x轴宽的一半为,Y轴高的顶点,得到半圆的中心位置
float startX = width / 2;
float startY = height;
//设置指针线条的宽度和颜色
mPaint.setStrokeWidth(mPointerWidth);
mPaint.setColor(mColor);
//指针起始结束点:停留在x,Y轴的另一顶点位置为:x轴-半圆的宽度
float stopX = mStrokeWidth * 3;
float stopY = height;
// 调试指针角度的值
canvas.rotate(currentNum, startX, startY);
// 绘制指针
canvas.drawLine(startX, startY, stopX, stopY, mPaint);
//绘制指针点心圆点
// canvas.drawCircle(startX, height, stopX, mPointerPaint);
}
/**
* 画半圆
*
* @param canvas
*/
private void drawSemicircle(Canvas canvas) {
int width = getWidth();
int height = getHeight();
//为了避免边缘被遮挡,设置left,top,right三边的padding为5
float padding = 5;
float left = padding;
float top = padding;
float right = width - padding;
float bottom = height * 2;
//设置半圆宽度
mPaint.setStrokeWidth(mStrokeWidth);
RectF rectF = new RectF(left, top, right, bottom);
//设置半圆弧形线段渲染颜色,dis_move=180/5=36
int dis_move = mRadian / mColors.length;
for (int i = 0; i < mColors.length; i++) {
mPaint.setColor(mColors[i]);//红
//在第一条线段起,每下一条接着上一条结束位置处渲染开始
int startAngle = mRadian + (dis_move * i);
canvas.drawArc(rectF, startAngle, dis_move, false, mPaint);
}
}
}