1. 程式人生 > >Android 折線圖繪製

Android 折線圖繪製

專案需要一個折線圖,又不想引入那個MPAndroidChart和HelloCharts框架,看了看他們的原理和微信推薦的內容,修改整理出了下面的內容。
在此感謝原作者。

我們大致要實現的形式如下:

看完之後,讓我們進入正題:

自定義View四步驟走起;

還是我們自定View的那幾個步驟:

  • 1、自定義View的屬性
  • 2、在View的構造方法中獲得我們自定義的屬性
  • [ 3、重寫onMesure ]
  • 4、重寫onDraw
  • 5、重寫onTouchEvent(如果你需要這個控制元件對手是操作進行特殊的處理)

1,在attrs裡面進行宣告

   <!--這裡為什麼抽出去來?因為假如有兩個textSize分別在不同的自定義view下,構建的時候會報錯,抽取出來重複利用-->
<attr name="textSize" format="dimension|reference"/> <attr name="textColor" format="color"/> <declare-styleable name="ChartView"> <attr name="max_score" format="integer"/> <attr name="min_score" format="integer"/> <attr name="broken_line_color"
format="color"/>
<attr name="textColor"/> <attr name="textSize"/> <attr name="dottedlineColor" format="color"/> </declare-styleable>

2、在View的構造方法中獲得我們自定義的屬性


  TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ChartView);
        maxScore = a.getInt
(R.styleable.ChartView_max_score, 800); minScore = a.getInt(R.styleable.ChartView_min_score, 600); brokenLineColor = a.getColor(R.styleable.ChartView_broken_line_color, brokenLineColor); textNormalColor = a.getColor(R.styleable.ChartView_textColor, textNormalColor); textSize = a.getDimensionPixelSize(R.styleable.ChartView_textSize, (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 15, getResources().getDisplayMetrics())); straightLineColor = a.getColor(R.styleable.ChartView_dottedlineColor, straightLineColor); a.recycle();

在View的構造方法中獲得我們自定義的屬性後,我們要對Paint,Path進行初始化:

 //初始化path以及Paint
        brokenPath = new Path();

        brokenPaint = new Paint();
        brokenPaint.setAntiAlias(true);
        brokenPaint.setStyle(Paint.Style.STROKE);
        brokenPaint.setStrokeWidth(dipToPx(brokenLineWith));
        brokenPaint.setStrokeCap(Paint.Cap.ROUND);

        straightPaint = new Paint();
        straightPaint.setAntiAlias(true);
        straightPaint.setStyle(Paint.Style.STROKE);
        straightPaint.setStrokeWidth(brokenLineWith);
        straightPaint.setColor((straightLineColor));
        straightPaint.setStrokeCap(Paint.Cap.ROUND);

        dottedPaint = new Paint();
        dottedPaint.setAntiAlias(true);
        dottedPaint.setStyle(Paint.Style.STROKE);
        dottedPaint.setStrokeWidth(brokenLineWith);
        dottedPaint.setColor((straightLineColor));
        dottedPaint.setStrokeCap(Paint.Cap.ROUND);

        textPaint = new Paint();
        textPaint.setAntiAlias(true);
        textPaint.setTextAlign(Paint.Align.CENTER);
        textPaint.setStyle(Paint.Style.FILL);
        textPaint.setColor((textNormalColor));
        textPaint.setTextSize(textSize);

3、重寫onMesure(此View不需要我們去計算,但我們可以重寫onSizeChanged進行一些寬高確定,資料的獲取等等)

由於onSizeChanged方法在構造方法、onMeasure之後,又在onDraw之前,此時已經完成全域性變數初始化,也得到了控制元件的寬高,所以可以在這個方法中確定一些與寬高有關的數值。

比如這個View的半徑啊、padding值等,方便繪製的時候計算大小和位置:

View的座標軸及獲取方法如圖:

