很多金融APP都会给我们呈现七天收益曲线图(貌似一开始是支付宝里面的余额宝先发明的),最近做项目需要用到,之前也接触过图表相关的开源库,比如hellocharts、MPChart等比较出名的两个,但是感觉就这么一个图表不需要去集成一个开源库,还是自己去实现一个吧!于是周末在家用了一天时间去实现并完善这个图表,看效果图:
怎么样,自我感觉良好~下面说说主要实现思路:
说起来就这么几步,先看一下数据model:
package com.example.tianbin.myincomechart;
/**
* Created by TianBin on 2017/7/1 12:53.
* Description :
*/
public class Model {
public String date;
public float percent;
}
两个成员变量,一个是X轴日期,一个是Y轴收益百分比,然后就是我们的主角——自定义图表部分。先来分析一下:我们需要绘制的是X轴Y轴坐标系,并且有横向分割线,另外就是具体的坐标点。Y轴首先是分为6等份,有一份分给x轴坐标文本,另外5份是Y轴刻度值均分的结果(根据后台返回数据的最大最小值均分,算出每个刻度值),而在绘制的过程中,需要根据图表的高度以及总刻度值来计算每一份收益百分比所占像素,最后根据每个model的percent值来计算出应该绘制在屏幕的位置。然后用path方法把所有的点连在一起,绘制出最终的曲线,至于下面阴影部分的绘制,只需要让path闭合就可以了,来看这部分关键代码:
//...代码省略
//放大为整数,避免浮点运算
min= (int) (datas.get(0).percent*100);
max= (int) (datas.get(0).percent*100);
if(datas.get(0).percent<0){
negativePos=0;
}
for (int i = 1; i < datas.size(); i++) {
int f= (int) (datas.get(i).percent*100);
if(min>f){
min=f;
}
if(maxmax=f;
}
if(datas.get(i).percent<0){
negativePos=i;
}
}
//转换比例,找出最大和最小进行换算每个梯度所占像素
perPercent=(max-min)/(ySize-1);
// Log.e(TAG, "onDraw: "+perPercent+"@"+min+"#"+max );
perHeight=getHeight()/(ySize+1);
//坐标右对齐========Y轴处理
String str=negativePos==-1?datas.get(0).percent+"%":datas.get(negativePos).percent+"%";
xVertical=getPaddingLeft()+labelYPaint.measureText(str);
xPadding= TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,10,getResources().getDisplayMetrics());
//X轴处理和曲线绘制,居中对齐
eachWidth=(getWidth()-getPaddingRight()-xVertical-xPadding)/xSize;
bottomY=getHeight()-perHeight-labelYHeight/3f;//最低点坐标
topY=perHeight-labelYHeight/3f;//最高点坐标
perY=(bottomY-topY)/((max-min)/100f);//每个刻度所占坐标像素
for (int i = 0; i < datas.size(); i++) {
float x=i*eachWidth+xPadding+xVertical+eachWidth/2f;
float y=bottomY-perY*(datas.get(i).percent-min/100f);
// Log.e(TAG, "onDraw: "+y+"#"+perY+"$"+bottomY+"$"+topY );
points[i][0]=x;
points[i][1]=y;
}
上面主要就是求出最大最小两个边界值,然后计算出后面绘制坐标轴和曲线需要的一些变量及数据集坐标集合,继续看代码:
//...代码省略
for (int i = 0; i < ySize; i++) {
float eachy=(min+i*perPercent)/100f;
String label=df.format(eachy)+"%";
if(i==ySize-1){
label=df.format(max/100f)+"%";
}
float y=perHeight*(ySize-i);
// Log.e(TAG, "onDraw: "+y+"#"+label );
//绘制y坐标轴
canvas.drawText(label,xVertical,y,labelYPaint);
//绘制横向分割线
canvas.drawLine(xVertical+xPadding,y-labelYHeight/3,getWidth()-getPaddingRight(),y-labelYHeight/3,xSeparatePaint);
}
上面是绘制y轴相关的内容,包括坐标label,横向分割线,主要工作量就是计算绘制所需的坐标。这里max/100f是因为初始化数据的时候为了防止浮点运算,故意放大100倍为整数,所以这里要除回来。
for (int i = 0; i < datas.size(); i++) {
String label=datas.get(i).date;
float x=i*eachWidth+xPadding+xVertical+eachWidth/2f;
float y=bottomY-perY*(datas.get(i).percent-min/100f);
// Log.e(TAG, "onDraw: "+y+"#"+perY+"$"+bottomY+"$"+topY );
//绘制x坐标轴
canvas.drawText(label,x,getHeight()-labelXHeight*1.8f,labelXPaint);
}
同理y轴的绘制,上面的代码是x轴的相关内容绘制。
//绘制曲线图
if(playAnim){
Path dst = new Path();
//根据动画值从线段总长度不断截取绘制造成动画效果
mPathMeasure.getSegment(mPathMeasure.getLength() * mAnimatorValue, mPathMeasure.getLength(), dst, true);
canvas.drawPath(dst, linePaint);
if(fillAreaHasAnim){
float currX=(points[datas.size()-1][0]-points[0][0]) * (1-mAnimatorValue)+points[0][0];
if(isFillArea){
dst.lineTo(points[0][0],bottomY);
dst.lineTo(currX,bottomY);
dst.close();
canvas.drawPath(dst,fillPaint);
}
}
}else{
canvas.drawPath(path,linePaint);
}
if(isOver||!playAnim){
if(isFillArea){
Path pa=new Path(path);
pa.lineTo(points[0][0],bottomY);
pa.lineTo(points[datas.size()-1][0],bottomY);
pa.close();
canvas.drawPath(pa,fillPaint);
}
//最后一个点上面绘制文本
Bitmap bg= BitmapFactory.decodeResource(getResources(),R.mipmap.ic_launcher);
float lastX=points[datas.size()-1][0];
float lastY=points[datas.size()-1][1];
canvas.drawBitmap(bg,lastX-bg.getWidth(),lastY-bg.getHeight(),new Paint());
String tip=datas.get(datas.size()-1).percent+"%";
canvas.drawText(tip,lastX-labelValuePaint.measureText(tip)/2f,lastY-labelValueHeight/2f,labelValuePaint);
float pointRadius= TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,3,getResources().getDisplayMetrics());
if(drawPoints){
for (float[] f:points) {
canvas.drawCircle(f[0],f[1],pointRadius,pointPaint);
}
}
}
}
上面就是根据变量来判断绘制的方式和内容,还有动画方式。可以让曲线从左到右动态绘制,还可以控制曲线下面的区域是否填充,以及坐标点的绘制与否。下面的代码是所需绘制路径的初始化:
private void initPath(){
path.moveTo(points[points.length-1][0],points[points.length-1][1]);
for (int i = points.length-1; i >=0; i--) {
path.lineTo(points[i][0],points[i][1]);
}
mPathMeasure=new PathMeasure(path,false);
}
核心代码就是上面这些,已经加入了很详细的注释,下面说一下动画:主要采用的PathMeasure这个类,结合ValueAnimator,注意上面代码构造path路径,加入的顺序是倒序,为什么这样呢?看onDraw方法里面第37行,PathMeasure这个方法是用来取指定片段(不熟悉这个类用法的请自行学习),我们播放动画的思路就是不停的绘制曲线,每次增加一个单位,这样看起来就像在往前冲一样,而我们的动画是这样写的:
private void initListener() {
mUpdateListener = new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mAnimatorValue = (float) animation.getAnimatedValue();
invalidate();
}
};
}
private void initAnimator() {
valueAnimator = ValueAnimator.ofFloat(1, 0).setDuration(defaultDuration);
valueAnimator.addUpdateListener(mUpdateListener);
valueAnimator.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) {
}
@Override
public void onAnimationEnd(Animator animation) {
isOver=true;
invalidate();
}
//代码省略...
}
看到第11行,值的变化是1–>0,当在取线段的时候就是从最后取,因为我们构造path是倒序,所以最先绘制的就是第一个坐标点,这样随着动画插值器逐渐减小,绘制的曲线则越来越长,造成了动态绘制的效果~
最后来看下触摸事件,每个坐标点应该都是可以点击的:
@Override
public boolean onTouch(View v, MotionEvent event) {
if(datas!=null&&datas.size()>0){
if(isInArea(event.getX(),event.getY())){
Toast.makeText(getContext(),selectedValue.percent+"%", Toast.LENGTH_SHORT).show();
}
}
return false;
}
private boolean isInArea(float x,float y){
float radius= TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,10,getResources().getDisplayMetrics());
for (int i = 0; i < points.length; i++) {
if((x<=points[i][0]+radius)&&x>=(points[i][0]-radius)&&(y<=points[i][1]+radius)&&y>=(points[i][1]-radius)){
selectedValue=datas.get(i);
return true;
}
}
return false;
}
思路也很简单,设置touch监听事件就可以了,当手指触摸点在图表坐标10dp圆形范围内均认为触发了坐标点的点击事件。
最后我在图表中加入了一些可配置信息,如:是否采用动画,是否绘制顶点等等,大家可以自己尝试效果,最终调用的时候只需要setDatas就可以显示出来了,非常简单!
今天的讲解就到此结束了,如果有不明白的小伙伴可以在下方留言,看到后会第一时间回复大家~
欢迎star,欢迎fork,欢迎watch~