饼图的使用场景几乎无处不在,前段时间心血来潮,自定义了一个饼图控件,并且添加了各个区域的点击事件处理。
一、控件的绘制
基本的饼图绘制,相信有点自定义控件基础的各位应该都能绘制出来。
@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);
int width;
int height;
// 判断宽度指定模式
if (widthMode == MeasureSpec.EXACTLY) {
// 引用指定宽度
width = widthSize;
} else {
// 默认控件宽度设置为400dp
width = dp2Px(400);
}
// 判断高度指定模式
if (heightMode == MeasureSpec.EXACTLY) {
// 引用指定高度
height = heightSize;
}
// 宽度精确指定,高度没有指定
else if (widthMode == MeasureSpec.EXACTLY) {
// 按照宽的八分之五作为默认高
height = (int) (width * 0.625);
} else {
// 默认控件高度设置为250dp
height = dp2Px(250);
}
// 计算参数区域单位高度(每个单位区域分成4部分,上面和下面间隔1个单位,绘制2个单位)
textUnitHeight = (height * 7 / 8) / columnsLength / 4;
// 初始化PieChart位置
rectF.set(height / 16, height / 16, height * 15 / 16, height * 15 / 16);
setMeasuredDimension(width, height);
}
/**
* 按照最大角度绘制PieChart
*
* @param canvas Canvas对象
* @param maxDegrees 最大角度
*/
private void drawPieChartWithMaxDegrees(Canvas canvas, int maxDegrees) {
// 初始圆弧角度
float degrees = -90;
// 遍历数组,绘制圆弧
for (int i = 0; i < columnsLength; i++) {
// 判断栏目权重是否为0
final float deltaD = currentAccent == i ? columnsPercent.get(i) * 360 : columnsPercent.get(i) * maxDegrees;
if (columnsPercent.get(i) != 0 && deltaD != 0) {
// 如果栏目权重不为0,进行绘制
paint.reset();
paint.setAntiAlias(true);
paint.setColor(ContextCompat.getColor(getContext(), colors[i]));
canvas.drawArc(rectF, degrees, deltaD, true, paint);
// 如果饼图绘制完成(动画效果完成),计算各栏目的区域
if (maxDegrees == 360 && regions[i] == null) {
// 计算区域边界的Path
path.reset();
path.moveTo(rectF.centerX(), rectF.centerY());
path.lineTo(rectF.centerX() + (float) Math.sin(degrees),
rectF.centerY() + (float) Math.cos(degrees));
path.arcTo(rectF, degrees, deltaD);
// 计算Region对象
Region region = new Region();
region.setPath(path, new Region((int) rectF.left, (int) rectF.top, (int) rectF.right, (int) rectF
.bottom));
// 添加到数组中
regions[i] = region;
}
degrees = degrees + deltaD;
}
// 左 - PieChart宽度加上一个单位高度
final int left = getMeasuredHeight() + textUnitHeight;
// 上 - PieChart间距加上一个单位高度加上不同项目之间的偏移量
final int top = getMeasuredHeight() / 16 + textUnitHeight + i * 4 * textUnitHeight;
// 右 - PieChart宽度加上三个单位高度
final int right = getMeasuredHeight() + 3 * textUnitHeight;
// 底 - PieChart间距加上三个单位高度加上不同项目之间的偏移量
final int bottom = getMeasuredHeight() / 16 + 3 * textUnitHeight + i * 4 * textUnitHeight;
// 绘制右侧参数区域示例图标
paint.reset();
paint.setAntiAlias(true);
paint.setColor(ContextCompat.getColor(getContext(), colors[i]));
rect.set(left, top, right, bottom);
canvas.drawRect(rect, paint);
// 绘制右侧参数区域说明文字
final String text = String.format("%s (%s)", columns[i], floatToPercent(columnsPercent.get(i), 1, true));
paint.reset();
paint.setAntiAlias(true);
paint.setColor(Color.rgb(37, 37, 37));
paint.setTextSize(dp2Px(14));
rect.set(left + 4 * textUnitHeight, top, getMeasuredWidth(), bottom);
paint.getTextBounds(text, 0, text.length(), rect);
Paint.FontMetricsInt fontMetrics = paint.getFontMetricsInt();
// 计算文本居左时的XY坐标
float xPosition = left + 3 * textUnitHeight;
float yPosition = (2 * textUnitHeight - fontMetrics.bottom + fontMetrics.top) / 2 - fontMetrics.top + top;
canvas.drawText(text, xPosition, yPosition, paint);
}
}
上面的代码,我觉得注释应该算比较详尽了,这里我再稍微解释一下。
onMeasure()
方法搞定控件的大小之后,我们就可以根据控件的高宽,确定各部分的布局位置以及饼图的半径等所需的参数。根据传递给控件的各栏目的权重,可以计算出各栏目的百分比,然后根据这个百分比我们可以去计算出各栏目在一个圆中的所占角度。
为什么这里要强调百分比这个中间变量,正如各位在本文开头所见效果,整个饼图是从0度角逐渐放大到一个整圆的,随着整个控件从圆弧到圆的不断放大,这个过程中每个栏目的百分比是不会变化的。因此,我们可以暂且定义一个百分比数组变量,后面的绘制就不需要进行重复的计算了。
至于这个动画过程,我们首先想到的就是属性动画。从0不断放大到360。这时候,上面的drawPieChartWithMaxDegrees()
函数的第二个参数就起作用了,我们只需要将属性动画中产生的值传入函数然后不断绘制,这样一个动态的饼图绘制就完成了。
二、不规则区域的点击事件处理
绘制完了控件之后,我们就会去思考,该如何添加点击事件呢?
按照以往的方法,在onTouchEvent()
中根据回调传递进来的事件对象,获取事件触发点的坐标,然后判断坐标的范围确定触发何种互动事件。但是在本案中,一个栏目的形状是圆弧,这样我们怎么根据X和Y判断用户点击了哪一个栏目呢?这个判断起来好像比较复杂。那,我们需要换一种思路。
安卓中有一个Region
类,顾名思义,这个类创建出来的对象就是一个区域。而Region
类中有一个setPath()
方法,使得我们可以根据Path
对象去完成一个Region
对象的创建。
好了,有了基本的思路,接下来就是创建Path
对象的问题了。
// 计算区域边界的Path
path.reset();
path.moveTo(rectF.centerX(), rectF.centerY());
path.lineTo(rectF.centerX() + (float) Math.sin(degrees), rectF.centerY() + (float) Math.cos(degrees));
path.arcTo(rectF, degrees, deltaD);
注1:
degrees
是顺时针来看,当前圆弧的起始位置到手机坐标系Y轴正方向的角度。
注2:deltaD
是顺时针来看,当前圆弧的结束位置到手机坐标系Y轴正方向的角度。
注3:rectF
是设定的控件表示范围,在onMeasure()
中进行设置:rectF.set(height / 16, height / 16, height * 15 / 16, height * 15 / 16);
。
好,我来解释一下上面这段代码。
如上图所示,首先,将Path
对象移动到圆心点,然后通过lineTo()
函数连线到A点,这个A点的坐标,X坐标就是圆心点X坐标+sin(degrees),而Y坐标就是圆心点Y坐标+cos(degrees)。然后通过arcTo()
函数绘制这个栏目的圆弧,从degrees开始顺时针旋转deltaD个角度。这样,我们一个栏目的边界Path
对象就创建完成了。
有了这个Region
对象,我们在判断点击事件的时候,遍历一下所有的区域对象,然后通过regions[i].contains((int) event.getX(), (int) event.getY())
来判断点击的点是否在这个栏目之内。
@Override
public boolean onTouchEvent(MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_DOWN) {
if (regions != null && listener != null && isLastAnimatorFinished) {
final int size = regions.length;
// 遍历Region数组
for (int i = 0; i < size; i++) {
// 点击区域是栏目中的一个,输出事件
if (regions[i].contains((int) event.getX(), (int) event.getY())) {
// 标志位置否
isLastAnimatorFinished = false;
// 如果当前没有着重栏目,进行缩放着重
if (currentAccent == -1) {
listener.onColumnClick(i);
currentAccent = i;
// 着重栏目显示
accentPieChart();
}
// 如果当前有着重栏目,进行扩展恢复正常状态
else {
// 恢复饼图正常显示
normalPieChart();
}
// 事件处理完成,停止回馈上级
return true;
}
}
}
}
return super.onTouchEvent(event);
}
好了,点击事件的问题也就解决了,这样的控件,是不是各位也轻而易举的能完成了呢?
源码下载,请移步我的Github