下面是onSizeChanged方法:

  @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        viewWith = w;
        viewHeight = h;
        initData();
    }


    //初始化資料,這裡將資料轉換成point點集合,在ondraw的時候取出來畫好,連線
    private void initData() {
        scorePoints = new ArrayList<Point>();
        float maxScoreYCoordinate = viewHeight * 0.1f;
        float minScoreYCoordinate = viewHeight * 0.6f;

        Log.v(TAG, "initData: " + maxScoreYCoordinate);

        float newWith = viewWith - (viewWith * 0.15f) * 2;//分隔線距離最左邊和最右邊的距離是0.15倍的viewWith
        int coordinateX;

        for (int i = 0; i < score.length; i++) {
            Log.v(TAG, "initData: " + score[i]);
            Point point = new Point();
            coordinateX = (int) (newWith * ((float) (i) / (monthCount - 1)) + (viewWith * 0.15f));//確定point的X座標
            point.x = coordinateX;
            if (score[i] > maxScore) {
                score[i] = maxScore;
            } else if (score[i] < minScore) {
                score[i] = minScore;
            }
            point.y = (int) (((float) (maxScore - score[i]) / (maxScore - minScore)) * (minScoreYCoordinate - maxScoreYCoordinate) + maxScoreYCoordinate);////確定point的Y座標
            scorePoints.add(point);
        }
    }


4、重寫onDraw(一般來說展現出view的形態最複雜的地方)

onDraw方法如下

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        drawDottedLine(canvas, viewWith * 0.15f, viewHeight * 0.1f, viewWith, viewHeight * 0.1f);//上面一條虛線的畫法,不懂看座標系那一張圖
        drawDottedLine(canvas, viewWith * 0.15f, viewHeight * 0.6f, viewWith, viewHeight * 0.6f);//下面一條虛線的畫法
        drawText(canvas);//繪製文字,minScore,maxScore
        drawMonthLine(canvas);//月份的線及座標點
        drawBrokenLine(canvas);//繪製折線,就是畫點,moveto連線
        drawPoint(canvas);//繪製穿過折線的點
    }

下面,讓我們來一步步對其進行分解:

  • 1,畫兩條虛線
    /**
     * @param canvas 畫布
     * @param startX 起始點X座標
     * @param startY 起始點Y座標
     * @param stopX  終點X座標
     * @param stopY  終點Y座標
     */


    private void drawDottedLine(Canvas canvas, float startX, float startY, float stopX,
                                float stopY) {

        dottedPaint.setPathEffect(new DashPathEffect(new float[]{20, 10}, 4));//DashPathEffect如果不理解,看我上一篇文章
        dottedPaint.setStrokeWidth(1);
        // 例項化路徑
        Path mPath = new Path();
        mPath.reset();
        // 定義路徑的起點
        mPath.moveTo(startX, startY);
        mPath.lineTo(stopX, stopY);
        canvas.drawPath(mPath, dottedPaint);

    }
  • 2,繪製文字,minScore,maxScore等等
 /**
     * @param canvas
     * */
    private void drawText(Canvas canvas) {

        textPaint.setTextSize(textSize);//預設字型15
        textPaint.setColor(textNormalColor);

        canvas.drawText(String.valueOf(maxScore), viewWith * 0.1f - dipToPx(10), viewHeight * 0.1f + textSize * 0.25f, textPaint);
        canvas.drawText(String.valueOf(minScore), viewWith * 0.1f - dipToPx(10), viewHeight * 0.6f + textSize * 0.25f, textPaint);

        textPaint.setColor(0xff7c7c7c);

        float newWith = viewWith - (viewWith * 0.15f) * 2;//分隔線距離最左邊和最右邊的距離是0.15倍的viewWith
        float coordinateX;//分隔線X座標
        textPaint.setTextSize(textSize);
        textPaint.setStyle(Paint.Style.FILL);
        textPaint.setColor(textNormalColor);
        textSize = (int) textPaint.getTextSize();
        for (int i = 0; i < monthText.length; i++) {//這裡是繪製月份,從陣列中取出來,一個個的寫
            coordinateX = newWith * ((float) (i) / (monthCount - 1)) + (viewWith * 0.15f);

            if (i == selectMonth - 1)//被選中的月份要單獨畫出來多幾個圈圈
            {

                textPaint.setStyle(Paint.Style.STROKE);
                textPaint.setColor(brokenLineColor);
                RectF r2 = new RectF();
                r2.left = coordinateX - textSize - dipToPx(4);
                r2.top = viewHeight * 0.7f + dipToPx(4) + textSize / 2;
                r2.right = coordinateX + textSize + dipToPx(4);
                r2.bottom = viewHeight * 0.7f + dipToPx(4) + textSize + dipToPx(8);
                canvas.drawRoundRect(r2, 10, 10, textPaint);

            }
            //繪製月份
            canvas.drawText(monthText[i], coordinateX, viewHeight * 0.7f + dipToPx(4) + textSize + dipToPx(5), textPaint);//不是就正常的畫出

            textPaint.setColor(textNormalColor);

        }


    }
  • 3,月份的座標軸線及座標點的繪製
    //繪製月份的直線(包括刻度)
    private void drawMonthLine(Canvas canvas) {

        straightPaint.setStrokeWidth(dipToPx(1));
        canvas.drawLine(0, viewHeight * 0.7f, viewWith, viewHeight * 0.7f, straightPaint);

        float newWith = viewWith - (viewWith * 0.15f) * 2;//分隔線距離最左邊和最右邊的距離是0.15倍的viewWith
        float coordinateX;//分隔線X座標
        for (int i = 0; i < monthCount; i++) {
            coordinateX = newWith * ((float) (i) / (monthCount - 1)) + (viewWith * 0.15f);
            canvas.drawLine(coordinateX, viewHeight * 0.7f, coordinateX, viewHeight * 0.7f + dipToPx(4), straightPaint);
        //viewHeight * 0.7f + dipToPx(4)這個方法就是座標軸上的豎槓槓,你可以修改這裡來修改豎條的長度
        }

    }
  • 4,繪製折線,就是畫點,lineTo連線drawPath畫出來。
