本文要介绍的是一个参照手机支付宝app里面记账本功能里的“饼状图”实现的控件。通常app中可能的数据展示控件有柱状图,折线图,饼状图等,如果需要一个包含多种View控件的库,那么 MPAndroidChart 是不错的选择,如果只是需要一个简单的独立的饼状图控件,希望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动画时才会对当前选择的扇形进行突出显示。

绘制的思路是改变要突出的扇形角度对应的扇形的外圆、内圆的区域大小(drawArc中的oval参数),也就是修改drawArc方法需要的椭圆的矩形区域:

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);
}

上面绘制的顺序是:

  1. 绘制所有扇形的外圆扇形,当前项的半径会不同。
  2. 绘制对应当前扇形角度的内圆的扇形。
  3. 绘制除去当前扇形角度的其余角度的内圆的扇形。

grow动画又分为加粗(GROW_MODE_BOLD)和向外移动(GROW_MODE_MOVE_OUT)两个动画,不同动画时内圆扇形的半径不同,上面因为float值得原因扇形可能会有缝隙,为了消除这个缝隙,最终在绘制的时候会让“当前扇形的绘制”或者“剩余圆环部分”的绘制直接是绘制360度,因为最终的扇形的确存在包含关系。

点击事件

重写onTouchEvent方法,根据ACTION_DOWN时的(x, y)来确定点击区域是发生在圆环内部、圆环上、还是圆环外。之后会执行不同的处理。

@Override
public 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的额外半径。

资料

[BOT]自己动手实现android 饼状图,PieGraphView,附源码解析的更多相关文章

  1. 【原】Android热更新开源项目Tinker源码解析系列之三:so热更新

    本系列将从以下三个方面对Tinker进行源码解析: Android热更新开源项目Tinker源码解析系列之一:Dex热更新 Android热更新开源项目Tinker源码解析系列之二:资源文件热更新 A ...

  2. 【原】Android热更新开源项目Tinker源码解析系列之一:Dex热更新

    [原]Android热更新开源项目Tinker源码解析系列之一:Dex热更新 Tinker是微信的第一个开源项目,主要用于安卓应用bug的热修复和功能的迭代. Tinker github地址:http ...

  3. 【原】Android热更新开源项目Tinker源码解析系列之二:资源文件热更新

    上一篇文章介绍了Dex文件的热更新流程,本文将会分析Tinker中对资源文件的热更新流程. 同Dex,资源文件的热更新同样包括三个部分:资源补丁生成,资源补丁合成及资源补丁加载. 本系列将从以下三个方 ...

  4. Android进阶:五、RxJava2源码解析 2

    上一篇文章Android进阶:四.RxJava2 源码解析 1里我们讲到Rxjava2 从创建一个事件到事件被观察的过程原理,这篇文章我们讲Rxjava2中链式调用的原理.本文不讲用法,仍然需要读者熟 ...

  5. cesium 实现风场图效果(附源码下载)

    前言 cesium 官网的api文档介绍地址cesium官网api,里面详细的介绍 cesium 各个类的介绍,还有就是在线例子:cesium 官网在线例子,这个也是学习 cesium 的好素材. 内 ...

  6. 【Android编程】android平台的MITM瑞士军刀_cSploit源码解析及中间人攻击复现

    /文章作者:Kali_MG1937 作者博客ID:ALDYS4 QQ:3496925334 未经允许,禁止转载/ 何为MITM欺骗,顾名思义,中间人攻击的含义即为在局域网中充当数据包交换中间人的角色 ...

  7. Android开发——AsyncTask的使用以及源码解析

    .AsyncTask使用介绍  转载请标明出处:http://blog.csdn.net/seu_calvin/article/details/52172248 AsyncTask封装了Thread和 ...

  8. Android状态机StateMachine使用举例及源码解析

    Android frameworks源码StateMachine使用举例及源码解析 工作中有一同事说到Android状态机StateMachine.作为一名Android资深工程师,我居然没有听说过S ...

  9. Android 玩转IOC,Retfotit源码解析,教你徒手实现自定义的Retrofit框架

    CSDN:码小白 原文地址: http://blog.csdn.net/sk719887916/article/details/51957819 前言 Retrofit用法和介绍的文章实在是多的数不清 ...

随机推荐

  1. UWP中新加的数据绑定方式x:Bind分析总结

    UWP中新加的数据绑定方式x:Bind分析总结 0x00 UWP中的x:Bind 由之前有过WPF开发经验,所以在学习UWP的时候直接省略了XAML.数据绑定等几个看着十分眼熟的主题.学习过程中倒是也 ...

  2. InnoDB体系结构学习笔记

    后台线程 Master Thread 核心的后台线程,主要负责将缓冲池的数据异步刷新到磁盘,保证数据的一致性,包括(脏页的刷新).合并插入缓冲.(UNDO页的回收)等 IO Thread 4个writ ...

  3. C#多线程之线程同步篇1

    在多线程(线程同步)中,我们将学习多线程中操作共享资源的技术,学习到的知识点如下所示: 执行基本的原子操作 使用Mutex构造 使用SemaphoreSlim构造 使用AutoResetEvent构造 ...

  4. Hawk 7. 常见问题

    本页面您可以通过关键字搜索来获取信息. 理性使用爬虫 爬虫是一种灰色的应用,虽然作为Hawk的设计者,但我依然不得不这么说. 各大网站都在收集和整理数据上花费了大量的精力,因此抓取的数据应当仅仅作为科 ...

  5. java web学习总结(五) -------------------servlet开发(一)

    一.Servlet简介 Servlet是sun公司提供的一门用于开发动态web资源的技术. Sun公司在其API中提供了一个servlet接口,用户若想用发一个动态web资源(即开发一个Java程序向 ...

  6. SQLite学习笔记(十)&&加密

    随着移动互联网的发展,手机使用越来越广泛,sqlite作为手机端存储的一种解决方案,使用也非常普遍.但是sqlite本身安全特性却比较弱,比如不支持用户权限,只要能获取到数据库文件就能进行访问:另外也 ...

  7. Win10命令提示符(cmd)怎么复制粘贴

    在Win10系统里右键开始菜单,选择弹出菜单里的命令提示符,如下图所示: 然后复制要粘贴的文字,例如: echo hovertree.com 把上面的文字复制后,点击命令提示符窗口,然后在命令提示符窗 ...

  8. fmt标签把时间戳格式化日期

    jsp页面标签格式化日期 <%@taglib uri="http://java.sun.com/jsp/jstl/fmt" prefix="f" %> ...

  9. Hadoop3 在eclipse中访问hadoop并运行WordCount实例

    前言:       毕业两年了,之前的工作一直没有接触过大数据的东西,对hadoop等比较陌生,所以最近开始学习了.对于我这样第一次学的人,过程还是充满了很多疑惑和不解的,不过我采取的策略是还是先让环 ...

  10. SQL Server 服务器磁盘测试之SQLIO篇(一)

    数据库调优工作中,有一部分是需要排查IO问题的,例如IO的速度或者RAID级别无法响应高并发下的快速请求.最常见的就是查看磁盘每次读写的响应速度,通过性能计数器Avg.Disk sec/Read(Wr ...