自定义图表ChartView

泡在网上的日子 / 文 发表于2015-11-23 12:18 次阅读 chart,自定义

这里实现的ChartView只是一个比较简单的例子,针对公司的需求开发的,以后有时间的话会在github上面进行维护~

先上效果图吧,在模拟器上面显示的不是很好,折线线条锯齿比较明显而且颜色不清晰,真机上面就好了很多。

ChartViewDemo.gif

真机上面是这样的(红米):

blob.png

数据是几组随机数(看起来还是有点丑然而我已经懒得再去截图了…)

OK,不管怎么样,反正就长成这样了,于是我们先来分析一下这个图表应该怎么画。

可能很多人看见这样的一个图表,第一想法就是去github上面找一些现成的library,这样实际上我是不推荐的。为什么说呢,你去使用了别人的开源库,首先就存在一个能否满足你的项目实际需求的问题,其二这种直接拿来用却不思考的行为是非常阻碍自身的学习进步的,凡事都是拿来主义,怎么能提高自己呢?
当然我这么说不是说开源库不好,只是我们在使用之前,尽可能的多想想,在使用优秀的library时也尽量抽出时间去学习它的设计思想和模式,我们不可能学会所有的东西,但是这并不是说可以不学~

回到正题,首先呢看到这个图表,它是由4条横线、1条竖线、纵坐标方向的文本、横坐标方向的文本、矩形(柱状图)、圆环以及折线组成的。
那么绘制的时候,我们可以先从纵坐标文本开始绘制,这里会涉及到一个计算文字高度的问题,这里简单的介绍一下FontMetrics的几个成员变量,分别是top ascent descent bottomleading:

blob.png

实际上Android文字的绘制都是从基线开始的,从baseline到字符最高处的距离为ascent值为负,从baseline到字符最底部的距离称之为descent值为正,leading在这张图上没有表示出来,它其实是一个行间距属性,值为从上一行的descent到该行字符的ascent的距离。可以看出来,top和bottom要比ascent和descent稍微”大”一点点,事实上包括汉语拼音在内,还有蛮多语言是带有音标的,这个空隙就是为了这些音标准备的~

那么我们的textHeight就可以用Math.abs(ascent + descent)来表示。

/**
 * 画纵坐标文本
 *
 * @param canvas
 */
private void drawOrdinate(Canvas canvas) {
    ordinateSize = ordinateList.size();
    if (0 == ordinateSize) {
        return;
    }

    //计算y轴文本间距
    spaceY = (chartViewHeight - xAxisMarginBottom - ordinateSize * ordinateTextHeight) / (ordinateSize - 1);
    //y轴方向文本宽度
    ordinateTextWidth = 0;
    yAxisPaint.setTextAlign(Paint.Align.RIGHT);
    for (int i = 1; i < ordinateSize; i++) {
        //循环遍历数组,获得字符的最大长度,作为绘制字符的起点
        float ordinateTextWidthTemp = yAxisPaint.measureText(ordinateList.get(i));
        if (ordinateTextWidth < ordinateTextWidthTemp) {
            ordinateTextWidth = ordinateTextWidthTemp;
        }
    }

    for (int i = 0; i < ordinateSize; i++){
        canvas.drawText(ordinateList.get(i), ordinateTextWidth, chartViewHeight - i * spaceY - i * ordinateTextHeight - xAxisMarginBottom + 2, yAxisPaint);
    }
}

这里面的xAxisMarginBottom代表的是x轴距离View最下部的距离,ordinateList是由用户传入的纵坐标文本的集合。我们这里是通过用chartView的高度减掉(xAxisMarginBottom + ordinateTextHeight / 2)后得到y轴的高度,再去计算出y轴相邻文本的间距,计算得到集合中文本的最大长度,最后以该长度为起点从右向左绘制文本。

纵坐标的文本绘制完成,该轮到纵坐标线了,实际上非常简单,就是一条从上到下的直线,这里需要注意的是纵坐标的顶点在最顶部文本的中间位置,即应为chartViewHeight - xAxisMarginBottom - ordinateTextHeight / 2;

/**
 * 画纵坐标线
 *
 * @param canvas
 */
private void drawOrdinateLine(Canvas canvas) {
    if (ordinateSize == 0) {
        return;
    }
    canvas.drawLine(ordinateTextWidth + 10,
            chartViewHeight - (ordinateSize - 1) * spaceY - (ordinateSize - 1) * ordinateTextHeight - ordinateTextHeight / 2 - xAxisMarginBottom,
            ordinateTextWidth + 10,
            chartViewHeight - xAxisMarginBottom - ordinateTextHeight / 2, yAxisPaint);
}

