自己动手绘制一个折线图控件 ChartView

4,551 阅读7分钟

折线图在很多App中都有用到,Github上面有一些功能全面的折线图框架,比如hellecharts-androidachartengine。但是很多时候设计师给定的样式,通过这些框架不一定能完全达到效果。所以只有琢磨着通过自定义View来自己绘制一个折线图:


最终效果图


根据展示效果,坐标轴、刻度、刻度值、数据点线、标题全部都通过自绘来实现。

初始化绘制相关参数

在构造函数中初始化画笔及数据刻度值等参数:

private void init() {
    this.setBackgroundColor(Color.WHITE);
    // x轴刻度值
    if (xLabel == null) {
        xLabel = new String[]{"12-11", "12-12", "12-13", "12-14", "12-15", "12-16", "12-17"};
    }
    // 数据点
    if (data == null) {
        data = new String[]{"2.98", "2.99", "2.99", "2.98", "2.92", "2.94", "2.95"};
    }
    // 标题
    if (title == null) {
        title = "七日年化收益率(%)";
    }

    // 根据设置的数据值生成Y坐标刻度值
    yLabel = createYLabel();
    // 数据点及其连线的颜色集合
    mDataLineColors = new String[]{"#fbbc14", "#fbaa0c", "#fbaa0c", "#fb8505", "#ff6b02", "#ff5400", "#ff5400"};
    // 新建画笔
    mDataLinePaint = new Paint();       // 数据(点和连线)画笔
    mScaleLinePaint = new Paint();      // 坐标(刻度线条)值画笔
    mScaleValuePaint = new Paint();      // 图表(刻度值)画笔
    mBackColorPaint = new Paint();       // 背景(色块)画笔
    // 画笔抗锯齿
    mDataLinePaint.setAntiAlias(true);
    mScaleLinePaint.setAntiAlias(true);
    mScaleValuePaint.setAntiAlias(true);
    mBackColorPaint.setAntiAlias(true);
}

x轴的刻度值、数据点、y中刻度值设置初始值根据初始值先进行绘制。后续重新设置数据后再重绘。
在根据给定数据点生成y的坐标刻度值时,需要考虑两点:
1.数据点及其连线需要绘制在坐标区域的中间位置,并且数据点的临界值(最大或最小值)不能超过y坐标刻度的临界值;
2.y刻度值必须均分,并且根据不同数据值展示合适的y刻度值。
所以createYLabel()方法实现了根据给定的数据点的值来计算出对应y刻度值的算法。

/**
 * 根据数据值data生成合适的Y坐标刻度值
 *
 * @return y轴坐标刻度值数组
 */
private String[] createYLabel() {
    float[] dataFloats = new float[7];
    for (int i = 0; i < data.length; i++) {
        dataFloats[i] = Float.parseFloat(data[i]);
    }
    // 将数据值从小到大排序
    Arrays.sort(dataFloats);
    // 中间值
    float middle = format3Bit((dataFloats[0] + dataFloats[6]) / 2f);
    // y轴刻度,+0.01f为了避免所有数据点都相等时计算出的y刻度为0.
    float scale = format3Bit((dataFloats[6] - dataFloats[0]) / 4 + 0.01f);
    String[] yText = new String[5];
    yText[0] = (middle - 2 * scale) + "";
    yText[1] = (middle - scale) + "";
    yText[2] = middle + "";
    yText[3] = (middle + scale) + "";
    yText[4] = (middle + 2 * scale) + "";
    for (int i = 0; i < yText.length; i++) {
        yText[i] = format3Bit(yText[i]);
    }
    return yText;
}

将数据值排序后计算出中间值middle、y轴刻度值scaleformat3Bit(float number)将计算结果进行格式化,保证刻度值的小数位数一致。

/**
 * 格式化数字 ###.000
 *
 * @return ###.000
 */
private float format3Bit(float number) {
    DecimalFormat decimalFormat = new DecimalFormat("###.000");
    String target = decimalFormat.format(number);
    if (target.startsWith(".")) {
        target = "0" + target;
    }
    return Float.parseFloat(target);
}

