本文要介绍的是一个参照手机支付宝app里面记账本功能里的“饼状图”实现的控件。通常app中可能的数据展示控件有柱状图,折线图,饼状图等,如果需要一个包含多种View控件的库,那么 是不错的选择,如果只是需要一个简单的独立的饼状图控件,希望PieGraphView满足你的要求。
控件介绍
效果图如下:
目前实现的饼状图的效果如下所示,和支付宝app记账本中的功能基本一样:
控件功能:
- 展示的数据 可以展示多组数据(ItemGroup),每次展示一组数据,一组数据对应形成一个圆环。一组数据由多个Item组成,对应圆环中的扇形。
public static class ItemGroup { public String id; public Item[] items; } public static class Item { public double value; public int color; public String id; }
圆环
一个ItemGroup最终显示为一个圆环。它的中的items是包含的数据项。这些数据项根据其value占总数据的比例对应不同的扇形角度。ItemGroup的所有Item依次绘制,形成360°。起始角度和旋转
所有角度值是X正轴开始顺时针增加。圆环有一个开始角度使用字段mStartAngle表示,所有扇形的绘制是从mStartAngle开始的,它是0-360度的数值,例如可以设置为90让绘制从正下方开始等。圆环可以旋转,旋转是针对mStartAngle而言的。选中并高亮Item
点击可以选择一个扇形,选中的扇形作为“当前项”,使用字段int mCurrentItem
记录它的索引。选择一个扇形后,它会旋转其中间角度到mStartAngle的角度,然后对应扇形执行“grow”动画进行高亮突出。切换ItemGroup
点击圆环内部可以切换显示不同的ItemGroup。切换会有一个动画,先是顺时针从mStartAngle绘制整个圆环。之后在自动选中最后一个Item。
实现过程
圆环的基本绘制
圆环的绘制实际就是通过先后绘制两个半径不同的圆实现,圆就是360度的扇形,canvas.drawArc提供了这个功能:
public void drawArc(@NonNull RectF oval, float startAngle, float sweepAngle, boolean useCenter, @NonNull Paint paint)
需要先绘制有颜色的外圆对应的各个扇形,之后再“覆盖”绘制内圆对应的各个扇形。
绘制圆环的时候需要考虑开始角度mStartAngle和当前的旋转mRotation。这里设计了一个方法drawPieFromEnd用来在(start, end)的角度范围内绘制“被显示”的那些扇形。这里的角度是扇形数组的形成的0-360的连续角度范围。
为了绘制的简单,方法选择从最后一个扇形开始绘制,相当于从end绘制到start,这样的好处是不用去计算实际上start对应的是哪个扇形了,而根据传递的角度范围,当下一个绘制的扇形的起始角度大于start时,结束绘制:
/** * 从尾部开始绘制圆环,只绘制endAngle到startAngle之间的,不一定绘制所有圆环。 * * @param canvas * @param startAngle * @param endAngle */private void drawPieFromEnd(Canvas canvas, float startAngle, float endAngle) { if (angles == null) return; for (int i = angles.length - 1; i >= 0; i--) { float itemAngle = angles[i] + 0.5f; float sweepStart = endAngle - itemAngle; mPaintOuter.setColor(colors[i]); float radius = mSmallOval.width() / 2f + mRingWidth / 2f; if (sweepStart >= startAngle) { canvas.drawArc(mBigOval, sweepStart, itemAngle, true, mPaintOuter); int middleAngle = (int) (sweepStart + itemAngle / 2); calcAngleMiddleInRing(middleAngle, radius, mItemCenter); drawItemCenterIcon(canvas, middleAngle, colors[i], mItemCenter); } else { itemAngle = endAngle - startAngle; int middleAngle = (int) (startAngle + itemAngle / 2); canvas.drawArc(mBigOval, startAngle, itemAngle, true, mPaintOuter); calcAngleMiddleInRing(middleAngle, radius, mItemCenter); drawItemCenterIcon(canvas, middleAngle , colors[i], mItemCenter); break; } endAngle -= itemAngle; }}
动画
当前控件交互过程中总共有三个动画:
- showOut 每个ItemGroup显示时执行切换动画。
- rotate 旋转动画,被选中的Item会旋转其中心角度到mStartAngle。
- grow 被选中的扇形旋转结束后,或者再次点击当前已选扇形,就对它执行一次grow动画,使得扇形高亮突出。
所有动画通过Animation实现,这里只是使用Animation完成动画时间和进度的控制。
重写applyTransformation方法来记录当前动画的进度progress,然后invalidate通知onDraw的执行。 开始动画执行时将当前动画模式字段int mAnimMode
设置为不同的ANIM_MODE_xxx常量,然后onDraw中会根据当前的mAnimMode值,选择对应动画的绘制方法去执行。 代码结构如下:
public class PieGraphView extends View { private static final int ANIM_MODE_NONE = 0; private static final int ANIM_MODE_ROTATE = 1; ... private void initAnims() { mAnimRotate = new Animation() { @Override protected void applyTransformation(float interpolatedTime, Transformation t) { mRotateAnimProgress = interpolatedTime; // 旋转操作可以通过改变开始绘制的角度,也可以旋转整个View // 设置旋转角度后会使得可点击区域不再是沿着水平/竖直方向的正方形,所以不采用 invalidate(); if (interpolatedTime >= 1.0f) { cancel(); // mAnimMode = ANIM_MODE_NONE; setRotation(mRotation + mRotateDelta); mRotateDelta = 0; post(new Runnable() { @Override public void run() { growItem(mCurrentItem); } }); } } }; ... } ... @Override protected void onDraw(Canvas canvas) { switch (mAnimMode) { case ANIM_MODE_ROTATE: drawRotatedPie(canvas); canvas.drawArc(mSmallOval, 0, 360, true, mPaintInner); break; case ANIM_MODE_SHOW_OUT: ... } private void runAnimRotate() { mAnimMode = ANIM_MODE_ROTATE; clearAnimation(); mAnimRotate.cancel(); startAnimation(mAnimRotate); }}
initAnims()
方法中对动画进行初始化。执行runAnimRotate()
来开启动画。onDraw方法中根据动画模式选择执行不同的绘制方法。
旋转
mStartAngle和mRotation两个字段的值决定了绘制圆环的起始角度。这里旋转的方式不能是执行View.setRotation()方法,因为会旋转整个View的区域——View的坐标跟着旋转!!!使得之后点击事件的处理会比较麻烦。
旋转每次只需要计算“要旋转到的目标角度”和“当前已旋转的角度”的差值int mRotateDelta
,然后执行旋转动画,不断修改mRotation值执行onDraw即可: /** * 让整个圆旋转到targetDegree的角度,旋转是相对mStartAngle开始绘制的圆而言 * * @param targetDegree 应该介于0-360,是从第一个扇形片段作为0度算出来的角度,不是从X正轴开始的角度 * @param smartRotate 是否抄近路旋转? */private void rotateToDegree(float targetDegree, boolean smartRotate) { // 使得 targetDegree 介于0-360 targetDegree = (targetDegree + 360) % 360; int targetRotate = (int) -targetDegree; mRotateDelta = targetRotate - mRotation; mRotateDelta = mRotateDelta % 360; if (smartRotate) { // 将旋转控制在180度内 if (mRotateDelta > 180) { mRotateDelta = mRotateDelta - 360; } else if (mRotateDelta < -180) { mRotateDelta = 360 + mRotateDelta; } } runAnimRotate();}
上面旋转角度控制在(-360, 360),和扇形相关的角度控制在(0, 360)。
突出显示扇形
选择的扇形记录其对应Item的索引int mCurrentItem
,只有在没有任何动画执行时,或者是正在执行grow动画时才会对当前选择的扇形进行突出显示。
private void drawGrownPie(Canvas canvas) { if (angles == null) return; final float rotatedStart = this.mStartAngle + mRotation; float rotatedEnd = rotatedStart + 360f; float currentItemStart = 0f, currentItemSweep = 360f; for (int i = angles.length - 1; i >= 0; i--) { float itemAngle = angles[i] + 0.5f; float sweepStart = rotatedEnd - itemAngle; float sweep = itemAngle; mPaintOuter.setColor(colors[i]); RectF oval = mBigOval; if (sweepStart < rotatedStart) { sweepStart = rotatedStart; sweep = rotatedEnd - rotatedStart; } if (mGrownItem == i) { sweepStart += mGrownPieGap; sweep -= 2 * mGrownPieGap; float padding = mGrownWidth * (1f - mGrowProgress); mGrownOval.set(mCanvasRect); mGrownOval.inset(padding, padding); oval = mGrownOval; currentItemStart = sweepStart; currentItemSweep = sweep; } // 绘制扇形圆环 canvas.drawArc(oval, sweepStart, sweep, true, mPaintOuter); // 绘制圆环上扇形的中心“点” int middleAngle = (int) (sweepStart + sweep / 2); float radius = (mSmallOval.width() + mRingWidth) / 2f; if (mGrownItem == i && mGrowMode == GROW_MODE_MOVE_OUT) { radius += mGrowProgress * mGrownWidth; } calcAngleMiddleInRing(middleAngle, radius, mItemCenter); drawItemCenterIcon(canvas, middleAngle, colors[i], mItemCenter); if (sweepStart < rotatedStart) break; rotatedEnd -= itemAngle; } // 绘制内圆,分当前扇形和非当前扇形两部分 mGrownOval.set(mSmallOval); float grownRadius = mGrownWidth * mGrowProgress; float otherStart = currentItemStart + currentItemSweep; float otherSweep = 360f - currentItemSweep; if (mGrowMode == GROW_MODE_MOVE_OUT) { // 小圆转一圈,消掉可能的缝隙 otherStart = 0f; otherSweep = 360f; mGrownOval.inset(-grownRadius, -grownRadius); } else if (mGrowMode == GROW_MODE_BOLD) { mGrownOval.inset(grownRadius, grownRadius); // 小圆转一圈,消掉可能的缝隙 currentItemStart = 0f; currentItemSweep = 360f; } canvas.drawArc(mGrownOval, currentItemStart, currentItemSweep, true, mPaintInner); canvas.drawArc(mSmallOval, otherStart, otherSweep, true, mPaintInner);}
上面绘制的顺序是:
- 绘制所有扇形的外圆扇形,当前项的半径会不同。
- 绘制对应当前扇形角度的内圆的扇形。
- 绘制除去当前扇形角度的其余角度的内圆的扇形。
grow动画又分为加粗(GROW_MODE_BOLD)和向外移动(GROW_MODE_MOVE_OUT)两个动画,不同动画时内圆扇形的半径不同,上面因为float值得原因扇形可能会有缝隙,为了消除这个缝隙,最终在绘制的时候会让“当前扇形的绘制”或者“剩余圆环部分”的绘制直接是绘制360度,因为最终的扇形的确存在包含关系。
点击事件
重写onTouchEvent方法,根据ACTION_DOWN时的(x, y)来确定点击区域是发生在圆环内部、圆环上、还是圆环外。之后会执行不同的处理。
@Overridepublic boolean onTouchEvent(MotionEvent event) { if (event.getAction() == MotionEvent.ACTION_DOWN && mAnimMode == ANIM_MODE_NONE) { int item = calcClickItem(event.getX(), event.getY()); if (item >= 0 && item < angles.length) { setCurrentItem(item, true); } return true; } return super.onTouchEvent(event);}
只有在动画未执行时处理点击事件。这里只是简单的监听手指按下的动作,如果为了“更自然”的监听,可以在ACTION_UP中根据前后的坐标变动来选择是否判定为对饼状图的有效点击。也可以结合OnClickListener处理“click”事件。总之,关键是获得点击的(x, y)坐标。
方法calcClickItem完成了点击事件的不同处理:如果点击发生在内圆就切换显示的ItemGroup,点击发生在圆环外不处理。点击圆环上某个扇形后,就设置扇形对应的Item为“当前项”,对应扇形会被旋转到mStartAngle的位置,旋转后执行grow动画进行突出显示。
private int calcClickItem(float x, float y) { if (angles == null) return -1; final float outerRadius = mBigOval.width() / 2; final float innerRadius = mSmallOval.width() / 2; float centerX = mBigOval.centerX(); float centerY = mBigOval.centerY(); double clickRadius = Math.sqrt((x - centerX) * (x - centerX) + (y - centerY) * (y - centerY)); if (clickRadius < innerRadius) { // 点击发生在小圆内部,也就是点击到标题区域 onTitleRegionClicked(); return -1; } else if (clickRadius > outerRadius) { // 点击发生在大圆环外 return -2; } // 计算点击的坐标(x, y)和圆中心点形成的角度,角度从0-360,顺时针增加 int clickedDegree = GeomTool.calcAngle(x, y, centerX, centerY); // 计算出来的clickedDegree是整个View原始的,被点击item需要考虑startAngle。 int startAngle = mStartAngle + mRotation; int angleStart = startAngle; for (int i = 0; i < angles.length; i++) { int itemStart = (angleStart + 360) % 360; float end = itemStart + angles[i]; if (end >= 360f) { if (clickedDegree >= itemStart && clickedDegree < 360) return i; if (clickedDegree >= 0 && clickedDegree < (end - 360)) return i; } else { if (clickedDegree >= itemStart && clickedDegree < end) { return i; } } angleStart += angles[i]; } return -3;}
计算点击的角度
根据点击的坐标(x, y)和圆心(centerX, centerY)可以计算出点击的点相对圆心的角度。下面方法calcAngle完成此任务。
代码如下:
/** * 计算坐标(x1, y1)和(x2, y2)形成的角度,角度从0-360,顺时针增加 * (x轴向右,y轴向下) */public static int calcAngle(float x1, float y1, float x2, float y2) { double resultDegree = 0; double vectorX = x1 - x2; // 点到圆心的X轴向量,X轴向右,向量为(0, vectorX) double vectorY = y2 - y1; // 点到圆心的Y轴向量,Y轴向上,向量为(0, vectorY) // 点落在X,Y轴的情况这里就排除 if (vectorX == 0) { // 点击的点在Y轴上,Y不会为0的 if (vectorY > 0) { resultDegree = 90; } else { resultDegree = 270; } } else if (vectorY == 0) { // 点击的点在X轴上,X不会为0的 if (vectorX > 0) { resultDegree = 0; } else { resultDegree = 180; } } else { // 根据形成的正切值算角度 double tanXY = vectorY / vectorX; double arc = Math.atan(tanXY); // degree是正数,相当于正切在四个象限的角度的绝对值 double degree = Math.abs(arc / Math.PI * 180); // 将degree换算为对应x正轴开始的0-360的角度 if (vectorY < 0 && vectorX > 0) { // 右下 0-90 resultDegree = degree; } else if (vectorY < 0 && vectorX < 0) { // 左下 90-180 resultDegree = 180 - degree; } else if (vectorY > 0 && vectorX < 0) { // 左上 180-270 resultDegree = 180 + degree; } else { // 右上 270-360 resultDegree = 360 - degree; } } return (int) resultDegree;}
上面的方法calcClickItem根据此角度,结合当前圆环的mStartAngle、mRotation就可以确定点击落在的扇形区域了。
计算扇形中心
绘制扇形过程中,可以得到扇形的中间角度middleAngle,而中心的半径就是圆环外半径减去一半圆环宽度,使用GeomTool.calcCirclePoint工具方法,可以根据“圆心、半径、角度”计算出扇形中心点的坐标。
代码如下:
/** * 计算指定角度、圆心、半径时,对应圆周上的点。 * @param angle 角度,0-360度,X正轴开始,顺时针增加。 * @param radius 圆的半径 * @param cx 圆心X * @param cy 圆心Y * @param resultOut 计算的结果(x, y) ,方便对象的重用。 * @return resultOut, or new Point if resultOut is null. */public static Point calcCirclePoint(int angle, float radius, float cx, float cy, Point resultOut) { if (resultOut == null) resultOut = new Point(); // 将angle控制在0-360,注意这里的angle是从X正轴顺时针增加。而sin,cos等的计算是X正轴开始逆时针增加 angle = clampAngle(angle); double radians = angle / 180f * Math.PI; double sin = Math.sin(radians); double cos = Math.cos(radians); double dy = radius * sin; double dx = radius * cos; double x = cx + dx; double y = cy + dy; resultOut.set((int) x, (int) y); return resultOut;}
使用
目前没有添加任何attribute,方便单一类文件的阅读。
在布局文件中可以声明PieGraphView对象,然后Activity中可以对它设置数据,设置圆环宽度等。主要有下面几个方法:public void setData(ItemGroup[] groups)
设置要显示的数据。public void setRingWidthFactor(float factor)
设置圆环宽度public void setGrowWidthFactor(float factor)
设置圆环上某个Item可以grow的额外半径。
资料
自定义View入门
http://www.cnblogs.com/everhad/p/5755823.html完整代码:
https://git.oschina.net/idlestar/AndroidSample/blob/master/app/src/main/java/com/idlestar/androiddocs/view/widget/PieGraphView.java