//繪製折線
    private void drawBrokenLine(Canvas canvas) {
        brokenPath.reset();
        brokenPaint.setColor(brokenLineColor);
        brokenPaint.setStyle(Paint.Style.STROKE);
        if (score.length == 0) {
            return;
        }
        Log.v(TAG, "drawBrokenLine: " + scorePoints.get(0));
        brokenPath.moveTo(scorePoints.get(0).x, scorePoints.get(0).y);
        for (int i = 0; i < scorePoints.size(); i++) {
            brokenPath.lineTo(scorePoints.get(i).x, scorePoints.get(i).y);
        }
        canvas.drawPath(brokenPath, brokenPaint);

    }
  • 5,繪製折線穿過的點
  protected void drawPoint(Canvas canvas) {

        if (scorePoints == null) {
            return;
        }
        brokenPaint.setStrokeWidth(dipToPx(1));
        for (int i = 0; i < scorePoints.size(); i++) {
            brokenPaint.setColor(brokenLineColor);
            brokenPaint.setStyle(Paint.Style.STROKE);
            canvas.drawCircle(scorePoints.get(i).x, scorePoints.get(i).y, dipToPx(3), brokenPaint);
            brokenPaint.setColor(Color.WHITE);
            brokenPaint.setStyle(Paint.Style.FILL);
            if (i == selectMonth - 1) {//預設選中的才會繪製不同的點,如圖
                brokenPaint.setColor(0xffd0f3f2);
                canvas.drawCircle(scorePoints.get(i).x, scorePoints.get(i).y, dipToPx(8f), brokenPaint);
                brokenPaint.setColor(0xff81dddb);
                canvas.drawCircle(scorePoints.get(i).x, scorePoints.get(i).y, dipToPx(5f), brokenPaint);

                //繪製浮動文字背景框
                drawFloatTextBackground(canvas, scorePoints.get(i).x, scorePoints.get(i).y - dipToPx(8f));

                textPaint.setColor(0xffffffff);
                //繪製浮動文字
                canvas.drawText(String.valueOf(score[i]), scorePoints.get(i).x, scorePoints.get(i).y - dipToPx(5f) - textSize, textPaint);
            }
            brokenPaint.setColor(0xffffffff);
            canvas.drawCircle(scorePoints.get(i).x, scorePoints.get(i).y, dipToPx(1.5f), brokenPaint);
            brokenPaint.setStyle(Paint.Style.STROKE);
            brokenPaint.setColor(brokenLineColor);
            canvas.drawCircle(scorePoints.get(i).x, scorePoints.get(i).y, dipToPx(2.5f), brokenPaint);
        }
    }

        //這個方法是利用path和point畫出圖形,並設定背景顏色
    private void drawFloatTextBackground(Canvas canvas, int x, int y) {
        brokenPath.reset();
        brokenPaint.setColor(brokenLineColor);
        brokenPaint.setStyle(Paint.Style.FILL);

        //P1
        Point point = new Point(x, y);
        brokenPath.moveTo(point.x, point.y);

        //P2
        point.x = point.x + dipToPx(5);
        point.y = point.y - dipToPx(5);
        brokenPath.lineTo(point.x, point.y);

        //P3
        point.x = point.x + dipToPx(12);
        brokenPath.lineTo(point.x, point.y);

        //P4
        point.y = point.y - dipToPx(17);
        brokenPath.lineTo(point.x, point.y);

        //P5
        point.x = point.x - dipToPx(34);
        brokenPath.lineTo(point.x, point.y);

        //P6
        point.y = point.y + dipToPx(17);
        brokenPath.lineTo(point.x, point.y);

        //P7
        point.x = point.x + dipToPx(12);
        brokenPath.lineTo(point.x, point.y);

        //最後一個點連線到第一個點
        brokenPath.lineTo(x, y);

        canvas.drawPath(brokenPath, brokenPaint);

    }

