自定义view---利用贝塞尔曲线绘制的曲线图

直接来看看效果图吧

需求分析

从效果图上,我们可以得出以下结论

  1. 与传统的曲线图不同,x,y轴比较另类,x轴是下方的“地区一”,“地区二”,y轴可以把上方的具体数字看做y轴,即“82.66“,”88.86“这些数字
  2. 曲线覆盖的下方,有渐变色,颜色从曲线画笔颜色到完全透明
  3. 有点击效果以及滑动效果
  4. 选中某个点后,弹出浮窗,浮窗有阴影

曲线的绘制方法有两种:

  1. 利用贝塞尔曲线绘制
  2. 使用CornerPathEffect绘制
    CornerPathEffect可以将各个连接线之间的夹角用更平滑的方式连接,具体用法本篇文章不做叙述,毕竟我选择用贝塞尔曲线,理由很简单,自己计算控制点,可以做出更多的效果,从效果图可以看出,该曲线图用二阶贝塞尔即可完成

那么,先根据效果图来制定下自定义属性

自定义属性

属性名 作用
titleText String title的文字
titleBgColor color title栏的背景颜色
titleTextColor color title文字的颜色
titleTextSize sp title文字大小
chartBgColor color 图表的背景颜色
xCoordinateBgColor color x轴背景颜色
lineColor color y轴线的颜色
textDefaultColor color x轴与y轴为选中时的默认颜色
textSelectedColor color x轴与y轴被选中时的颜色
xCoordinateTextSize sp x轴的文字大小
yCoordinateTextSize sp y轴的文字大小
curveColor color 曲线颜色
chartPointDefaultStyle color 曲线表中点的默认样式
chartPointSelectedStyle color 曲线表中点被选中的样式
xCoordinateSpacing dp x轴坐标间的距离
chartMinimumColor color 图表渐变色中最浅的颜色
floatBoxBgColor color 浮窗的背景颜色

制定好自定义属性后,接下来就需要正式开始撸代码实现效果了

具体实现

onMeasure

一般来说,图表view的宽度始终都是充满整个屏幕的,根据效果图,高度并没有充满屏幕,那么在onMeasure中,我们只需要关心view的高度在wrap模式下,该如何展示,我用了一个比较简单粗暴的方法:

如果高度是wrap模式,那么设定高度为350dp

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);

    int heightMode = MeasureSpec.getMode(heightMeasureSpec);
    int heightSize = MeasureSpec.getSize(heightMeasureSpec);

    //宽度始终为match,只考虑高为自适应的情况
    if (heightMode == MeasureSpec.AT_MOST) {
        heightSize = dip2px(350);
    }
    height = heightSize;
    setMeasuredDimension(widthSize, height);

    x = firstSpacing;

    //减去0.1f是因为最后一个X周刻度距离右边的长度为X轴可见长度的10%
    minX = getWidth() - (getWidth() * 0.1f) - xCoordinateSpacing * (xCoordinateData.size() - 1);
    maxX = x;
}

onDraw

在开始写onDraw方法前,我们先制定一些小目标:

  1. 绘制title
  2. 绘制x轴
  3. 绘制y轴
  4. 绘制图表
  5. 绘制数据点
  6. 绘制曲线及渐变色
  7. 绘制浮窗

绘制title

首先,我们要计算title的高度,根据效果图的标注,我们将title部分的高度设置为view高度的1/8

    //绘制title
private void drawTitle(Canvas canvas) {
    bgColorPaint.setColor(titleBgColor);
    int titleBottom = height / 8;
    canvas.drawRect(0, 0, getWidth(), titleBottom, bgColorPaint);

    Rect mTextBound = new Rect();
    titlePaint.getTextBounds(titleText, 0, titleText.length(), mTextBound);
    Paint.FontMetrics fontMetrics = titlePaint.getFontMetrics();
    float textX = getWidth() / 2 - mTextBound.width() / 2;
    float textY = (titleBottom - fontMetrics.bottom + fontMetrics.top) / 2 - fontMetrics.top;
    canvas.drawText(titleText, textX, textY, titlePaint);

}

绘制x轴

同样的,x轴部分的高度也为view的1/8,x轴第一个值与原点之间的距离我们设置为x轴坐标间距离的1/3,也就是xCoordinateSpacing / 3;

再根据我们之前的需求分析,该view是有点击效果的,那么我们声明一个boolean类型的变量来判断该点是否被选中

    //绘制x轴