我们接着来绘制横坐标线,也就是一条从左到右的直线(口胡,从上到下明明是4条!)

/**
 * 画横坐标线
 *
 * @param canvas
 */
private void drawAbscissaLine(Canvas canvas) {
    if (ordinateSize == 0) {
        return;
    }
    for (int i = 0; i < ordinateSize; i++) {
        canvas.drawLine(ordinateTextWidth + 10,
                chartViewHeight - i * spaceY - i * ordinateTextHeight - ordinateTextHeight / 2 - xAxisMarginBottom,
                chartViewWidth,
                chartViewHeight - i * spaceY - i * ordinateTextHeight - ordinateTextHeight / 2 - xAxisMarginBottom,
                xAxisPaint);
    }
}

制横坐标的文本同绘制纵坐标的文本类似。

 /**
 * 画横坐标文本
 *
 * @param canvas
 */
private void drawAbscissa(Canvas canvas) {
    abscissaSize = abscissaList.size();
    if (abscissaSize == 0) {
        return;
    }
    //横坐标文本间距
    spaceX = (chartViewWidth - ordinateTextWidth - 30) / abscissaSize;
    abscissaTextWidth = 0;
    for (int i = 0; i < abscissaSize; i++) {
        canvas.drawText(abscissaList.get(i), ordinateTextWidth + 30 + i * spaceX, chartViewHeight - 15, xAxisPaint);
        float abscissaTextWidthTemp = xAxisPaint.measureText(abscissaList.get(i));
        if (abscissaTextWidth < abscissaTextWidthTemp) {
            abscissaTextWidth = abscissaTextWidthTemp;
        }
    }
}

这样我们就把x轴和y轴都绘制出来了,怎么样,不难吧?其实非常简单对吧~

好的,继续绘制柱状图。柱状图实际上就是一个矩形,drawRect的4个属性,top属性一定是用户设置给我们的,bottom就是x轴所在的y坐标。只剩下两个了,left和right。这个left和right应该如何计算呢?为了美观,我们应保证整个矩形关于其下的字的中点对称,这里我们设置整个矩形的宽度等于字宽度的一半。于是就有

float left = ordinateTextWidth + 30 + i * spaceX + xAxisTextSize / 2.5f;
float right = ordinateTextWidth + 30 + i * spaceX + abscissaTextWidth - xAxisTextSize / 2.5f;

i表示当前绘制的是第几个柱状图。
整个方法的代码如下:

/**
 * 画柱状图
 *
 * @param canvas
 */
private void drawHistogram(Canvas canvas) {
    if (abscissaSize == 0) {
        return;
    }
    yAxisHeight = chartViewHeight - xAxisMarginBottom - ordinateTextHeight / 2;
    for (int i = 0; i < abscissaSize; i++) {
        float historgramHeight = chartViewHeight - xAxisMarginBottom - (historgramList.get(i) / 3000f) * yAxisHeight;
        float left = ordinateTextWidth + 30 + i * spaceX + xAxisTextSize / 2.5f;
        float top = historgramHeight;
        float right = ordinateTextWidth + 30 + i * spaceX + abscissaTextWidth - xAxisTextSize / 2.5f;
        float bottom = chartViewHeight - xAxisMarginBottom - ordinateTextHeight / 2;
        canvas.drawRect(left, top, right, bottom, histogramPaint);
    }
}

剩下最后一个,折线图!
折线图实际上是通过用户传递给我们圆心坐标,我们将其扩展成一个圆形,并做相邻圆的连线即可。
先来画折线上面的圆:

linePaint.setStrokeWidth(3);
ArrayList<Circle> circleList;
for (int i = 0; i < brokenLineMap.size(); i++) {
    circleList = new ArrayList<>();
    linePaint.setColor(colors[i]);
    for (int j = 0; j < abscissaSize; j++) {

        //获得需要画的圆的纵坐标
        float brokenLineYAxis = chartViewHeight - xAxisMarginBottom - (brokenLineMap.get(i).get(j) / yMaxValue) * yAxisHeight;
        canvas.drawCircle(ordinateTextWidth + 30 + j * spaceX + abscissaTextWidth / 2, brokenLineYAxis, 10, linePaint);

        Circle circle = new Circle(ordinateTextWidth + 30 + j * spaceX + abscissaTextWidth / 2, brokenLineYAxis, 10);
        circleList.add(circle);
        circleListMap.put(i, circleList);
    }
}

Circle是一个内部类:

 private class Circle {

    private float x;
    private float y;
    private float r;

    public Circle(float x, float y, float r) {
        this.x = x;
        this.y = y;
        this.r = r;
    }

    public float getX() {
        return x;
    }

    public float getY() {
        return y;
    }

    public float getR() {
        return r;
    }
}

