自定义View——PieChart

本文是我在阅读了网络上其他作者的优秀内容之后做的摘录转载,其中有对内容的补充。
原来地址:http://www.idtkm.com/customview/customview5/

PieChart

(PS: 经过之前4篇博客的基础知识学习,终于可以开始编写PieChart了 (≧▽≦)/啦啦啦)

一、数据需求

来分析下,用户需要提供怎样的数据,首先要有数据的值,然后还需要对应的数据名称,以及颜色。绘制PieChart需要什么呢,由图可以看出,需要百分比值,扇形角度,色块颜色。所以总共属性有:

public class PieData { 
  private String name;
  private float value;
  private float percentage; 
  private int color = 0; 
  private float angle = 0;
}

各属性的set与get请自行增加。

二、构造函数

构造函数中,增加一些xml设置,创建一个attrs.xml

<?xml version="1.0" encoding="utf-8"?>
<resources> 
    <declare-styleable name="PieChart">
       <attr name="name" format="string"/> 
       <attr name="percentDecimal" format="integer"/> 
       <attr name="textSize" format="dimension"/> 
    </declare-styleable>
</resources>

这是只设置了一部分属性,如果你有强迫症希望全部设置的话,可以自行增加。在PieChart中使用TypedArray进行属性的获取。建议使用如下的写法,可以避免在没有设置属性时,也运行getXXX方法

TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.PieChart, defStyleAttr,defStyleRes);
int n = array.getIndexCount();
for (int i=0; i<n; i++){ 
  int attr = array.getIndex(i); 
  switch (attr){ 
      case R.styleable.PieChart_name: 
        name = array.getString(attr);
        break; 
      case R.styleable.PieChart_percentDecimal:
        percentDecimal = array.getInt(attr,percentDecimal); 
        break; 
      case R.styleable.PieChart_textSize: 
        percentTextSize = array.getDimensionPixelSize(attr,percentTextSize); 
        break; 
      }
  }
array.recycle();

三、动画函数

绘制一个完整的圆,旋转的角度为360,动画时间为可set参数,默认5秒,监听animatedValue参数,用于与绘制时进行计算。ValueAnimator类涉及到的参数的意义请查看自定义View——Canvas与ValueAnimator文章。

private void initAnimator(long duration){ 
  if (animator !=null &&animator.isRunning()){ 
    animator.cancel(); animator.start();
  }else { 
    animator=ValueAnimator.ofFloat(0,360).setDuration(duration); 
    animator.setInterpolator(timeInterpolator); 
    animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
       @Override 
       public void onAnimationUpdate(ValueAnimator animation) { 
            animatedValue = (float) animation.getAnimatedValue(); 
            invalidate(); 
        }
     });
   animator.start(); 
  }
}

四、onMeasure

View默认的onMeasure方法中,并没有根据测量模式,对布局宽高进行调整,所以为了适应wrap_content的布局设置,需要对onMeasure方法进行重写。

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 
  int width = measureDimension(widthMeasureSpec); 
  int height = measureDimension(heightMeasureSpec); 
  setMeasuredDimension(width,height);
}

重写的onMeasure方法,调用了自定义的measureDimension方法处理数据,完成后交给系统的setMeasuredDimension方法。接下来看下自定义的measureDimension方法。

private int measureDimension(int measureSpec){ 
  int size = measureWrap(mPaint); 
  int specMode = MeasureSpec.getMode(measureSpec); 
  int specSize = MeasureSpec.getSize(measureSpec); 
  switch (specMode){ 
    case MeasureSpec.UNSPECIFIED: 
      size = measureWrap(mPaint); 
      break; 
    case MeasureSpec.EXACTLY: 
      size = specSize; 
      break; 
    case MeasureSpec.AT_MOST: 
      //合适尺寸不得大于View的尺寸 
      size = Math.min(specSize,measureWrap(mPaint)); 
      break; 
   }
return size;}

measureDimension根据测量的类型,分别计算尺寸的长度,每个类型的含义在第一篇中已经进行了说明,在这里不再赘述。EXACTLY是在xml中定义match_parent以及具体的数值是使用,而AT_MOST则是在wrap_content时使用,measureWrap方法用于计算当前PieChart的最小合适长度,接下来看看这个方法。

 private int measureWrap(Paint paint){ 
  float wrapSize; 
  if (mPieData!=null&&mPieData.size()>1){ 
     NumberFormat numberFormat =NumberFormat.getPercentInstance(); 
     numberFormat.setMinimumFractionDigits(percentDecimal); 
     paint.setTextSize(percentTextSize); 
     float percentWidth = paint.measureText(numberFormat.format(mPieData.get(stringId).getPercentage())+""); 
     paint.setTextSize(centerTextSize); 
     float nameWidth = paint.measureText(name+""); 
     wrapSize = (percentWidth*4+nameWidth*1.0f)*(float) offsetScaleRadius; 
  }else { 
      wrapSize = 0; 
  } 
return (int) wrapSize;}

