Android 折线图绘制

项目需要一个折线图,又不想引入那个MPAndroidChart和HelloCharts框架,看了看他们的原理和微信推荐的内容,修改整理出了下面的内容。
在此感谢原作者。

我们大致要实现的形式如下:

在看这篇文章之前,首先建议去看我的上一篇文章
Android PathEffect 自定义折线图必备

看完之后,让我们进入正题:

自定义View四步骤走起;

还是我们自定View的那几个步骤:

  • 1、自定义View的属性
  • 2、在View的构造方法中获得我们自定义的属性
  • [ 3、重写onMesure ]
  • 4、重写onDraw
  • 5、重写onTouchEvent(如果你需要这个控件对手是操作进行特殊的处理)

1,在attrs里面进行声明

   
    <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();
        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的属性
  • 初始化画笔
  • 绘制代表最高分和最低分的两根虚线
  • 绘制文字
  • 绘制代表月份的属性
  • 绘制芝麻分折线
  • 绘制代表芝麻分的圆点
  • 绘制选中分数的悬浮文字以及背景
  • 处理点击事件
  • 如果你想画出多条折线图,看我上篇博客的方法即可Android PathEffect 自定义折线图必备

大家在看的时候按照这个逻辑走,很好理解自定义View的流程。

下面附上GitHub地址:

你可能感兴趣的:(自定义View初级,安卓自定义View从入门到精通)