private void drawXCoordinate(Canvas canvas) {
    int xCoordinateHeight = height - (height / 8);
    bgColorPaint.setColor(xCoordinateBgColor);
    coordinateTextPaint.setTextSize(xCoordinateTextSize);
    //先绘制背景颜色
    canvas.drawRect(0, xCoordinateHeight, getWidth(), height, bgColorPaint);


    for (int i = 0; i < xCoordinateData.size(); i++) {
        float textX = (xCoordinateSpacing / 3) + xCoordinateSpacing * i;
        float textY = (height - (height / 8)) + ((height - xCoordinateHeight) / 2);

        if (i == selectedIndex) {
            coordinateTextPaint.setColor(textSelectedColor);
        } else {
            coordinateTextPaint.setColor(textDefaultColor);
        }
        canvas.drawText(xCoordinateData.get(i), textX, textY, coordinateTextPaint);
    }

}

绘制y轴

y轴绘制步骤与x轴一致

    //绘制y轴
private void drawYCoordinate(Canvas canvas) {
    coordinateTextPaint.setTextSize(yCoordinateTextSize);

    for (int i = 0; i < yCoordinateData.size(); i++) {
        String text = yCoordinateData.get(i) + "";
        float textX = (xCoordinateSpacing / 3) + xCoordinateSpacing * i;
        float textY = (height / 9) + (height / 8);

        if (i == selectedIndex) {
            coordinateTextPaint.setColor(textSelectedColor);
        } else {
            coordinateTextPaint.setColor(textDefaultColor);
        }
        canvas.drawText(text, textX, textY, coordinateTextPaint);
    }
}

绘制图表

//绘制图表
private void drawChart(Canvas canvas) {
    bgColorPaint.setColor(chartBgColor);
    int chartTop = height / 8;

    pointPaint.setColor(Color.BLUE);

    bgColorPaint.setColor(chartBgColor);

    //先绘制图表背景颜色
    canvas.drawRect(0, chartTop, getWidth(), height - chartTop, bgColorPaint);

    bgColorPaint.setColor(lineColor);
    for (int i = 0; i < yCoordinateData.size(); i++) {
        //绘制坐标线
        float lineX = (xCoordinateSpacing / 3) + (xCoordinateSpacing * i);
        canvas.drawLine(lineX, height / 8, lineX, height - chartTop, bgColorPaint);
    }
}

绘制数据点

在绘制数据点前,我们要知道原点在哪儿,根据效果图,原点的x坐标在屏幕的最左侧,也就是0,y坐标在x坐标部分的顶部,也就是height - (height / 8);而数据点的x坐标,就是x轴的坐标加上x轴文字宽度的一半

private void drawPoint(Canvas canvas) {
        yOri = height - (height / 8);
    for(int i = 0; i < yCoordinateData.size(); i++) {

        int x = (int) (this.x + this.xCoordinateSpacing * i + (getTextWidth(i) / 2));
        int y = (int) (yOri - (((yOri * (1 - 0.5f)) * yCoordinateData.get(i)) / Collections.max(yCoordinateData)));
        canvas.drawCircle(x, y, 25, pointPaint);

        }
    }

绘制曲线

前面提到,我们用贝塞尔曲线来完成曲线图中曲线的绘制,那么,我们先计算贝塞尔曲线中的控制点


先根据相邻点(P1,P2, P3)计算出相邻点的中点(P4, P5),然后再计算相邻中点的中点(P6)。然后将(P4,P6, P5)组成的线段平移到经过P2的直线(P8,P2,P7)上。接着根据(P4,P6,P5,P2)的坐标计算出(P7,P8)的坐标。最后根据P7,P8等控制点画出三阶贝塞尔曲线。

    private void initPoints() {
    yOri = height - (height / 8);
    mPoints = new ArrayList<>();
    for (int i = 0; i < yCoordinateData.size(); i++) {
        int x = (int) (this.x + this.xCoordinateSpacing * i + (getTextWidth(i) / 2));
        int y = (int) (yOri - (((yOri * (1 - 0.5f)) * yCoordinateData.get(i)) / Collections.max(yCoordinateData)));
        mPoints.add(new Point(x, y));
    }
    //该点用于绘制从最后一个点到屏幕最右侧到二阶贝塞尔曲线
    mPoints.add(new Point(getWidth(), (int) (height - (height / 2.5))));
}

private void initMidPoints() {
    mMidPoints = new ArrayList<>();
    for (int i = 0; i < mPoints.size(); i++) {
        Point midPoint = null;
        if (i == mPoints.size() - 1) {
            return;
        } else {
            midPoint = new Point((mPoints.get(i).x + mPoints.get(i + 1).x) / 2,
                    (mPoints.get(i).y + mPoints.get(i + 1).y) / 2);
        }
        mMidPoints.add(midPoint);
    }
}

