目录
前言
对于很多应用来说都可能需要做一些数据的统计与展示,通常使用的也就是圆盘统计图和直线统计图以及折线统计图,由于android原生系统中不提供这类的控件,通常需要自己手撸一个或者使用第三方框架,有很多第三方框架做的很好很炫酷,功能也很全面,但是如果让你自己手写一个改怎么实现呢?今天我来展示一下我的小思路吧。
设计
统计控件有很多种,我这次只写了圆环信息统计控件,首先的设计一下这个控件的样式和功能,在参考支付宝账单的圆环统计控件后,我觉得这个样式很有特色的有特殊的美感。
效果
设计与实现的效果如下:
编写
首先加载一个圆环统计信息必须按照每一个子项来进行加载,定义一个bean类DataItem,其中包括属性:
/**
* 所占值
*/
private int value;
/**
* 顶部文本
*/
private String topText;
/**
* 底部文本
*/
private String bottomText;
/**
* 颜色
*/
private int color;
现在开始我们的重头戏,那就是自定义控件的工作,首先看了效果图,我们要先画圆环,由于整个大圆环由不同数据子项构成,即把每个子项进行比例运算画出每个子项对应的圆弧最后达到画出整个圆环的效果。不过第一步还是进行大小的测量,即先获得控件所占的高度和宽度,按照圆半径占控件高度比例和圆心半径所占控件高度比例,得出圆环大圆和圆心的半径。代码如下:
/**
* 圆半径所占控件高度比例
*/
private float radiusRatio = 7.0f/24.0f;
/**
* 圆心半径所占控件高度比例
*/
private float centerRadiusRatio = 1.0f/6.0f;
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
radius = (int) (radiusRatio*heightSize);
centerRadius = (int) (heightSize*centerRadiusRatio);
setMeasuredDimension(widthSize,heightSize);
}
确定好控件的大小之后开始重写onDraw方法
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
drawItem(canvas);
drawCenter(canvas);
drawItemInfo(canvas);
}
onDraw方法中有三个方法第一绘制每个子项对应的圆弧,第二绘制环形中心颜色第三绘制每个数据子项对应的数据线和数据信息。首先看画弧:
private void drawItem(Canvas canvas) {
float start = 0;
float need = 0;
canvas.translate(getWidth()/ 2, getHeight()/ 2);
discRect = new RectF(-radius,-radius,radius,radius);
for (DataItem item : items) {
mPaint.setColor(item.getColor());
start += need;
need = item.getValue()*1.0f/total*360.0f;
canvas.drawArc(discRect,start,need,true,mPaint);
}
}
其中start表示每个子项开始绘制的圆弧起始度数,need表示每个子项圆弧所占的度数,然后画圆弧,每个子项对应圆弧所占的度数由value和total的比例决定。
画完圆弧之后即可绘制中心
private void drawCenter(Canvas canvas) {
mPaint.setColor(centerColor);
canvas.drawCircle(0,0,centerRadius,mPaint);
}
代码简单即根据设定的颜色覆盖圆心区域,最后最大的难点到了,即是绘制每个子项对应的数据线和数据信息。先看效果图,每个子项对应的数据线先是一个小圆然后对应一线,如果没有数据重叠是不需要画折线的,当数据点位于顶边上圆弧的时候画折线要和画直线更好看,底部圆弧同理。当数据子项挨的很近的时候这个时候可以进行数据位置的移动让其不重叠在一起,为了简单起见,我们先写一个函数只负责根据起点和终点绘制数据线。把起点和终点确定的方法留给之后一个函数来计算和确定
private void drawDataLine(Point start,Point end,Canvas canvas,DataItem item){
mPaint.setColor(item.getColor());
canvas.drawCircle(start.x,start.y,radius/20,mPaint);
if (start.y != end.y){
Point temp = new Point(0,end.y);
int distance = Math.abs(start.y-end.y);
if (distance>itemHeight){
distance = itemHeight;
}
if (start.x>0){
temp.x = start.x+distance;
}else {
temp.x = start.x-distance;
}
canvas.drawLine(start.x,start.y,temp.x,temp.y,mPaint);
start = temp;
}
drawText(canvas,end,item);
canvas.drawLine(start.x,start.y,end.x,end.y,mPaint);
}
代码如上所述,根据起点和终点画数据线,如果线的起点和终点在一条水平线上即可画一条直线,若不在一条水平线上即画一条折线。当线条画好自然对应的文本也可绘制完成
private void drawText(Canvas canvas,Point point,DataItem item) {
drawText(canvas,point,item.getTopText(),true);
drawText(canvas,point,item.getBottomText(),false);
}
private void drawText(Canvas canvas,Point point,String text,boolean isTop) {
if (text!=null){
float x = point.x +(point.x>0?-getAlignLength(text):0);
float y ;
if (isTop){
y = point.y - (itemHeight-textSize)/2;
}else {
y = point.y + (itemHeight+textSize)/2;
}
canvas.drawText(text,x,y,textPaint);
}
}
private float getAlignLength(String text){
return textPaint.measureText(text);
}
绘制数据线的功能函数已经实现那么现在只需要明确好起点和终点集合,对于起点很好确定,即将圆弧对应的中线延长一点即可获取的数据线的起点,即获取圆弧的中心点的角度。按照半径的8/7获取到起点,代码如下:
private Point getPointByAngle(float angle) {
Point point = new Point();
point.x = (int) (Math.cos(Math.toRadians(angle))*radius*8/7);
point.y = (int) (Math.sin(Math.toRadians(angle))*radius*8/7);
return point;
}
起点确定了,就得获取到终点,所限终点可以为起点平移到左右两侧边缘的点,但是可能会导致多个数据项的终点距离太近导致数据项信息的重叠,所以必须要进行终点位置的调整,先看代码:
private void drawItemInfo(Canvas canvas) {
float angle[] = new float[items.size()];
Point point[] = new Point[items.size()];
Stack<Point> stack = new Stack<>();
Point endPoint = null;
for (int i = 0; i < items.size(); i++) {
angle[i] = items.get(i).getValue()*1.0f/total*360.0f;
point[i] = getPointByAngle(angle[i]/2+(i>0?angle[i-1]:0));
endPoint = getEndPoint(point[i],endPoint);
if (i>0) angle[i] += angle[i-1];
if ((point[i].x<0&&point[i].y>0)||(point[i].x>0&&point[i].y<0)){
if (!stack.isEmpty()&&!isSameQuadrant(stack.peek(),point[i])){
reverseDrawItemInfo(stack,i-1,canvas,null);
endPoint = point[i-1];
}
stack.push(point[i]);
if (i==items.size()-1){
reverseDrawItemInfo(stack,i,canvas,point[0]);
}
continue;
}else if (!stack.isEmpty()){
reverseDrawItemInfo(stack,i-1,canvas,null);
endPoint = getEndPoint(point[i],point[i-1]);
}
drawDataLine(point[i],endPoint,canvas,items.get(i));
}
}
private boolean isSameQuadrant(Point point,Point prePoint) {
return point.x*prePoint.x>=0&&point.y*prePoint.y>=0;
}
这段代码中首先确定了每个数据子项的起点(point[i]),然后根据数据子项的起点来确定终点。首先我们我们看一张图:
圆弧绘制的起点为蓝色小点,我们可以简单的比较当前点和前一个点的终点,如果当前点与前一个点挨的太近即将当前点的终点Y坐标下移使得两点高度距离适中,但是由于绘制的方向是从第四象限开始一直到第一象限结束,所以当绘制到第三象限和第二象限时会导致数据线的重叠,应该反向绘制,这个时候采取一个Stack的数据结构即可实现,让我们先来看一下确定终点的方法,如下所示,如果距离相差不会导致重叠即可直接返回水平线对应的终点,反之距离相差太近或者终点靠近底部或顶部就就行终点位置的调整。
private Point getEndPoint(Point point,Point prePoint) {
if (prePoint != null&&point.x*prePoint.x>=0){
int distance = Math.abs(prePoint.y - point.y);
if (distance < 3*itemHeight/2||(point.y>=0
&&prePoint.y>point.y)||(point.y<0&&prePoint.y<point.y)){
Point endPoint = new Point();
endPoint.x = point.x>0?getWidth()/2-margin:-getWidth()/2+margin;
endPoint.y = point.y>=0?prePoint.y+2*itemHeight:prePoint.y-2*itemHeight;
return endPoint;
}
}
return getEndPoint(point);
}
private Point getEndPoint(Point point){
Point endPoint = new Point();
endPoint.x = point.x>0?getWidth()/2-margin:-getWidth()/2+margin;
if (Math.abs(point.y)> 5*radius/6){
endPoint.y = point.y+(point.y>0?itemHeight:-itemHeight);
}else {
endPoint.y = point.y;
}
return endPoint;
}
对于二三象限即可反向绘制,代码如下:
private void reverseDrawItemInfo(Stack<Point> stack
,int position,Canvas canvas,Point nextPoint){
Point curPoint;
while (!stack.isEmpty()){
curPoint = stack.pop();
nextPoint = getEndPoint(curPoint,nextPoint);
drawDataLine(curPoint,nextPoint,canvas,items.get(position--));
}
}
最后提供数据加载方法,将传入的数据,输入到List数据集合中,再更新界面即可
public void setItems(List<DataItem> items) {
this.items = items;
total = 0;
for (DataItem item : items) {
total += item.getValue();
}
invalidate();
}
使用
在自定义好控件之后,在Activity中配置使用,首先在xml里面添加控件
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipChildren="false"
tools:context="com.disc.MainActivity">
<com.disc.DiscView
android:id="@+id/disc"
android:layout_width="match_parent"
android:layout_height="250dp" />
</android.support.constraint.ConstraintLayout>
然后在Activity中加载模拟数据,示例如下:
DiscView discView = findViewById(R.id.disc);
List<DataItem> items = new ArrayList<>();
items.add(new DataItem(1,"百度","26.56",getColor(R.color.red)));
items.add(new DataItem(1,"腾讯","32.45",getColor(R.color.green)));
items.add(new DataItem(1,"美团","12.36",getColor(R.color.red)));
items.add(new DataItem(23,"Google","56.23",getColor(R.color.black)));
items.add(new DataItem(1,"沃尔玛","45.56",getColor(R.color.red)));
items.add(new DataItem(35,"阿里巴巴","45.56",getColor(R.color.blue)));
items.add(new DataItem(2,"华为","45.56",getColor(R.color.black)));
items.add(new DataItem(3,"斗鱼","45.56",getColor(R.color.blue)));
items.add(new DataItem(4,"虎牙","45.56",getColor(R.color.yellow)));
items.add(new DataItem(24,"京东","35.56",getColor(R.color.green)));
items.add(new DataItem(23,"Windows","37.25",getColor(R.color.yellow)));
items.add(new DataItem(12,"头条","334.25",getColor(R.color.blue)));
items.add(new DataItem(13,"IBM","37.25",getColor(R.color.black)));
items.add(new DataItem(2,"甲骨文","30.25",getColor(R.color.yellow)));
discView.setItems(items);
然后运行即可看到效果图所示效果
源码
如果有想需要查看源码的小伙伴可以点击这里
因为时间问题里面还有一些小问题,并不能像开源框架一样适用各类项目如果有需要的朋友可以自行修改参数加入到自己的项目当中。如果感觉有用,请帮忙star,谢谢。
总结
很多人很喜欢用开源框架,其有利也有弊,为了适用于各类的项目开源框架会进行扩展提供适应于各种情形的设置,如果只是需要一些基本功能或者特定功能的轻量级控件,建议自行定义。在编写之前必须考虑各项功能的设计,在实现上可以让每个方法分工明确把关于逻辑控制操作留在一个方法中做统一处理,更有利于思路的清晰。