测量宽高的方式类似于TextView,根据PieChart中的图名与百分比文本的宽度进行计算的。其中stringId是在处理数据的过程中,计算出的拥有最长字符的区域Id。
从代码中可以看出,wrap_content情况下的,PieChart的宽高就等于百分比字符长度的4倍,加上图名的长度。

五、onSizeChanged

在此函数中,获取当前View的宽高以及根据padding值计算出的实际绘制区域的宽高,同时进行PieChart绘制所需的半径以及布局位置设置。

protected void onSizeChanged(int w, int h, int oldw, int oldh) { 
  super.onSizeChanged(w, h, oldw, oldh); 
  mWidth = w-getPaddingLeft()-getPaddingRight();
 //适应padding设置 
 mHeight = h-getPaddingTop()-getPaddingBottom();
//适应padding设置 
mViewWidth = w; mViewHeight = h; 

//标准圆环 
//圆弧 
r = (float) (Math.min(mWidth,mHeight)/2*widthScaleRadius);

// 饼状图半径 
// 饼状图绘制区域 
rectF.left = -r; 
rectF.top = -r; 
rectF.right =r; 
rectF.bottom = r; 

//白色圆弧 
//透明圆弧 
rTra = (float) (r*radiusScaleTransparent); 
rectFTra.left = -rTra; 
rectFTra.top = -rTra; 
rectFTra.right = rTra; 
rectFTra.bottom = rTra; 

//白色圆 
rWhite = (float) (r*radiusScaleInside); 

//浮出圆环 
//圆弧 
// 饼状图半径 
rF = (float) (Math.min(mWidth,mHeight)/2*widthScaleRadius*offsetScaleRadius); 
// 饼状图绘制区域 
rectFF.left = -rF; 
rectFF.top = -rF; 
rectFF.right = rF; 
rectFF.bottom = rF; 
...
}

六、onDraw

onDraw分为绘制扇形,绘制文本,绘制图名三个部分。绘制扇形和文本时需要与Valueanimator的监听值进行计算,完成动画;另外还要在Touch时进行交互,完成浮出动画。在进行具体的绘制之前,需要坐标原点平移至中心位置,并且判断数据是否为空。

1、绘制扇形

  float currentStartAngle = 0;
  // 当前起始角度
  canvas.save();
  canvas.rotate(mStartAngle);
  float drawAngle;
  for (int i=0; i<mPieData.size(); i++){ 
    PieData pie = mPieData.get(i); 
    if (Math.min(pie.getAngle()-1,animatedValue-currentStartAngle)>=0){ 
      drawAngle = Math.min(pie.getAngle()-1,animatedValue-currentStartAngle); 
    }else { 
      drawAngle = 0; 
    } 
    if (i==angleId){ 
      drawArc(canvas,currentStartAngle,drawAngle,pie,rectFF,rectFTraF,reatFWhite,mPaint); 
    }else { 
      drawArc(canvas,currentStartAngle,drawAngle,pie,rectF,rectFTra,rectFIn,mPaint); 
    } 
    currentStartAngle += pie.getAngle();}canvas.restore();

根据当前的初始角度旋转画布。初始化扇形的起始角度,通过累加计算出下一次的起始角度。
drawArc用于绘制扇形,和上一篇最后的环形图片一样,通过一大一小两个扇形进行补集运算,获得可知半径的及宽度的圆环,只不过这里多了一个为了立体效果而增加的半透明圆弧。


绘制扇形时,使用当前的动画值减去起始角度与当前的扇形经过的角度对比取小,作为当前扇形的需要绘制的经过角度。减1是为了生存扇形区域之间的间隔。

angleId用于Touch时显示点击是哪一块扇形,具体判断会在TouchEvent中进行。

2、绘制文本