private void initMidMidPoints() {
    mMidMidPoints = new ArrayList<>();
    for (int i = 0; i < mMidPoints.size(); i++) {
        Point midMidPoint = null;
        if (i == mMidPoints.size() - 1) {
            return;
        } else {
            midMidPoint = new Point((mMidPoints.get(i).x + mMidPoints.get(i + 1).x) / 2,
                    (mMidPoints.get(i).y + mMidPoints.get(i + 1).y) / 2);
        }
        mMidMidPoints.add(midMidPoint);
    }
}

private void initControlPoints() {
    mControlPoints = new ArrayList<>();
    for (int i = 0; i < mPoints.size(); i++) {
        if (i == 0 || i == mPoints.size() - 1) {
            continue;
        } else {
            Point before = new Point();
            Point after = new Point();
            before.x = mPoints.get(i).x - mMidMidPoints.get(i - 1).x + mMidPoints.get(i - 1).x;
            before.y = mPoints.get(i).y - mMidMidPoints.get(i - 1).y + mMidPoints.get(i - 1).y;
            after.x = mPoints.get(i).x - mMidMidPoints.get(i - 1).x + mMidPoints.get(i).x;
            after.y = mPoints.get(i).y - mMidMidPoints.get(i - 1).y + mMidPoints.get(i).y;
            mControlPoints.add(before);
            mControlPoints.add(after);
        }
    }
}

得出mControlPoints就是我们最终得出来的控制点,接下来的事情就很简单了
,我们再观察观察效果图,从屏幕最左边到第一个数据点和最后一个数据点到屏幕最右边,都有一个曲线连接,那么就随便写两个控制点用于绘制这两根曲线

    //绘制曲线
private void drawCurve(Canvas canvas) {
    // 重置路径
    Path curvePath = new Path();
    curvePath.reset();

    Path gradientPath = new Path();
    gradientPath.reset();

    //从屏幕左边绘制到第一个点
    curvePath.moveTo(0, (float) (height - (height / 2.5)));// 起点
    curvePath.quadTo(0, (float) (height - (height / 2.5)),// 控制点
            mPoints.get(0).x, mPoints.get(0).y);

    gradientPath.moveTo(0, height - (height / 8));
    gradientPath.lineTo(0, (float) (height - (height / 2.5)));
    gradientPath.quadTo(0, (float) (height - (height / 2.5)),// 控制点
            mPoints.get(0).x, mPoints.get(0).y);

    for (int i = 0; i < mPoints.size(); i++) {
        if (i == 0) {// 第一条为二阶贝塞尔
            curvePath.moveTo(mPoints.get(i).x, mPoints.get(i).y);// 起点
            curvePath.quadTo(mControlPoints.get(i).x, mControlPoints.get(i).y,// 控制点
                    mPoints.get(i + 1).x, mPoints.get(i + 1).y);


            gradientPath.quadTo(mControlPoints.get(i).x, mControlPoints.get(i).y,// 控制点
                    mPoints.get(i + 1).x, mPoints.get(i + 1).y);

        } else if (i < mPoints.size() - 1) {// 三阶贝塞尔
            //二阶
            curvePath.quadTo(mControlPoints.get(2 * i - 1).x, mControlPoints.get(2 * i - 1).y,
                    mPoints.get(i + 1).x, mPoints.get(i + 1).y);


            gradientPath.quadTo(mControlPoints.get(2 * i - 1).x, mControlPoints.get(2 * i - 1).y,
                    mPoints.get(i + 1).x, mPoints.get(i + 1).y);
        } else if (i == mPoints.size() - 2) {// 最后一条为二阶贝塞尔
            curvePath.moveTo(mPoints.get(i).x, mPoints.get(i).y);// 起点
            curvePath.quadTo(mControlPoints.get(mControlPoints.size() - 1).x,
                    mControlPoints.get(mControlPoints.size() - 1).y,
                    mPoints.get(i + 1).x, mPoints.get(i + 1).y);// 终点

            gradientPath.quadTo(mControlPoints.get(mControlPoints.size() - 1).x,
                    mControlPoints.get(mControlPoints.size() - 1).y,
                    mPoints.get(i + 1).x, mPoints.get(i + 1).y);//终点
        }
    }

    //绘制渐变色
    gradientPath.lineTo(getWidth(), height - (height / 8));
    gradientPath.lineTo(0, height - (height / 8));
    float left = 0;
    float top = getPaddingTop();
    float bottom = height - (height / 8);
    //渐变色中最深的颜色用绘制曲线的颜色
    LinearGradient lg = new LinearGradient(left, top, left, bottom, curveColor,
            chartMinimumColor, Shader.TileMode.CLAMP);// CLAMP重复最后一个颜色至最后
    gradientColorPaint.setShader(lg);
    gradientColorPaint.setXfermode(new PorterDuffXfermode(
            android.graphics.PorterDuff.Mode.SRC_ATOP));
    canvas.drawPath(gradientPath, gradientColorPaint);

    canvas.drawPath(curvePath, curvePaint);
}

