需求:实现十二月份的得分平滑曲线,如下所示:

当看到图表时,首先想到的是第三方的开源控件如:MPAndroidChart、HelloChart,但是测试后,发现了一些无法满足需求的事

  • 坐标只显示当前月份,都是全部显示
  • 月份不可点击切换
  • 利用控件本身指定的平滑曲线与实际坐标点出入较大

既然第三方控件不能满足,那么就自定义个折线 View 实现,主要考虑的有:

  • 画 X 轴月份坐标
  • 画得分的坐标和当前得分浮窗
  • 画平滑曲线
  • 给定点击事件,切换当前得分浮窗

步骤

  1. onSizeChanged()确定画布大小后,根据传入的得分列表,计算对应的坐标。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh)
    {
    super.onSizeChanged(w, h, oldw, oldh);
    viewWith = w;//宽
    viewHeight = h;//高
    initData();
    }
    private void initData()
    {
    scorePoints = new ArrayList<>();//坐标 List
    points_x = new LinkedList<Integer>(); //用于计算曲线
    points_y = new LinkedList<Integer>();
    float maxScoreYCoordinate = viewHeight * 0.15f;//设置最大高度
    float minScoreYCoordinate = viewHeight * 0.80f;//设置最大宽度
    float newWith = viewWith - (viewWith * 0.1f) * 2;//分隔线距离最左边和最右边的距离是0.1倍的viewWith
    int coordinateX;
    if (score == null) {
    return;
    }
    selectMonth = score.length;//默认选中最后一个月
    for(int i = 0; i < score.length; i++)
    {
    Log.d("ScoreChartView", "initData: " + score[i]);
    Point point = new Point();
    //计算x轴坐标
    coordinateX = (int) (newWith * ((float) (i) / (monthCount - 1)) + (viewWith * 0.1f));
    point.x = coordinateX;
    if(score[i] > maxScore)
    {
    score[i] = maxScore;
    }
    else if(score[i] < minScore)
    {
    score[i] = minScore;
    }
    //计算y轴坐标
    point.y = (int) (((float) (maxScore - score[i]) / (maxScore - minScore)) * (minScoreYCoordinate - maxScoreYCoordinate) + maxScoreYCoordinate);
    scorePoints.add(point);
    }
    }
  2. 根据坐标画出 X 轴月份

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    //绘制文本
    private void drawText(Canvas canvas)
    {
    textPaint.setTextSize(dipToPx(12));
    textPaint.setColor(textNormalColor);
    textPaint.setColor(0xff7c7c7c);
    float newWith = viewWith - (viewWith * 0.1f) * 2;//分隔线距离最左边和最右边的距离是0.15倍的viewWith
    float coordinateX;//分隔线X坐标
    textPaint.setTextSize(dipToPx(12));
    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.1f);
    if(i == selectMonth - 1)
    {
    //选中月份
    textPaint.setStyle(Paint.Style.STROKE);
    textPaint.setColor(circleColor);
    }
    //绘制月份
    canvas.drawText(monthText[i], coordinateX, viewHeight * 0.85f + dipToPx(4) + textSize + dipToPx(5), textPaint);
    textPaint.setColor(textNormalColor);
    }
    }
  1. 画得分的坐标和当前得分浮窗

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    protected void drawPoint(Canvas canvas)
    {
    if(scorePoints == null)
    {
    return;
    }
    brokenPaint.setStrokeWidth(dipToPx(1));
    for(int i = 0; i < scorePoints.size(); i++)
    {
    //选中月份绘制圆点
    if(i == selectMonth - 1)
    {
    brokenPaint.setStyle(Paint.Style.FILL);
    brokenPaint.setColor(0xffffffff);
    canvas.drawCircle(scorePoints.get(i).x, scorePoints.get(i).y, dipToPx(8f), brokenPaint);
    //绘制浮动文本背景框
    drawFloatTextBackground(canvas, scorePoints.get(i).x, scorePoints.get(i).y - dipToPx(8.5f));
    textPaint.setColor(textNormalColor);
    textPaint.setTextSize(dipToPx(24f));
    //绘制浮动文字
    canvas.drawText(String.valueOf(score[i]), scorePoints.get(i).x, scorePoints.get(i).y - dipToPx(8f) - textSize, textPaint);
    //内层
    brokenPaint.setColor(circleColor);
    canvas.drawCircle(scorePoints.get(i).x, scorePoints.get(i).y, dipToPx(2.5f), brokenPaint);
    brokenPaint.setStyle(Paint.Style.FILL);
    brokenPaint.setColor(circleColor);
    canvas.drawCircle(scorePoints.get(i).x, scorePoints.get(i).y, dipToPx(4f), brokenPaint);
    }
    }
    }
    ...
    private void drawFloatTextBackground(Canvas canvas, int x, int y)
    {
    brokenPath.reset();
    brokenPaint.setColor(floatTextBackgroundColor);
    brokenPaint.setStyle(Paint.Style.FILL);
    //P1
    Point point = new Point(x, y);
    brokenPath.moveTo(point.x, point.y);
    //P2
    point.x = point.x + dipToPx(8);
    point.y = point.y - dipToPx(8);
    brokenPath.lineTo(point.x, point.y);
    //P3
    point.x = point.x + dipToPx(17);//右下角
    brokenPath.lineTo(point.x, point.y);
    //P4
    point.y = point.y - dipToPx(25);//右上角
    brokenPath.lineTo(point.x, point.y);
    //P5
    point.x = point.x - dipToPx(50);
    brokenPath.lineTo(point.x, point.y);
    //P6
    point.y = point.y + dipToPx(25);//左下角
    brokenPath.lineTo(point.x, point.y);
    //P7
    point.x = point.x + dipToPx(17);
    brokenPath.lineTo(point.x, point.y);
    //最后一个点连接到第一个点
    brokenPath.lineTo(x, y);
    canvas.drawPath(brokenPath, brokenPaint);
    }
  1. 画平滑曲线,根据坐标列表,分别将坐标点的 x 和 y 存入各自的 list 中,由 Cubic 根据矩阵计算曲线,并在两两坐标点中根据指定的点数画曲线。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    private void drawBrokenLine(Canvas canvas)
    {
    brokenPath.reset();
    brokenPaint.setColor(brokenLineColor);
    brokenPaint.setStrokeWidth(brokenLineWith);
    brokenPaint.setStyle(Paint.Style.STROKE);
    if(score == null || score.length == 0 || score.length == 1)
    {
    return;
    }
    points_x.clear();
    points_y.clear();
    for (int i = 0; i < scorePoints.size(); i++) {
    points_x.add(scorePoints.get(i).x);//存入所有 X 坐标
    points_y.add(scorePoints.get(i).y);//存入所有 Y 坐标
    }
    List<Cubic> calculate_x = calculate(points_x);//计算
    List<Cubic> calculate_y = calculate(points_y);
    brokenPath
    .moveTo(calculate_x.get(0).eval(0), calculate_y.get(0).eval(0));
    for (int i = 0; i < calculate_x.size(); i++) {
    for (int j = 1; j <= STEPS; j++) {//两点之间指定的数量
    float u = j / (float) STEPS;
    brokenPath.lineTo(calculate_x.get(i).eval(u), calculate_y.get(i)
    .eval(u));
    }
    }
    canvas.drawPath(brokenPath, brokenPaint);
    }
    ...
    private List<Cubic> calculate(List<Integer> x) {
    int n = x.size() - 1;
    float[] gamma = new float[n + 1];
    float[] delta = new float[n + 1];
    float[] D = new float[n + 1];
    int i;
    /*
    * We solve the equation [2 1 ] [D[0]] [3(x[1] - x[0]) ] |1 4 1 | |D[1]|
    * |3(x[2] - x[0]) | | 1 4 1 | | . | = | . | | ..... | | . | | . | | 1 4
    * 1| | . | |3(x[n] - x[n-2])| [ 1 2] [D[n]] [3(x[n] - x[n-1])]
    *
    * by using row operations to convert the matrix to upper triangular and
    * then back sustitution. The D[i] are the derivatives at the knots.
    */
    gamma[0] = 1.0f / 2.0f;
    for (i = 1; i < n; i++) {
    gamma[i] = 1 / (4 - gamma[i - 1]);
    }
    gamma[n] = 1 / (2 - gamma[n - 1]);
    delta[0] = 3 * (x.get(1) - x.get(0)) * gamma[0];
    for (i = 1; i < n; i++) {
    delta[i] = (3 * (x.get(i + 1) - x.get(i - 1)) - delta[i - 1])
    * gamma[i];
    }
    delta[n] = (3 * (x.get(n) - x.get(n - 1)) - delta[n - 1]) * gamma[n];
    D[n] = delta[n];
    for (i = n - 1; i >= 0; i--) {
    D[i] = delta[i] - gamma[i] * D[i + 1];
    }
    /* now compute the coefficients of the cubics */
    List<Cubic> cubics = new LinkedList<Cubic>();
    for (i = 0; i < n; i++) {
    Cubic c = new Cubic(x.get(i), D[i], 3 * (x.get(i + 1) - x.get(i))
    - 2 * D[i] - D[i + 1], 2 * (x.get(i) - x.get(i + 1)) + D[i]
    + D[i + 1]);
    cubics.add(c);
    }
    return cubics;
    }
    ...
    public class Cubic {
    float a,b,c,d; /* a + b*u + c*u^2 +d*u^3 */
    public Cubic(float a, float b, float c, float d){
    this.a = a;
    this.b = b;
    this.c = c;
    this.d = d;
    }
    /** evaluate cubic */
    public float eval(float u) {
    return (((d*u) + c)*u + b)*u + a;
    }
    }
  1. 点击事件,根据 onTouchEvent() 确定点击的位置,如果是有效的触摸范围,然后进行重绘

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    //是否是有效的触摸范围
    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.85f - dipToPx(3);//减去dipToPx(3)增大触摸面积
    float newWith = viewWith - (viewWith * 0.1f) * 2;//分隔线距离最左边和最右边的距离是0.15倍的viewWith
    float validTouchX[] = new float[monthText.length];
    for(int i = 0; i < scorePoints.size(); i++)//超出不可点击
    {
    validTouchX[i] = newWith * ((float) (i) / (monthCount - 1)) + (viewWith * 0.1f);
    }
    if(y > monthTouchY)
    {
    for(int i = 0; i < validTouchX.length; i++)
    {
    Log.d("ScoreChartView", "validateTouch: validTouchX:" + validTouchX[i]);
    if(x < validTouchX[i] + dipToPx(8) && x > validTouchX[i] - dipToPx(8))
    {
    Log.d("ScoreChartView", "validateTouch: " + (i + 1));
    selectMonth = i + 1;
    return true;
    }
    }
    }
    return false;
    }

结论

自此就完成了需求,需要注意的是,关闭硬件加速,可全局指定或具体的 activity 中指定,不关闭硬件加速可能会出现绘制折线错乱等问题。由于平滑曲线计算,产生的趋势问题,曲线的谷底和谷峰可能出现超过坐标点最低后最高。

https://github.com/DituLin/ScoreChartDemo

参考

仿芝麻信用

多点绘制平滑曲线

画经过多点的曲线