自己动手绘制一个折线图控件ChartView

折线图在很多App中都有用到,GitHub上面有一些功能全面的折线图框架,比如hellecharts-androidachartengine。但是很多时候设计师给定的样式,通过这些框架不一定能完全达到效果。所以只有琢磨着通过自定义View来自己绘制一个折线图:

最终效果图

根据展示效果,坐标轴、刻度、刻度值、数据点线、标题全部都通过自绘来实现。

初始化绘制相关参数

在构造函数中初始化画笔及数据刻度值等参数:

private void init() {
    this.setBackgroundColor(Color.WHITE);
    // x轴刻度值
    if (xLabel == null) {
        xLabel = new String[]{"12-11", "12-12", "12-13", "12-14", "12-15", "12-16", "12-17"};
    }
    // 数据点
    if (data == null) {
        data = new String[]{"2.98", "2.99", "2.99", "2.98", "2.92", "2.94", "2.95"};
    }
    // 标题
    if (title == null) {
        title = "七日年化收益率(%)";
    }

    // 根据设置的数据值生成Y坐标刻度值
    yLabel = createYLabel();
    // 数据点及其连线的颜色集合
    mDataLineColors = new String[]{"#fbbc14", "#fbaa0c", "#fbaa0c", "#fb8505", "#ff6b02", "#ff5400", "#ff5400"};
    // 新建画笔
    mDataLinePaint = new Paint();       // 数据(点和连线)画笔
    mScaleLinePaint = new Paint();      // 坐标(刻度线条)值画笔
    mScaleValuePaint = new Paint();      // 图表(刻度值)画笔
    mBackColorPaint = new Paint();       // 背景(色块)画笔
    // 画笔抗锯齿
    mDataLinePaint.setAntiAlias(true);
    mScaleLinePaint.setAntiAlias(true);
    mScaleValuePaint.setAntiAlias(true);
    mBackColorPaint.setAntiAlias(true);
}

x轴的刻度值、数据点、y中刻度值设置初始值根据初始值先进行绘制。后续重新设置数据后再重绘。
在根据给定数据点生成y的坐标刻度值时,需要考虑两点:
1.数据点及其连线需要绘制在坐标区域的中间位置,并且数据点的临界值(最大或最小值)不能超过y坐标刻度的临界值;
2.y刻度值必须均分,并且根据不同数据值展示合适的y刻度值。
所以createYLabel()方法实现了根据给定的数据点的值来计算出对应y刻度值的算法。

/**
 * 根据数据值data生成合适的Y坐标刻度值
 *
 * @return y轴坐标刻度值数组
 */
private String[] createYLabel() {
    float[] dataFloats = new float[7];
    for (int i = 0; i < data.length; i++) {
        dataFloats[i] = Float.parseFloat(data[i]);
    }
    // 将数据值从小到大排序
    Arrays.sort(dataFloats);
    // 中间值
    float middle = format3Bit((dataFloats[0] + dataFloats[6]) / 2f);
    // y轴刻度,+0.01f为了避免所有数据点都相等时计算出的y刻度为0.
    float scale = format3Bit((dataFloats[6] - dataFloats[0]) / 4 + 0.01f);
    String[] yText = new String[5];
    yText[0] = (middle - 2 * scale) + "";
    yText[1] = (middle - scale) + "";
    yText[2] = middle + "";
    yText[3] = (middle + scale) + "";
    yText[4] = (middle + 2 * scale) + "";
    for (int i = 0; i < yText.length; i++) {
        yText[i] = format3Bit(yText[i]);
    }
    return yText;
}

将数据值排序后计算出中间值middle、y轴刻度值scaleformat3Bit(float number)将计算结果进行格式化,保证刻度值的小数位数一致。

/**
 * 格式化数字 ###.000
 *
 * @return ###.000
 */
private float format3Bit(float number) {
    DecimalFormat decimalFormat = new DecimalFormat("###.000");
    String target = decimalFormat.format(number);
    if (target.startsWith(".")) {
        target = "0" + target;
    }
    return Float.parseFloat(target);
}