绘制浮窗

要绘制出效果图那样的阴影很有难度,我暂且没有百分百的还原。我利用BlurMaskFilter来实现了类似阴影的效果,只需要在初始化画笔的时候,设置一下即可,当然,需要关闭硬件加速:

setLayerType(LAYER_TYPE_SOFTWARE, floatBoxPaint);
floatBoxPaint.setMaskFilter(new BlurMaskFilter(30, BlurMaskFilter.Blur.SOLID));

设置好画笔后,接下来就绘制浮窗:

    private void drawFloatBox(Canvas canvas) {
    RectF rectF = new RectF();

    for (int i = 0; i < xCoordinateData.size(); i++) {
        if (i == selectedIndex) {
            rectF.left = mPoints.get(i).x - dip2px(47);
            rectF.right = mPoints.get(i).x + dip2px(47);
            rectF.top = mPoints.get(i).y - dip2px(70);
            rectF.bottom = mPoints.get(i).y + dip2px(90);

            canvas.drawRoundRect(rectF, 5, 5, floatBoxPaint);
            coordinateTextPaint.setTextSize(60);
            coordinateTextPaint.setColor(textSelectedColor);
            canvas.drawText(yCoordinateData.get(i) + "", this.x + xCoordinateSpacing * i,
                    mPoints.get(i).y - dip2px(35), coordinateTextPaint);
        }
    }
}

onTouchEvent

点击事件以及滑动事件,其实比较简单,先来看看滑动事件:
在处理滑动事件前,我们要明确几个东西:

  1. 数据点的最小x坐标以及最大x坐标,用于记录滑动距离

    滑动事件

    case MotionEvent.ACTION_MOVE:

    //如果屏幕不足以展示所有数据时
    if (xCoordinateSpacing * xCoordinateData.size() > getWidth()) {
        float dis = event.getX() - startX;
        startX = event.getX();
        if (x + dis < minX) {
            x = minX;
        } else if (x + dis > maxX) {
            x = maxX;
        } else {
            x = x + dis;
        }
        invalidate();
    }
    break;
    

点击事件

/**
 * 点击X轴坐标或者折线节点
 *
 * @param event
 */
private void clickAction(MotionEvent event) {
    int dp10 = dip2px(10);
    float eventX = event.getX();
    float eventY = event.getY();
    for (int i = 0; i < xCoordinateData.size(); i++) {
        //节点
        float x = mPoints.get(i).x;
        float y = mPoints.get(i).y;
        if (eventX >= x - dp10 && eventX <= x + dp10 &&
                eventY >= y - dp10 && eventY <= y + dp10 && selectedIndex != i) {//每个节点周围10dp都是可点击区域
            selectedIndex = i;
            invalidate();
            return;
        }
        //X轴刻度
        x = mPoints.get(i).x;
        y = (height - (height / 8)) + ((height - (height - (height / 8))) / 2);
        if (eventX >= x - getTextWidth(i) / 2 - dp10 && eventX <= x + getTextWidth(i) + dp10 / 2 &&
                eventY >= y - dp10 && eventY <= y + dp10 && selectedIndex != i) {
            selectedIndex = i;
            invalidate();
            return;
        }
    }
}

完整的onTouchEvent代码

    @Override
public boolean onTouchEvent(MotionEvent event) {
    this.getParent().requestDisallowInterceptTouchEvent(true);
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            startX = event.getX();
            break;
        case MotionEvent.ACTION_MOVE:
            //如果屏幕不足以展示所有数据时
            if (xCoordinateSpacing * xCoordinateData.size() > getWidth()) {
                float dis = event.getX() - startX;
                startX = event.getX();
                if (x + dis < minX) {
                    x = minX;
                } else if (x + dis > maxX) {
                    x = maxX;
                } else {
                    x = x + dis;
                }
                invalidate();
            }
            break;
        case MotionEvent.ACTION_UP:
            clickAction(event);
            this.getParent().requestDisallowInterceptTouchEvent(false);

            break;
        case MotionEvent.ACTION_CANCEL:
            this.getParent().requestDisallowInterceptTouchEvent(false);
            break;
    }
    return true;
}

到这里,整个曲线图已经完成了,来看看最终效果

结语

其实最终效果与效果图还有些差距,包括浮窗的效果也不完美,不过倒是有个思路,将点击事件暴露出来,提供x,y坐标,点击后,弹出pop,可以达到一样的效果,有兴趣的话,各位可以试试看。
文章中若有错误,还希望各位提出来

源码

坚持原创技术分享,您的支持将鼓励我继续创作!