前言:继上次写了自定义圆形进度条后,今天给大家带来自定义扇形饼状图。先上效果图:
是不是很炫?看上去还有点立体感。下面带大家一起来瞧一瞧吧。
看着这个效果图,我们可以想象下接下来暂时会需要用到以下属性:
/**
* 存放事物的品种与其对应的数量
*/
private Map kindsMap = new LinkedHashMap();
/**
* 存放颜色
*/
private ArrayList colors = new ArrayList<>();
private Paint mPaint;//饼状画笔
private Paint mTextPaint; // 文字画笔
private static final int DEFAULT_RADIUS = 200;
private int mRadius = DEFAULT_RADIUS; //外圆的半径
private String centerTitle; //中间标题
然后重写父类的构造方法,初始化画笔:
public PieChatView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mPaint = new Paint();
mTextPaint = new Paint();
mPaint.setAntiAlias(true);
mPaint.setStyle(Paint.Style.FILL);
mTextPaint.setColor(Color.BLACK);
mTextPaint.setAntiAlias(true);
mTextPaint.setStyle(Paint.Style.STROKE);
mTextPaint.setTextAlign(Paint.Align.CENTER);
}
public PieChatView(Context context) {
this(context, null, 0);
}
public PieChatView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
一般都是一个参数和两个参数的全部调用第三个参数的。我三个参数的构造方法中没有去从xml文件中去获取属性了。我完全就用代码实现了。也可以将一些属性放在attrs.xml文件中,然后去获取。自行选择吧。
这个方法,只要你以前写过自定义View,基本上就是一样的套路:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int wideSize = MeasureSpec.getSize(widthMeasureSpec);
int wideMode = MeasureSpec.getMode(widthMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int width, height;
if (wideMode == MeasureSpec.EXACTLY) { //精确值 或matchParent
width = wideSize;
} else {
width = mRadius * 2 + getPaddingLeft() + getPaddingRight();
if (wideMode == MeasureSpec.AT_MOST) {
width = Math.min(width, wideSize);
}
}
if (heightMode == MeasureSpec.EXACTLY) { //精确值 或matchParent
height = heightSize;
} else {
height = mRadius * 2 + getPaddingTop() + getPaddingBottom();
if (heightMode == MeasureSpec.AT_MOST) {
height = Math.min(height, heightSize);
}
}
setMeasuredDimension(width, height);
mRadius = (int) (Math.min(width - getPaddingLeft() - getPaddingRight(),
height - getPaddingTop() - getPaddingBottom()) * 1.0f / 2);
}
就是获得系统测量好的宽高,和模式后分三种模式去讨论。最终通过setMeasuredimension()确定宽高的值。宽高确定好了,那就可以确定下整个饼状图的半径 mRadius了。取宽高中的较小的那个。
我们需要画一个一个的扇形,还有将扇形从零到360的动画效果,还有扇形中的文字,中间的文字,还有实现立体感的效果。
画一个扇形还是蛮容易的:通过画布调用drawArc()方法话一个60度的扇形:
mPaint.setStyle(Paint.Style.FILL);
mTextPaint.setColor(Color.RED);
RectF oval = new RectF(-mRadius, -mRadius, mRadius, mRadius);
mCanvas.drawArc(oval, 0, 60, true, mPaint);
前面设置下画笔的颜色,style为实心,就这几行代码的事情。第一个参数:是一个正方形,表明在这个区域内画扇形。第二个参数:从哪个角度开始画,第三个参数:就是画的角度度数,而不是画到哪个角度去。第四个参数:是否以正方形中心为圆心。我这样一说,你可能就会有点懵逼了。你自己改为false,看效果,就理解我说的了。效果如下:
目前画了一个扇形,如此多的扇形需要用户去设置数据,来确定每个扇形的角度.
public ArrayList getColors() {
return colors;
}
public void setColors(ArrayList colors) {
this.colors = colors;
}
public void setDataMap(LinkedHashMap map) {
this.kindsMap = map;
}
public String getCenterTitle() {
return centerTitle;
}
public void setCenterTitle(String centerTitle) {
this.centerTitle = centerTitle;
}
然后通过遍历,一 一画出这些扇形:
@Override
protected void onDraw(Canvas mCanvas) {
super.onDraw(mCanvas);
mCanvas.translate((getWidth() + getPaddingLeft() - getPaddingRight()) / 2, (getHeight() + getPaddingTop() - getPaddingBottom()) / 2);
paintPie(mCanvas);
}
private void paintPie(final Canvas mCanvas) {
if (kindsMap != null) {
Set> entrySet = kindsMap.entrySet();
Iterator> iterator = entrySet.iterator();
int i = 0;
float currentAngle = 0.0f;
while (iterator.hasNext()) {
Map.Entry entry = iterator.next();
int num = entry.getValue();
float needDrawAngle = num * 1.0f / sum * 360;
mPaint.setColor(colors.get(i));
mCanvas.drawArc(oval, currentAngle, needDrawAngle - 1, true, mPaint);
currentAngle = currentAngle + needDrawAngle;
i++;
}
}
}
记得,要先将画笔移到画布中心,currentAngle:当前的角度值,needDrawAngle :需要画多少角度。sum:数据的总个数,为每一个数据相加的和,计算百分比。效果如图:
咋一看离我们的预期效果还差好远。只有画完以后的样子,动画都没有。别急别急,动画马上就来:
private void initAnimator() {
ValueAnimator anim = ValueAnimator.ofFloat(0, 360);
anim.setDuration(10000);
anim.setInterpolator(new AccelerateDecelerateInterpolator());
anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
animatedValue = (float) valueAnimator.getAnimatedValue();
invalidate();
}
});
anim.start();
}
设置一个动画,在10秒内,随着时间的推移,animatedValue从0到360度发生变化,每一次监听到animatedValue的值得改变,就去刷新View,执行ondraw();这个时候我们来修改下前面的paintPie();
if (Math.min(needDrawAngle-1, animatedValue - currentAngle) >= 0) {
mPaint.setColor(colors.get(i));
mCanvas.drawArc(oval, currentAngle, Math.min(needDrawAngle - 1, animatedValue - currentAngle), true, mPaint);
}
我在这个地方遇到了一个坑----ondraw()方法多次执行时,每一次执行完毕后会抹去上一次画过的图形.也就是说,我没用动画的时候,ondraw()方法就调用了一次,调用了while循环后,把所有的扇形画出来了。用了动画之后,animatedValue的值一改变,就会执行一次while循环。每次while循环都会重头开始画,我们只要保证每次while循环所画的角度正好是animatedValue,就ok了。所以,在needDrawAngle-1小于animatedValue - currentAngle时,就把该部分扇形画出来。在needDrawAngle-1大于animatedValue - currentAngle时,那就只画animatedValue - currentAngle的角度。
之所以减一,是因为扇形之间留一条白白的缝隙。
当某个扇形角度,不够文字的宽度时,我们就会将文字画在圆的外面。所以这个我们得添加一个属性minAngle,角度最小值,添加getter和setter方法,让使用者去设置。当needDrawAngle小于这个角度值时,就画在外面。大于这个值就画在扇形中央。
//画文字
private void drawText(Canvas mCanvas, float textAngle, String kinds, float needDrawAngle) {
Rect rect = new Rect();
mTextPaint.setTextSize(sp2px(15));
mTextPaint.getTextBounds(kinds, 0, kinds.length(), rect);
if (textAngle >= 0 && textAngle <= 90) { //画布坐标系第一象限(数学坐标系第四象限)
if (needDrawAngle < minAngle) { //如果小于某个度数,就把文字画在饼状图外面
mCanvas.drawText(kinds, (float) (mRadius * 1.2 * Math.cos(Math.toRadians(textAngle))), (float) (mRadius * 1.2 * Math.sin(Math.toRadians(textAngle)))+rect.height()/2, mTextPaint);
} else {
mCanvas.drawText(kinds, (float) (mRadius * 0.75 * Math.cos(Math.toRadians(textAngle))), (float) (mRadius * 0.75 * Math.sin(Math.toRadians(textAngle)))+rect.height()/2, mTextPaint);
}
} else if (textAngle > 90 && textAngle <= 180) { //画布坐标系第二象限(数学坐标系第三象限)
if (needDrawAngle < minAngle) {
mCanvas.drawText(kinds, (float) (-mRadius * 1.2 * Math.cos(Math.toRadians(180 - textAngle))), (float) (mRadius * 1.2 * Math.sin(Math.toRadians(180 - textAngle)))+rect.height()/2, mTextPaint);
} else {
mCanvas.drawText(kinds, (float) (-mRadius * 0.75 * Math.cos(Math.toRadians(180 - textAngle))), (float) (mRadius * 0.75 * Math.sin(Math.toRadians(180 - textAngle)))+rect.height()/2, mTextPaint);
}
} else if (textAngle > 180 && textAngle <= 270) { //画布坐标系第三象限(数学坐标系第二象限)
if (needDrawAngle < minAngle) {
mCanvas.drawText(kinds, (float) (-mRadius * 1.2 * Math.cos(Math.toRadians(textAngle - 180))), (float) (-mRadius * 1.2 * Math.sin(Math.toRadians(textAngle - 180)))+rect.height()/2, mTextPaint);
} else {
mCanvas.drawText(kinds, (float) (-mRadius * 0.75 * Math.cos(Math.toRadians(textAngle - 180))), (float) (-mRadius * 0.75 * Math.sin(Math.toRadians(textAngle - 180)))+rect.height()/2, mTextPaint);
}
} else { //画布坐标系第四象限(数学坐标系第一象限)
if (needDrawAngle < minAngle) {
mCanvas.drawText(kinds, (float) (mRadius * 1.2 * Math.cos(Math.toRadians(360 - textAngle))), (float) (-mRadius * 1.2 * Math.sin(Math.toRadians(360 - textAngle)))+rect.height()/2, mTextPaint);
} else {
mCanvas.drawText(kinds, (float) (mRadius * 0.75 * Math.cos(Math.toRadians(360 - textAngle))), (float) (-mRadius * 0.75 * Math.sin(Math.toRadians(360 - textAngle)))+rect.height()/2, mTextPaint);
}
}
}
首先,画文字,我们要知道文字的中心点坐标。所以怎么来求呢?看图:
通过这幅图,大家应该知道了吧。唯一一点要说的就是画图的坐标系和数学中的坐标系不一样。画的时候还要分四个象限的情况去讨论。有的人说,为什么不是在1/2r处,而是在0.75r处呢?因为我们中心还要画title的呀,对吧。画在1/2处,不就覆盖了嘛!
然后在中心画文字咯。文字很容易。先画个白色背景的圆半径为外圆半径的一半,这样就覆盖了扇形中间的一部分。在内圆上再画字。就OK了。
那立体效果怎么实现呢?
也很简单,可以仔细观察,那部分好像是透明的。隐约能看见前面画的扇形。所以我们在画内园之前先画个比内园稍微大一点的透明圆。设置画笔的透明度就搞定。上代码:
while (iterator.hasNext()) {
Map.Entry entry = iterator.next();
String kinds = entry.getKey();
int num = entry.getValue();
float needDrawAngle = num * 1.0f / sum * 360;
String drawAngle = dff.format(needDrawAngle / 360 * 100);
kinds = kinds + "," + drawAngle + "%";
float textAngle = needDrawAngle / 2 + currentAngle;
if (Math.min(needDrawAngle, animatedValue - currentAngle) >= 0) {
mPaint.setColor(colors.get(i));
mCanvas.drawArc(oval, currentAngle, Math.min(needDrawAngle - 1, animatedValue - currentAngle), true, mPaint);
mPaint.setColor(Color.WHITE);
mPaint.setAlpha(10);
mCanvas.drawCircle(0, 0, mRadius / 2 + dp2px(10), mPaint);
mPaint.setAlpha(255);
mCanvas.drawCircle(0, 0, mRadius / 2, mPaint);
drawCenterText(mCanvas, centerTitle, 0, 0, mTextPaint);
drawText(mCanvas, textAngle, kinds, needDrawAngle);
}
currentAngle = currentAngle + needDrawAngle;
i++;
}
该画的画完了。接下来就是一些优化和修改了。
/**
* dp 2 px
*
* @param dpVal
*/
protected int dp2px(int dpVal) {
return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
dpVal, getResources().getDisplayMetrics());
}
/**
* sp 2 px
*
* @param spVal
* @return
*/
protected int sp2px(int spVal) {
return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP,
spVal, getResources().getDisplayMetrics());
}
因为图中有很多地方接触到文字,如果设置文字的时候用的是px,那么在不同的机器上,显示的文字大小将会差距很大。最好也提供一些方法让使用者去设置这些字体的大小。
因为数据是使用者设置上去的,什么时候开启动画,还得提供一个公开方法:
public void startDraw() {
if (kindsMap != null && colors != null && centerTitle != null) {
initAnimator();
}
}
可以知道,我用了两个容器来装数据。有没有感觉到浪费资源?其实我们完全可以只用一个ArrayList即可。对吧。将所有数据定义为一个实体的bean类。说到这,又遇到一个坑,我最开始使用hashMap去存数据的,优越hashMap存放数据是无序的,所以每次画出来的扇形结构不一样。你也许又会问我,怎么颜色总是飘忽不定啊?因为我是用了个for循环随机生成的颜色,哈哈。
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
PieChatView pieChatView = (PieChatView) findViewById(R.id.pie);
kindsMap.put("苹果", 10);
kindsMap.put("梨子", 30);
kindsMap.put("香蕉", 10);
kindsMap.put("葡萄", 30);
kindsMap.put("哈密瓜", 10);
kindsMap.put("猕猴桃",30);
kindsMap.put("草莓", 10);
kindsMap.put("橙子", 30);
kindsMap.put("火龙果", 10);
kindsMap.put("椰子", 20);
for (int i = 1; i <= 40; i++){
int r= (new Random().nextInt(100)+10)*i;
int g= (new Random().nextInt(100)+10)*3*i;
int b= (new Random().nextInt(100)+10)*2*i;
int color = Color.rgb(r,g,b);
if(Math.abs(r-g)>10&&Math.abs(r-b)>10&&Math.abs(b-g)>10){
colors.add(color);
}
}
pieChatView.setCenterTitle("水果大拼盘");
pieChatView.setDataMap(kindsMap);
pieChatView.setColors(colors);
pieChatView.setMinAngle(50);
pieChatView.startDraw();
到这里,文章就结束了。最后附上代码的github地址:
https://github.com/Demidong/ClockView
我写的博客还不是很多,欢迎大家参与讨论,留言,指正我的不足。