//扇形百分比文字
currentStartAngle = mStartAngle;
for (int i=0; i<mPieData.size(); i++){ 
  PieData pie = mPieData.get(i); 
  mPaint.setColor(percentTextColor); 
  mPaint.setTextSize(percentTextSize); 
  mPaint.setTextAlign(Paint.Align.CENTER); 
  NumberFormat numberFormat =NumberFormat.getPercentInstance(); 
  numberFormat.setMinimumFractionDigits(percentDecimal); 

  //根据Paint的TextSize计算Y轴的值 
  if (animatedValue>pieAngles[i]-pie.getAngle()/2&&percentFlag) { 
    if (i == angleId) { 
        drawText(canvas,pie,currentStartAngle,numberFormat,true); 
    } else { 
        if (pie.getAngle() > minAngle) { 
            drawText(canvas,pie,currentStartAngle,numberFormat,false); 
         } 
    }
   currentStartAngle += pie.getAngle(); }}
  • 文本是有方向的,无法在画布旋转后绘制,所以初始化当前扇形的起始角度为PieChart的起始角度。* 然后循环绘制文本,当扇形绘制到当前区域的1/2时,开始绘制当前区域的文字。为了防止文本遮挡视线,在绘制前需要判断此扇形经过的角度是否大于最小显示角度。* angleId用于Touch时显示点击是哪一块扇形,具体判断会在TouchEvent中进行。

    private void drawText(Canvas canvas, PieData pie ,float currentStartAngle, NumberFormat numberFormat,boolean flag){ 
        int textPathX = (int) (Math.cos(Math.toRadians(currentStartAngle + (pie.getAngle() / 2))) * (r + rTra) / 2); 
        int textPathY = (int) (Math.sin(Math.toRadians(currentStartAngle + (pie.getAngle() / 2))) * (r + rTra) / 2); 
        mPoint.x = textPathX; 
        mPoint.y = textPathY; 
        String[] strings; 
        if (flag){ 
            strings = new String[]{pie.getName() + "", numberFormat.format(pie.getPercentage()) + ""}; 
        }else { 
            strings = new String[]{numberFormat.format(pie.getPercentage()) + ""}; 
        }
         textCenter(strings, mPaint, canvas, mPoint, Paint.Align.CENTER);
      }
    

drawText函数的主要作用就是根据传入的Pie,获取大小扇形的半径合除以2,角度取一半,计算出扇形中心点,然后使用之前介绍的textCenter多行文本居中函数进行文本绘制。最后累加当前扇形的起始角度,用于下一个扇形使用。

3、绘制图名

//饼图名
mPaint.setColor(centerTextColor);
mPaint.setTextSize(centerTextSize);
mPaint.setTextAlign(Paint.Align.CENTER);

//根据Paint的TextSize计算Y轴的值
mPoint.x=0;
mPoint.y=0;
String[] strings = new String[]{name+""};
textCenter(strings,mPaint,canvas,mPoint, Paint.Align.CENTER);

绘制图名的部分就比较简单了,和之前绘制单个Pie时类似,获取x,y坐标为(0,0),然后使用textCenter多行文本绘制函数进行文本绘制。

七、onTouchEvent

onTouchEvent用于处理当前的点击事件,具体内容在第一篇文章中已经进行了说明,这里使用其中的ACTION_DOWNACTION_UP事件。

public boolean onTouchEvent(MotionEvent event) { 
  if (touchFlag&&mPieData.size()>0){ 
    switch (event.getAction()){ 
      case MotionEvent.ACTION_DOWN: 
        float x = event.getX()-(mWidth/2); 
        float y = event.getY()-(mHeight/2); 
        float touchAngle = 0; 
        if (x<0&&y<0){ 
            touchAngle += 180; 
        }else if (y<0&&x>0){ 
            touchAngle += 360; 
        }else if (y>0&&x<0){ 
            touchAngle += 180; 
        } 
        touchAngle +=Math.toDegrees(Math.atan(y/x)); 
        touchAngle = touchAngle-mStartAngle; 
        if (touchAngle<0){ 
            touchAngle = touchAngle+360; 
        } 
        float touchRadius = (float) Math.sqrt(y*y+x*x); 
        if (rTra< touchRadius && touchRadius< r){ 
          angleId = -Arrays.binarySearch(pieAngles,(touchAngle))-1; 
          invalidate(); 
        } 
        return true; 
    case MotionEvent.ACTION_UP: 
        angleId = -1; 
        invalidate(); 
        return true; 
      } 
    }
 return super.onTouchEvent(event);}

运行之前需要判断PieChart是否开启了点击效果,同时需要判断数据不为空。

在用户点击下的时候,获取当前的坐标,计算出这个点与原点的距离以及角度。通过距离可以判断出是否点击在了扇形区域上,而通过角度可以判断出点击了哪一个区域。将判断出的区域Id传递给angleId值,就像我们之前在onDraw中说的那样,重新绘制,根据angleId浮出指定的扇形区域。
用户手指离开屏幕时,重置angleId为默认值,并使用invalidate()函数,重新绘制onDraw中变化的部分。

八、小结

经过之前4篇的知识准备,终于迎来了本章的PieChart的具体实现。在本文中重温了之前的绘制流程的各个函数,VlaueAnimator函数,以及Canvas、Path的使用方法,并使用这些方法完成了一个自定义饼图的绘制。在之后的文章中还会进行几个图表的实战,比如下面这个曲线图。
cubic

如果在阅读过程中,有任何疑问与问题,欢迎与我联系。

GitHub:https://github.com/Idtk
博客:http://www.idtkm.com
邮箱:Idtkma@gmail.comPieChart源码请点击

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,014评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,796评论 3 386
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,484评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,830评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,946评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,114评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,182评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,927评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,369评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,678评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,832评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,533评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,166评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,885评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,128评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,659评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,738评论 2 351

推荐阅读更多精彩内容