自定義Android圖表ChartView

jopen 8年前發布 | 30K 次閱讀 Android開發 移動開發

這里實現的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/ 

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!