/**
 * 格式化数据 ###.000
 *
 * @param numberStr 格式化后的字符串形式
 * @return ###.000
 */
private String format3Bit(String numberStr) {
    if (TextUtils.isEmpty(numberStr)) {
        return "0.000";
    }
    float numberFloat = Float.parseFloat(numberStr);
    DecimalFormat decimalFormat = new DecimalFormat("###.000");
    String target = decimalFormat.format(numberFloat);
    if (target.startsWith(".")) {
        target = "0" + target;
    }
    return target;
}

onMeasure中初始化绘制尺寸及画笔属性等参数:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    initParams();
}

private void initParams() {
    int width = getMeasuredWidth() - getPaddingLeft() - getPaddingRight();
    int height = getMeasuredHeight() - getPaddingTop() - getPaddingBottom();

    yScale = height / 7.5f;         // y轴刻度
    xScale = width / 7.5f;          // x轴刻度
    startPointX = xScale / 2;       // 开始绘图的x坐标
    startPointY = yScale / 2;       // 开始UI图的y坐标
    xLength = 6.5f * xScale;        // x轴长度
    yLength = 5.5f * yScale;        // y轴长度
    xTextPlaceHeight = yScale / 2;       // x轴刻度文字的占位高度
    yTextPlaceWidth = xScale / 2;        // y轴刻度文字的占位宽度
    titleHeight = yScale;

    chartLineStrokeWidth = xScale / 50;     // 图表线条的线宽
    coordTextSize = xScale / 5;             // 坐标刻度文字的大小
    dataLineStrodeWidth = xScale / 15;      // 数据线条的线宽

    // 设置画笔相关属性
    mBackColorPaint.setColor(0x11DEDE68);
    mScaleLinePaint.setStrokeWidth(chartLineStrokeWidth);
    mScaleLinePaint.setColor(0xFFDEDCD8);
    mScaleValuePaint.setColor(0xFF999999);
    mScaleValuePaint.setTextSize(coordTextSize);
    mDataLinePaint.setStrokeWidth(dataLineStrodeWidth);
    mDataLinePaint.setStrokeCap(Paint.Cap.ROUND);
    mDataLinePaint.setTextSize(1.5f * coordTextSize);
}

onMeasure中就不去判断测量模式了,布局中直接使用match_parent或具体的dp值了。

绘制

onDraw方法中进行绘制:

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    drawBackColor(canvas);              // 绘制背景色块
    drawYAxisAndXScaleValue(canvas);    // 绘制y轴和x刻度值
    drawXAxisAndYScaleValue(canvas);    // 绘制x轴和y刻度值
    drawDataLines(canvas);              // 绘制数据连线
    drawDataPoints(canvas);             // 绘制数据点
    drawTitle(canvas);                  // 绘制标题
}

绘制方法的具体实现:

/**
 * 绘制背景色块
 * @param canvas
 */
private void drawBackColor(Canvas canvas) {
    for (int i = 0; i < 7; i++) {
        if (i == 2 || i == 4 || i == 6) {
            canvas.drawRect(startPointX + (i - 1) * xScale,
                    startPointY,
                    startPointX + i * xScale,
                    yLength + startPointY,
                    mBackColorPaint);
        }
    }
}

private void drawYAxisAndXScaleValue(Canvas canvas) {
    for (int i = 0; i < 7; i++) {
        canvas.drawLine(startPointX + i * xScale,
                startPointY,
                startPointX + i * xScale,
                startPointY + yLength,
                mScaleLinePaint);
        mScaleValuePaint.getTextBounds(xLabel[i], 0, xLabel[i].length(), bounds);
        if (i == 0) {
            canvas.drawText(xLabel[i],
                    startPointX,
                    startPointY + yLength + bounds.height() + yScale / 15,
                    mScaleValuePaint);
        } else {
            canvas.drawText(xLabel[i],
                    startPointX + i * xScale - bounds.width() / 2,
                    startPointY + yLength + bounds.height() + yScale / 15,
                    mScaleValuePaint);
        }
    }
}