/**
 * 格式化数据 ###.000
 *
 * @param numberStr 格式化后的字符串形式
 * @return ###.000
 */
private String format3Bit(String numberStr) {
    if (TextUtils.isEmpty(numberStr)) {
        return "0.000";
    }
    float numberFloat = Float.parseFloat(numberStr);
    DecimalFormat decimalFormat = new DecimalFormat("###.000");
    String target = decimalFormat.format(numberFloat);
    if (target.startsWith(".")) {
        target = "0" + target;
    }
    return target;
}

onMeasure中初始化绘制尺寸及画笔属性等参数:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    initParams();
}

private void initParams() {
    int width = getMeasuredWidth() - getPaddingLeft() - getPaddingRight();
    int height = getMeasuredHeight() - getPaddingTop() - getPaddingBottom();

    yScale = height / 7.5f;         // y轴刻度
    xScale = width / 7.5f;          // x轴刻度
    startPointX = xScale / 2;       // 开始绘图的x坐标
    startPointY = yScale / 2;       // 开始UI图的y坐标
    xLength = 6.5f * xScale;        // x轴长度
    yLength = 5.5f * yScale;        // y轴长度
    xTextPlaceHeight = yScale / 2;       // x轴刻度文字的占位高度
    yTextPlaceWidth = xScale / 2;        // y轴刻度文字的占位宽度
    titleHeight = yScale;

    chartLineStrokeWidth = xScale / 50;     // 图表线条的线宽
    coordTextSize = xScale / 5;             // 坐标刻度文字的大小
    dataLineStrodeWidth = xScale / 15;      // 数据线条的线宽

    // 设置画笔相关属性
    mBackColorPaint.setColor(0x11DEDE68);
    mScaleLinePaint.setStrokeWidth(chartLineStrokeWidth);
    mScaleLinePaint.setColor(0xFFDEDCD8);
    mScaleValuePaint.setColor(0xFF999999);
    mScaleValuePaint.setTextSize(coordTextSize);
    mDataLinePaint.setStrokeWidth(dataLineStrodeWidth);
    mDataLinePaint.setStrokeCap(Paint.Cap.ROUND);
    mDataLinePaint.setTextSize(1.5f * coordTextSize);
}

onMeasure中就不去判断测量模式了,布局中直接使用match_parent或具体的dp值。

绘制

onDraw方法中进行绘制:

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    drawBackColor(canvas);              // 绘制背景色块
    drawYAxisAndXScaleValue(canvas);    // 绘制y轴和x刻度值
    drawXAxisAndYScaleValue(canvas);    // 绘制x轴和y刻度值
    drawDataLines(canvas);              // 绘制数据连线
    drawDataPoints(canvas);             // 绘制数据点
    drawTitle(canvas);                  // 绘制标题
}

绘制方法的具体实现:

/**
 * 绘制背景色块
 * @param canvas
 */
private void drawBackColor(Canvas canvas) {
    for (int i = 0; i < 7; i++) {
        if (i == 2 || i == 4 || i == 6) {
            canvas.drawRect(startPointX + (i - 1) * xScale,
                    startPointY,
                    startPointX + i * xScale,
                    yLength + startPointY,
                    mBackColorPaint);
        }
    }
}

private void drawYAxisAndXScaleValue(Canvas canvas) {
    for (int i = 0; i < 7; i++) {
        canvas.drawLine(startPointX + i * xScale,
                startPointY,
                startPointX + i * xScale,
                startPointY + yLength,
                mScaleLinePaint);
        mScaleValuePaint.getTextBounds(xLabel[i], 0, xLabel[i].length(), bounds);
        if (i == 0) {
            canvas.drawText(xLabel[i],
                    startPointX,
                    startPointY + yLength + bounds.height() + yScale / 15,
                    mScaleValuePaint);
        } else {
            canvas.drawText(xLabel[i],
                    startPointX + i * xScale - bounds.width() / 2,
                    startPointY + yLength + bounds.height() + yScale / 15,
                    mScaleValuePaint);
        }
    }
}