上面我们在画了圆以后将其放到了一个circleListMap中,这是为了继续做圆之间的连线准备的。

linePaint.setStrokeWidth(1);
for (int i = 0; i < circleListMap.size(); i++) {
    linePaint.setColor(colors[i]);
    for (int j = 1; j < circleListMap.get(i).size(); j++) {
        drawLineBetweenCirCLe(canvas, circleListMap.get(i).get(j - 1).getX(),
                circleListMap.get(i).get(j - 1).getY(),
                circleListMap.get(i).get(j - 1).getR(),
                circleListMap.get(i).get(j).getX(),
                circleListMap.get(i).get(j).getY(),
                circleListMap.get(i).get(j).getR());
    }
}

这样我们的整个图表就绘制完成了,接下来处理下onTouchEvent(),来让柱状图响应响应的事件:

@Override
public boolean onTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            downX = event.getX();
            downY = event.getY();
            inside = isInside(downX, downY);
            if (inside) {
                if (null != listener) {
                    listener.show();
                }
            } else {
                return false;
            }
            break;
        case MotionEvent.ACTION_UP:
            if (inside) {
                if (null != listener) {
                    listener.dismiss();
                }
            }
            break;
    }
    return true;
}

当手指按下时判断该落点是否在柱状图内,若在由用户进行处理,手指抬时做响应的处理。

/**
 * 判断点是否在柱状图内
 *
 * @param downX
 * @param downY
 * @return
 */
private boolean isInside(float downX, float downY) {

    for (int i = 0; i < abscissaSize; i++) {
        histogramXStart = ordinateTextWidth + 30 + i * spaceX + xAxisTextSize / 3;
        histogramYStart = chartViewHeight - xAxisMarginBottom - (historgramList.get(i) / 3000f) * yAxisHeight;
        histogramXEnd = ordinateTextWidth + 30 + i * spaceX + abscissaTextWidth - xAxisTextSize / 3;
        histogramYEnd = chartViewHeight - xAxisMarginBottom - ordinateTextHeight / 2;
        if (downX >= histogramXStart && downX <= histogramXEnd && downY >= histogramYStart && downY <= histogramYEnd) {
            selectPosition = i;
            return true;
        }
    }
    return false;
}

public interface OnInsideTouchListener {
    void show();

    void dismiss();
}

public void setOnTouchListener(OnInsideTouchListener listener) {
    this.listener = listener;
}

这一段代码就只是接口回调和判断,比较简单就不做讲解了~~

最后,How to Use?

<declare-styleable name="chartView">
    <attr name="xAxisMarginBottom" format="dimension"/>
    <attr name="xAxisTextSize" format="dimension"/>
    <attr name="yAxisTextSize" format="dimension"/>
    <attr name="histogramShow" format="boolean"/>
    <attr name="brokenLineShow" format="boolean"/>
    <attr name="historgramColor" format="color"/>
</declare-styleable>

提供以上属性,基本都是见其名知其意的,简单啦…

在activity中加入如下代码:

chartView.setAbscissa(abscissaList);
chartView.setOrdinate(ordinateList);
chartView.setHistorgramList(historgramList);
chartView.setBrokenLineMap(brokenLineMap);
chartView.onSettingFinished();

chartView.setOnTouchListener(new ChartView.OnInsideTouchListener() {

    @Override
    public void show() {
        Toast.makeText(MainActivity.this, "当前按下的是第" + chartView.getSelectPosition() + "个", Toast.LENGTH_SHORT).show();
    }

    @Override
    public void dismiss() {
    }
});

嗯…最后的最后,贴上项目github地址

其实写博客也是为了让自己领会的更深一点,也希望自己也能不断进步~


本文出自:http://z.sye.space/2015/10/20/ChartView/ 

收藏 赞 (8) 踩 (1)
上一篇:Android应用启动优化:一种DelayLoad的实现和原理
0. 应用启动优化概述 在 Android 开发中,应用启动速度是一个非常重要的点,应用启动优化也是一个非常重要的过程.对于应用启动优化,其实核心思想就是在启动过程中少做事情,具体实践的时候无非就是下面几种: 异步加载 延时加载 懒加载 不用一一去解释,做过启动
下一篇:10 条提升 Android 性能的建议
About the Speaker: Boris Farber 每个人都知道一个 App 的成功,更这个 App 的性能体验有着很密切的关系。但是如何让你的 App 拥有极致性能体验呢?在 DroidCon NYC 2015 的这个分享里,Boris Farber 带来了他关于 Android Api 以及如何避免一些常见的坑的