闲来想自己写个饼状图,于是就动手开始画了。
主要的逻辑:1.根据比例依次旋转角度画出扇形;2.在扇形区域内设置内容数据;3.当点击某个扇形的时候,就让当前扇形脱离整体,空出一部分
看起来这个逻辑比较复杂,但是真正写下来之后就会发现其实饼状图也简单,主要就是围绕着安卓简单的自定义控件画扇形。效果如下:
接下来就开始代码实现:
首先初始化2个画笔,一个是画扇形的,一个是画扇形的边框的,还有初始化集合数据:
private void initPaint() {
mPaint = new Paint();
mPaint.setStyle(Paint.Style.FILL);
mPaint.setAntiAlias(true);
mPaintBorder = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaintBorder.setStyle(Paint.Style.STROKE);
mPaintBorder.setAntiAlias(true);
mPaintBorder.setColor(Color.BLACK);
mPaintBorder.setTextSize(35);
mDatas.add(new PieItemBean("测试1", 9, Color.rgb(155, 187, 90)));
mDatas.add(new PieItemBean("测试2", 3, Color.rgb(191, 79, 75)));
mDatas.add(new PieItemBean("测试3", 76f, Color.rgb(242, 167, 69)));
mDatas.add(new PieItemBean("测试4", 6, Color.rgb(60, 173, 213)));
mDatas.add(new PieItemBean("测试5", 6, Color.rgb(90, 79, 88)));
}
然后先复写onMeasure方法,以便设置控件的大小:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
setMeasuredDimension(measureValue(widthMeasureSpec), measureValue(heightMeasureSpec));
}
private int measureValue(int measureSpec) {
int result = 100;//设置最小值
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
if (specMode == MeasureSpec.EXACTLY) { //fill_parent或者设置了具体的宽高
result = specSize;
} else if (specMode == MeasureSpec.AT_MOST) { //wrap_content
result = Math.min(result, specSize);
}
return result;
}
接下来就是最重要的onDraw方法了.
在onDraw方法中,首先需要获得padding值和宽高,从而设置圆的半径:
int width = getMeasuredWidth();
int height = getMeasuredHeight();
int rWidth = width - getPaddingLeft() - getPaddingRight();
int rHeight = height - getPaddingTop() - getPaddingBottom();
mRadious = Math.min(rWidth, rHeight) / 2;
//圆心坐标
cenX = mRadious + getPaddingLeft();
cenY = mRadious + getPaddingTop();
然后就是根据每个条目所占比例来绘制扇形图了:
//用于存放当前百分比的圆心角度
float currentAngle = 0.0f;
float offsetAngle = 0f;//角度偏移量
for (int i = 0; i < mDatas.size(); i++) {
PieItemBean bean = mDatas.get(i);
currentAngle = per2Radious(totalAngle, bean.value);//得到当前角度
mPaint.setColor(bean.color);//给画笔设置颜色
if (mRectF == null) {//设置圆所需的范围
mRectF = new RectF(getPaddingLeft(), getPaddingTop(), width - getPaddingRight(), width - getPaddingRight());
}
//在饼图中显示所占比例
canvas.drawArc(mRectF, offsetAngle , currentAngle, true, mPaint);
//边框
canvas.drawArc(mRectF, offsetAngle , currentAngle, true, mPaintBorder);
//下次的起始角度
offsetAngle += currentAngle;
}
其中用到了2个工具方法:
/**
* 将百分比转换为图心角角度
*/
public float per2Radious(float totalAngle, float percentage) {
float angle = 0.0f;
if (percentage >= 101f || percentage < 0.0f) {
//Log.e(TAG,"输入的百分比不合规范.须在0~100之间.");
} else {
float v = percentage / 100;//先获取百分比
float itemPer = totalAngle * v;//获取对应角度的百分比
angle = round(itemPer, 2);//精确到小数点后面2位
}
return angle;
}
/**
* 四舍五入到小数点后scale位
*/
public float round(float v, int scale) {
if (scale < 0)
throw new IllegalArgumentException("The scale must be a positive integer or zero");
BigDecimal bgNum1 = new BigDecimal(v);
BigDecimal bgNum2 = new BigDecimal("1");
return bgNum1.divide(bgNum2, scale, BigDecimal.ROUND_HALF_UP).floatValue();
}
然后我们就可以先运行起来了,运行后结果如下:
可以看到整个轮廓已经出来了,接下来就是第二步,在扇形区域内部显示文字.在显示文字的的时候主要是要计算文字描绘的起点位置,因为对于三角函数只对90°内的熟悉,所以计算的时候都转换为90°之内的值来计算:
//先将度数定位到当前所在条目的一半位置
float degree = offsetAngle + currentAngle / 2;
//根据角度所在不同象限来计算出文字的起始点坐标
float dx = 0, dy = 0;
if (degree > 0 && degree <= 90f) {//在第四象限
dx = (float) (cenX + mRadious * 2.3 / 3 * Math.cos(2 * PI / 360 * degree));//注意Math.sin(x)中x为弧度值,并非数学中的角度,所以需要将角度转换为弧度
dy = (float) (cenY + mRadious * 2.7 / 3 * Math.sin(2 * PI / 360 * degree));
} else if (degree > 90f && degree <= 180f) {//在第三象限
dx = (float) (cenX - mRadious * 2.3 / 3 * Math.cos(2 * PI / 360 * (180f - degree)));
dy = (float) (cenY + mRadious * 2.7 / 3 * Math.sin(2 * PI / 360 * (180f - degree)));
} else if (degree > 180f && degree <= 270f) {//在第二象限
dx = (float) (cenX - mRadious * 2.3 / 3 * Math.cos(2 * PI / 360 * (270f - degree)));
dy = (float) (cenY - mRadious * 2.7 / 3 * Math.sin(2 * PI / 360 * (270f - degree)));
} else {
dx = (float) (cenX + mRadious * 2.3 / 3 * Math.cos(2 * PI / 360 * (360f - degree)));
dy = (float) (cenY - mRadious * 2.7 / 3 * Math.sin(2 * PI / 360 * (360f - degree)));
}
//文字的基本线坐标设置为半径的2.3/3位置处,起点y坐标设置为半径的2.7/3位置处
canvas.drawText(bean.value + "%", dx, dy, mPaintBorder);
//下次的起始角度
offsetAngle += currentAngle;
现在数据已经都绘制好了,剩下的就是扇形区的点击事件了
要判断点击的位置位于哪个条目,需要以下几点判断:
1.首先根据点击点的坐标来计算到圆心的距离,来判断点击位置是否在圆内部
2.如果在圆的内部,再计算出点击处与x轴所成的角度,然后根据该角度来判断在哪个数据合集内,之前的offsetAngle属性最后记录的都是当前元素在坐标系内横跨的角度,所以建立一个数组存起来,方便对比:
//下次的起始角度
offsetAngle+= currentAngle;
degrees[i] = offsetAngle;
然后就是复写onTouchEvent来计算了。在这里补充一下onTouchEvent事件的返回值说明:如果返回true,则是当前触摸事件(整个事件包括down,move,up)不处理,留着交给父控件处理或者在别的动作的时候消耗,所以在down,move时都返回true,以便将触摸事件能够交给up时处理,up处理完,当前的点击事件已经处理完成了,所以可以返回false,示意该控件已经处理完了这个事件,不需要父控件在处理了。
private long startTime;//点击的起始时间
@Override
public boolean onTouchEvent(MotionEvent event) {
float x = event.getX();
float y = event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
startTime = System.currentTimeMillis();
break;
case MotionEvent.ACTION_MOVE:
break;
case MotionEvent.ACTION_UP:
long currTime = System.currentTimeMillis();
float dx = x - cenX;
float dy = y - cenY;
if (currTime - startTime <= 500) {//按下和抬起的时间在500毫秒内认为是单击
float degree;//被点击选中的角度
//先判断是否在圆内部
if (isInCircle(dx, dy, mRadious)) {//根据不同的象限来获取到x轴的角度
if (dx > 0 && dy > 0) {//第四象限
degree = (float) (90f - 180 * Math.atan2(dx, dy) / PI);
} else if (dx < 0 && dy > 0) {//第三象限
degree = (float) (180 * Math.atan2(-dx, dy) / PI + 90f);
} else if (dx < 0 && dy < 0) {//第二象限
degree = (float) (180 * Math.atan2(dy, dx) / PI + 360f);
} else {//第一象限
degree = (float) (360f - 180 * Math.atan2(-dy, dx) / PI);
}
//然后判断该角度在哪个数据集内
selectedPos = judgeDegree(degree);
invalidate();//请求重绘
}
}
return false;
}
return true;
}
/**
* 根据坐标计算是否在圆内部
*/
private boolean isInCircle(float lx, float ly, float radius) {
double v = Math.pow(Math.abs(lx), 2) + Math.pow(Math.abs(ly), 2);
double dis = Math.sqrt(v);
if (dis > radius) {
return false;
}
return true;
}
/**
* 判断当前点击的在哪个数据集合里面
*/
private int judgeDegree(float degree) {
int selectedPos = 0;
for (int i = 0; i < degrees.length; i++) {
if (degree <= degrees[i]) {
selectedPos = i;
break;
}
}
return selectedPos;
}
至此,onTouchEvent事件已经处理完了,接下来就是在onDraw方法中将选中的条目剥离出来,单独处理,从而完成点击效果
if (selectedPos == i) {//选中的偏离一点儿
canvas.save();
canvas.rotate(offsetAngle + currentAngle / 2);//先将画布x轴旋转到当前角度的一半位置,
canvas.translate(50, 0);//然后在平移50个单位,就将被选中的模块独立出来了
canvas.drawArc(mRectF, currentAngle / 2, -currentAngle, true, mPaint);
//边框
canvas.drawArc(mRectF, currentAngle / 2, -currentAngle, true, mPaintBorder);
canvas.restore();
} else {
//在饼图中显示所占比例
canvas.drawArc(mRectF, offsetAngle , currentAngle, true, mPaint);
//边框
canvas.drawArc(mRectF, offsetAngle , currentAngle, true, mPaintBorder);
}
最后,再加入一个初始化时候的旋转动画
利用handler和message:
private static final int MSG_INFO = 0x24;
private int num = 0;
private float perDegree = 360f / 10;//单位扩大角度
private Handler mHander = new Handler() {
@Override
public void handleMessage(Message msg) {
super.handleMessage(msg);
if (msg.what == MSG_INFO) {
if (num > 10) {
return;
}
totalAngle = num * perDegree;
invalidate();
num++;
}
}
};
在onDraw中加入推送消息
mHander.sendEmptyMessageDelayed(MSG_INFO,20);