/**
 * 绘制x轴和y刻度值
 * @param canvas
 */
private void drawXAxisAndYScaleValue(Canvas canvas) {
    for (int i = 0; i < 6; i++) {
        if (i < 5) {
            mScaleValuePaint.getTextBounds(yLabel[4 - i], 0, yLabel[4 - i].length(), bounds);
            canvas.drawText(yLabel[4 - i],
                    startPointX + xScale / 15,
                    startPointY + yScale * (i + 0.5f) + bounds.height() / 2,
                    mScaleValuePaint);
            canvas.drawLine(startPointX + bounds.width() + 2 * xScale / 15,
                    startPointY + (i + 0.5f) * yScale,
                    startPointX + xLength,
                    startPointY + (i + 0.5f) * yScale,
                    mScaleLinePaint);
        } else {
            canvas.drawLine(startPointX,
                    startPointY + (i + 0.5f) * yScale,
                    startPointX + xLength,
                    startPointY + (i + 0.5f) * yScale,
                    mScaleLinePaint);
        }
    }
}

/**
 * 绘制数据线条
 * @param canvas
 */
private void drawDataLines(Canvas canvas) {
    getDataRoords();
    for (int i = 0; i < 6; i++) {
        mDataLinePaint.setColor(Color.parseColor(mDataLineColors[i]));
        canvas.drawLine(mDataCoords[i][0], mDataCoords[i][1], mDataCoords[i + 1][0], mDataCoords[i + 1][1], mDataLinePaint);
    }
}

/**
 * 绘制数据点
 * @param canvas
 */
private void drawDataPoints(Canvas canvas) {
    // 点击后,绘制数据点
    if (isClick && clickIndex > -1) {
        mDataLinePaint.setColor(Color.parseColor(mDataLineColors[clickIndex]));
        canvas.drawCircle(mDataCoords[clickIndex][0], mDataCoords[clickIndex][1], xScale / 10, mDataLinePaint);
        mDataLinePaint.setColor(Color.WHITE);
        canvas.drawCircle(mDataCoords[clickIndex][0], mDataCoords[clickIndex][1], xScale / 20, mDataLinePaint);
        mDataLinePaint.setColor(Color.parseColor(mDataLineColors[clickIndex]));
    }
}

/**
 * 绘制标题
 * @param canvas
 */
private void drawTitle(Canvas canvas) {
    // 绘制标题文本和线条
    mDataLinePaint.getTextBounds(title, 0, title.length(), bounds);
    canvas.drawText(title, (getWidth() - bounds.width()) / 2, startPointY + yLength + yScale, mDataLinePaint);
    canvas.drawLine((getWidth() - bounds.width()) / 2 - xScale / 15,
            startPointY + yLength + yScale - bounds.height() / 2 + coordTextSize / 4,
            (getWidth() - bounds.width()) / 2 - xScale / 2,
            startPointY + yLength + yScale - bounds.height() / 2 + coordTextSize / 4,
            mDataLinePaint);
}

/**
 * 获取数据值的坐标点
 *
 * @return 数据点的坐标
 */
private void getDataRoords() {
    float originalPointX = startPointX;
    float originalPointY = startPointY + yLength - yScale;
    for (int i = 0; i < data.length; i++) {
        mDataCoords[i][0] = originalPointX + i * xScale;
        float dataY = Float.parseFloat(data[i]);
        float oriY = Float.parseFloat(yLabel[0]);
        mDataCoords[i][1] = originalPointY - (yScale * (dataY - oriY) / (Float.parseFloat(yLabel[1]) - Float.parseFloat(yLabel[0])));
    }
}

getDataRoords()是为了根据数据点的值及坐标刻度的的比例关系计算出数据点的坐标,数据点(小圆圈)是点击后重绘才显示。
点击数据点后,详细的数据信息采用PopupWindow来展示。点击事件就直接在onTouchEnvent中来处理:

@Override
public boolean onTouchEvent(MotionEvent event) {
    float touchX = event.getX();
    float touchY = event.getY();
    for (int i = 0; i < 7; i++) {
        float dataX = mDataCoords[i][0];
        float dataY = mDataCoords[i][1];
        // 控制触摸/点击的范围,在有效范围内才触发
        if (Math.abs(touchX - dataX) < xScale / 2 && Math.abs(touchY - dataY) < yScale / 2) {
            isClick = true;
            clickIndex = i;
            invalidate();     // 重绘展示数据点小圆圈
            showDetails(i);   // 通过PopupWindow展示详细数据信息
            return true;
        } else {
            hideDetails();
        }
        clickIndex = -1;
        invalidate();
    }
    return super.onTouchEvent(event);
}```
遍历数据点,根据触摸的位置判断是否在数据点的有效范围内,在有效范围内则通过`showDetails(i)`弹出弹窗,展示详细的百分比信息。

private void showDetails(int index) {
if (mPopWin != null) mPopWin.dismiss();
TextView tv = new TextView(getContext());
tv.setTextColor(Color.WHITE);
tv.setBackgroundResource(R.drawable.shape_pop_bg);
GradientDrawable myGrad = (GradientDrawable) tv.getBackground();
myGrad.setColor(Color.parseColor(mDataLineColors[index]));
tv.setPadding(20, 0, 20, 0);
tv.setGravity(Gravity.CENTER);
tv.setText(data[index] + "%");
mPopWin = new PopupWindow(tv, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
mPopWin.setBackgroundDrawable(new ColorDrawable(0));
mPopWin.setFocusable(false);
// 根据坐标点的位置计算弹窗的展示位置
int xoff = (int) (mDataCoords[index][0] - 0.5f * xScale);
int yoff = -(int) (getHeight() - mDataCoords[index][1] + 0.75f * yScale);
mPopWin.showAsDropDown(this, xoff, yoff);
mPopWin.update();
}

private void hideDetails() {
if (mPopWin != null) mPopWin.dismiss();
}

需要注意的是:根据需要展示的数据点的`index`以及数据点的坐标,计算出展示弹窗的位置`xoff `和`xoff `,弹窗的展示位置是从控件的最左下角为原点算的偏移量。
#####配置添加数据的方法,供外部调用

/**

  • 设置x轴刻度值
  • @param xLabel x刻度值
    */
    public void setxLabel(String[] xLabel) {
    this.xLabel = xLabel;
    }

/**

  • 设置数据
  • @param data 数据值
    */
    public void setData(String[] data) {
    this.data = data;
    }

/**

  • 设置标题
  • @param title 标题
    */
    public void setTitle(String title) {
    this.title = title;
    }

/**

  • 重新设置x轴刻度、数据、标题后必须刷新重绘
    */
    public void fresh() {
    init();
    postInvalidate();
    }
当然,也可以添加自定义属性,将数据、坐标刻度值在布局文件中来配置,这里就不添加了。
#####使用

private void setData() {
String title = "7日年化收益率(%)";
String[] xLabel1 = {"12-11", "12-12", "12-13", "12-14", "12-15", "12-16", "12-17"};
String[] xLabel2 = {"2-13", "2-14", "2-15", "2-16", "2-17", "2-18", "2-19"};
String[] data1 = {"2.92", "2.99", "3.20", "2.98", "2.92", "2.94", "2.90"};
String[] data2 = {"2.50", "2.50", "2.50", "2.50", "2.50", "2.50", "2.50"};
mChartView1.setTitle(title);
mChartView1.setxLabel(xLabel1);
mChartView1.setData(data1);
mChartView1.fresh();
mChartView2.setTitle(title);
mChartView2.setxLabel(xLabel2);
mChartView2.setData(data2);
mChartView2.fresh();
}

![最终效果图1](http://upload-images.jianshu.io/upload_images/1801191-c593c7a590797c62.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
![最终效果图2](http://upload-images.jianshu.io/upload_images/1801191-7ae480b871c84795.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)


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

推荐阅读更多精彩内容