/**
 * 绘制x轴和y刻度值
 * @param canvas
 */
private void drawXAxisAndYScaleValue(Canvas canvas) {
    for (int i = 0; i < 6; i++) {
        if (i < 5) {
            mScaleValuePaint.getTextBounds(yLabel[4 - i], 0, yLabel[4 - i].length(), bounds);
            canvas.drawText(yLabel[4 - i],
                    startPointX + xScale / 15,
                    startPointY + yScale * (i + 0.5f) + bounds.height() / 2,
                    mScaleValuePaint);
            canvas.drawLine(startPointX + bounds.width() + 2 * xScale / 15,
                    startPointY + (i + 0.5f) * yScale,
                    startPointX + xLength,
                    startPointY + (i + 0.5f) * yScale,
                    mScaleLinePaint);
        } else {
            canvas.drawLine(startPointX,
                    startPointY + (i + 0.5f) * yScale,
                    startPointX + xLength,
                    startPointY + (i + 0.5f) * yScale,
                    mScaleLinePaint);
        }
    }
}

/**
 * 绘制数据线条
 * @param canvas
 */
private void drawDataLines(Canvas canvas) {
    getDataRoords();
    for (int i = 0; i < 6; i++) {
        mDataLinePaint.setColor(Color.parseColor(mDataLineColors[i]));
        canvas.drawLine(mDataCoords[i][0], mDataCoords[i][1], mDataCoords[i + 1][0], mDataCoords[i + 1][1], mDataLinePaint);
    }
}

/**
 * 绘制数据点
 * @param canvas
 */
private void drawDataPoints(Canvas canvas) {
    // 点击后,绘制数据点
    if (isClick && clickIndex > -1) {
        mDataLinePaint.setColor(Color.parseColor(mDataLineColors[clickIndex]));
        canvas.drawCircle(mDataCoords[clickIndex][0], mDataCoords[clickIndex][1], xScale / 10, mDataLinePaint);
        mDataLinePaint.setColor(Color.WHITE);
        canvas.drawCircle(mDataCoords[clickIndex][0], mDataCoords[clickIndex][1], xScale / 20, mDataLinePaint);
        mDataLinePaint.setColor(Color.parseColor(mDataLineColors[clickIndex]));
    }
}

/**
 * 绘制标题
 * @param canvas
 */
private void drawTitle(Canvas canvas) {
    // 绘制标题文本和线条
    mDataLinePaint.getTextBounds(title, 0, title.length(), bounds);
    canvas.drawText(title, (getWidth() - bounds.width()) / 2, startPointY + yLength + yScale, mDataLinePaint);
    canvas.drawLine((getWidth() - bounds.width()) / 2 - xScale / 15,
            startPointY + yLength + yScale - bounds.height() / 2 + coordTextSize / 4,
            (getWidth() - bounds.width()) / 2 - xScale / 2,
            startPointY + yLength + yScale - bounds.height() / 2 + coordTextSize / 4,
            mDataLinePaint);
}

/**
 * 获取数据值的坐标点
 *
 * @return 数据点的坐标
 */
private void getDataRoords() {
    float originalPointX = startPointX;
    float originalPointY = startPointY + yLength - yScale;
    for (int i = 0; i < data.length; i++) {
        mDataCoords[i][0] = originalPointX + i * xScale;
        float dataY = Float.parseFloat(data[i]);
        float oriY = Float.parseFloat(yLabel[0]);
        mDataCoords[i][1] = originalPointY - (yScale * (dataY - oriY) / (Float.parseFloat(yLabel[1]) - Float.parseFloat(yLabel[0])));
    }
}

getDataRoords()是为了根据数据点的值及坐标刻度的的比例关系计算出数据点的坐标,数据点(小圆圈)是点击后重绘才显示。
点击数据点后,详细的数据信息采用PopupWindow来展示。点击事件就直接在onTouchEnvent中来处理:

@Override
public boolean onTouchEvent(MotionEvent event) {
    float touchX = event.getX();
    float touchY = event.getY();
    for (int i = 0; i < 7; i++) {
        float dataX = mDataCoords[i][0];
        float dataY = mDataCoords[i][1];
        // 控制触摸/点击的范围,在有效范围内才触发
        if (Math.abs(touchX - dataX) < xScale / 2 && Math.abs(touchY - dataY) < yScale / 2) {
            isClick = true;
            clickIndex = i;
            invalidate();     // 重绘展示数据点小圆圈
            showDetails(i);   // 通过PopupWindow展示详细数据信息
            return true;
        } else {
            hideDetails();
        }
        clickIndex = -1;
        invalidate();
    }
    return super.onTouchEvent(event);
}

遍历数据点,根据触摸的位置判断是否在数据点的有效范围内,在有效范围内则通过showDetails(i)弹出弹窗,展示详细的百分比信息。

private void showDetails(int index) {
    if (mPopWin != null) mPopWin.dismiss();
    TextView tv = new TextView(getContext());
    tv.setTextColor(Color.WHITE);
    tv.setBackgroundResource(R.drawable.shape_pop_bg);
    GradientDrawable myGrad = (GradientDrawable) tv.getBackground();
    myGrad.setColor(Color.parseColor(mDataLineColors[index]));
    tv.setPadding(20, 0, 20, 0);
    tv.setGravity(Gravity.CENTER);
    tv.setText(data[index] + "%");
    mPopWin = new PopupWindow(tv, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
    mPopWin.setBackgroundDrawable(new ColorDrawable(0));
    mPopWin.setFocusable(false);
    // 根据坐标点的位置计算弹窗的展示位置
    int xoff = (int) (mDataCoords[index][0] - 0.5f * xScale);
    int yoff = -(int) (getHeight() - mDataCoords[index][1] + 0.75f * yScale);
    mPopWin.showAsDropDown(this, xoff, yoff);
    mPopWin.update();
}

private void hideDetails() {
    if (mPopWin != null) mPopWin.dismiss();
}

需要注意的是:根据需要展示的数据点的index以及数据点的坐标,计算出展示弹窗的位置xoffxoff,弹窗的展示位置是从控件的最左下角为原点算的偏移量。

配置添加数据的方法,供外部调用
/**
 * 设置x轴刻度值
 *
 * @param xLabel x刻度值
 */
public void setxLabel(String[] xLabel) {
    this.xLabel = xLabel;
}

/**
 * 设置数据
 *
 * @param data 数据值
 */
public void setData(String[] data) {
    this.data = data;
}

/**
 * 设置标题
 *
 * @param title 标题
 */
public void setTitle(String title) {
    this.title = title;
}

/**
 * 重新设置x轴刻度、数据、标题后必须刷新重绘
 */
public void fresh() {
    init();
    postInvalidate();
}

当然,也可以添加自定义属性,将数据、坐标刻度值在布局文件中来配置,这里就不添加了。

使用
private void setData() {
    String title = "7日年化收益率(%)";
    String[] xLabel1 = {"12-11", "12-12", "12-13", "12-14", "12-15", "12-16", "12-17"};
    String[] xLabel2 = {"2-13", "2-14", "2-15", "2-16", "2-17", "2-18", "2-19"};
    String[] data1 = {"2.92", "2.99", "3.20", "2.98", "2.92", "2.94", "2.90"};
    String[] data2 = {"2.50", "2.50", "2.50", "2.50", "2.50", "2.50", "2.50"};
    mChartView1.setTitle(title);
    mChartView1.setxLabel(xLabel1);
    mChartView1.setData(data1);
    mChartView1.fresh();
    mChartView2.setTitle(title);
    mChartView2.setxLabel(xLabel2);
    mChartView2.setData(data2);
    mChartView2.fresh();
}

最终效果图1

最终效果图2

源码:github.com/xiaoyanger0…