5、重寫onTouchEvent

需求:點選一個點上面就會出現和預設選種一樣的效果,顯示背景圓圈和文字。底部文字也是選中狀態

 //重寫ontouchevent,


    @Override
    public boolean onTouchEvent(MotionEvent event) {
        this.getParent().requestDisallowInterceptTouchEvent(true);
        //一旦底層View收到touch的action後呼叫這個方法那麼父層View就不會再呼叫onInterceptTouchEvent了,也無法截獲以後的action,這個事件被消費了

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                break;
            case MotionEvent.ACTION_MOVE:
                break;
            case MotionEvent.ACTION_UP://觸控(ACTION_DOWN操作),滑動(ACTION_MOVE操作)和擡起(ACTION_UP)
                onActionUpEvent(event);
                this.getParent().requestDisallowInterceptTouchEvent(false);
                break;
            case MotionEvent.ACTION_CANCEL:
                this.getParent().requestDisallowInterceptTouchEvent(false);
                break;
        }
        return true;
    }

    private void onActionUpEvent(MotionEvent event) {


        boolean isValidTouch = validateTouch(event.getX(), event.getY());//判斷是否是指定的觸控區域

        if (isValidTouch) {
            invalidate();
        }

    }


    //是否是有效的觸控範圍
    private boolean validateTouch(float x, float y) {

        //曲線觸控區域
        for (int i = 0; i < scorePoints.size(); i++) {
            // dipToPx(8)乘以2為了適當增大觸控面積
            if (x > (scorePoints.get(i).x - dipToPx(8) * 2) && x < (scorePoints.get(i).x + dipToPx(8) * 2)) {
                if (y > (scorePoints.get(i).y - dipToPx(8) * 2) && y < (scorePoints.get(i).y + dipToPx(8) * 2)) {
                    selectMonth = i + 1;
                    return true;
                }
            }
        }
        //月份觸控區域
        //計算每個月份X座標的中心點
        float monthTouchY = viewHeight * 0.7f - dipToPx(3);//減去dipToPx(3)增大觸控面積

        float newWith = viewWith - (viewWith * 0.15f) * 2;//分隔線距離最左邊和最右邊的距離是0.15倍的viewWith
        float validTouchX[] = new float[monthText.length];
        for (int i = 0; i < monthText.length; i++) {
            validTouchX[i] = newWith * ((float) (i) / (monthCount - 1)) + (viewWith * 0.15f);
        }

        if (y > monthTouchY) {
            for (int i = 0; i < validTouchX.length; i++) {
                Log.v(TAG, "validateTouch: validTouchX:" + validTouchX[i]);
                if (x < validTouchX[i] + dipToPx(8) && x > validTouchX[i] - dipToPx(8)) {
                    Log.v(TAG, "validateTouch: " + (i + 1));
                    selectMonth = i + 1;
                    return true;
                }
            }
        }

        return false;
    }

整體已經完成了,總結一下大致步驟:

  • 初始化View的屬性
  • 初始化畫筆
  • 繪製代表最高分和最低分的兩根虛線
  • 繪製文字
  • 繪製代表月份的屬性
  • 繪製芝麻分折線
  • 繪製代表芝麻分的圓點
  • 繪製選中分數的懸浮文字以及背景
  • 處理點選事件

大家在看的時候按照這個邏輯走,很好理解自定義View的流程。