Android自定义View:曲线图(Java)

先来看看效果图

在这里插入图片描述

上面的柱状图之前的文章已经讲过了:Android自定义View-柱状图(java) - 掘金 (juejin.cn)
这次我们讲讲下面这个曲线图

分析一波

曲线图包含的元素:
1、左边的纵坐标轴值
2、底部的横坐标轴值
3、中间的坐标轴虚线
4、各位点值连接而成的曲线
5、曲线往下覆盖住的区域
一共包含这五个东西。

看看整体的属性

// 坐标轴上描述性文字的空间大小
private int mBottomTextHeight;
private int mLeftTextWidth, mLeftTextMargin;

private List<WaveConfigData> mWaves;// 数值集合
private int mCoordinateYCount = 6;// y轴的分块数
private String[] mCoordinateXValues;// 外界传入,x轴的值
private float[] mCoordinateYValues;// 动态计算,y轴的值
// 网格尺寸
private int mGridWidth, mGridHeight;
// 所有曲线中所有数据中的最大值
private float mGlobalMaxValue;// 用于确认是否需要调整坐标系
private float mMaxYValue;// 所有数据线里面,最大的Y轴数据

看看数据类:

public static class WaveConfigData {
    int color; // 曲线颜色
    int linearGradientEndColor; // 区域类型,渐变颜色的endColor
    boolean isCoverRegion; // 是否是覆盖区域类型
    float[] values; // 点位数值
}

以下是View初始化逻辑:

private void init(Context context, @Nullable AttributeSet attrs) {
        // 自定义属性的赋值,具体逻辑看项目源码
        ......

        // 初始化数据集合的容器
        mWaves = new ArrayList<>();
        // 初始化坐标轴Paint
        mCoordinatorPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
        mCoordinatorPaint.setColor(coordinatorColor);
        mCoordinatorPaint.setStrokeWidth(dp2px(0.5f));
        // 初始化文本Paint
        mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
        mTextPaint.setColor(textColor);
        mTextPaint.setTextSize(coordinatorTextSize);
        // 初始化曲线Paint
        mWrapPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
        mWrapPaint.setTextSize(showTextSize);
        mWrapPaint.setStrokeWidth(dp2px(2));

        mCoordinateDashedPathEffect = new DashPathEffect(new float[]{9, 9}, 5);
        mWaveDashedPathEffect = new DashPathEffect(new float[]{20, 10}, 10);
    }

首先看下数据创建逻辑

public void addWave(int color, int linearGradientEndColor, int dashStartPoint, boolean isCoverRegion, float... values) {
        WaveConfigData waveConfigData = new WaveConfigData(color, linearGradientEndColor, isCoverRegion, values);
        waveConfigData.dashStartPoint = dashStartPoint;
        mWaves.add(waveConfigData);
        // 根据value的值去计算纵坐标的数值
        float maxValue = 0;
        for (float value : values) {
            maxValue = Math.max(maxValue, value);
        }
        mMaxYValue = Math.max(mMaxYValue, maxValue);
        if (maxValue < mGlobalMaxValue) return;
        float gridValue = 1;
        float tempMaxY = mMaxYValue;
        while ((tempMaxY /= 10f) > 1) {
            gridValue *= 10;
        }
        if (tempMaxY >= 0.5)
            gridValue *= 2;
        mGlobalMaxValue = gridValue * (mCoordinateYCount - 1);
        // 给纵坐标的数值赋值
        mCoordinateYValues = new float[mCoordinateYCount];
        for (int i = 0; i < mCoordinateYCount; i++) {
            mCoordinateYValues[i] = i * gridValue;
        }
        invalidate();
    }

在添加曲线或覆盖区域的时候,根据点位数据float[] values计算出最大的纵坐标值maxValue,然后根据maxValue计算出y轴各个点位的具体值mCoordinateYValues。

开始Draw

/**
 * 获取X轴位置
 */
private int getXCoordinator() {
    return getHeight() - getPaddingBottom() - mBottomTextHeight;
}

/**
 * 获取Y轴位置
 */
private int getYCoordinator() {
    return getPaddingStart() + mLeftTextWidth + mLeftTextMargin;
}

@Override
protected void onDraw(Canvas canvas) {
    drawCoordinate(canvas);
    drawWrap(canvas);
    drawShowPop(canvas);
}

首先进行绘制坐标系:
我这边做了一个简化操作:y轴到控件左边的间距直接进行写死mLeftTextWidth和mLeftTextMargin,x轴到控件底部的间距直接写死mBottomTextHeight,少算了很多间距问题,但肯定会有情况没有适配到。例如左边y轴值的值很大20000000,就会显示不全,这个后期可以优化成读取文本宽度进行动态调整。
既然已经有mLeftTextWidth、mLeftTextMargin和mBottomTextHeight三个值了,坐标系的位置也很好确认了,开画:

/**
     * 绘制坐标系
     */
    private void drawCoordinate(Canvas canvas) {
        Point start = new Point();
        Point stop = new Point();
        // 1. 绘制横轴线和纵坐标单位   只绘制坐标轴X轴和实际最大值的横轴线
        int xLineCount = mCoordinateYValues.length;
        mGridHeight = (getXCoordinator() - getPaddingTop()) / (xLineCount - 1);

        float offsetY = CanvasUtil.getDrawTextOffsetY(mTextPaint);
        String drawText;
        float baseLine, left;
        for (int i = 0; i < xLineCount; i++) {
            if (i > 0) {
                mCoordinatorPaint.setPathEffect(mCoordinateDashedPathEffect);
            } else {
                mCoordinatorPaint.setPathEffect(null);
            }
            start.x = getYCoordinator();
            start.y = getXCoordinator() - mGridHeight * i;
            stop.x = getWidth() - getPaddingEnd();
            stop.y = start.y;
            // 绘制横轴线
            canvas.drawLine(start.x, start.y, stop.x, stop.y, mCoordinatorPaint);
            // 绘制纵坐标单位
            drawText = String.valueOf((int) mCoordinateYValues[i]);
            baseLine = start.y + offsetY;
            left = getYCoordinator() - mTextPaint.measureText(drawText) - mLeftTextMargin;
            canvas.drawText(drawText, left, baseLine, mTextPaint);
        }

        // 2. 绘制纵轴线和横坐标单位
        int yLineCount = mCoordinateXValues.length;
        mGridWidth = (getWidth() - getYCoordinator() - getPaddingEnd()) / (yLineCount - 1);
        start.y = getPaddingTop();
        stop.y = getXCoordinator();
        for (int i = 0; i < yLineCount; i++) {
            if (i > 0) {
                mCoordinatorPaint.setPathEffect(mCoordinateDashedPathEffect);
            } else {
                mCoordinatorPaint.setPathEffect(null);
            }
            start.x = stop.x = getYCoordinator() + mGridWidth * i;
            // 绘制纵轴线
            canvas.drawLine(start.x, start.y, stop.x, stop.y, mCoordinatorPaint);
            // 绘制横坐标单位
            drawText = mCoordinateXValues[i];
            baseLine = getXCoordinator() + mBottomTextHeight / 2f + offsetY;
            left = start.x - mTextPaint.measureText(drawText) / 2;
            canvas.drawText(drawText, left, baseLine, mTextPaint);
        }
    }

接着画曲线:

/**
 * 绘制曲线
 */
private void drawWrap(Canvas canvas) {
    float yHeight = mGridHeight * (mCoordinateYCount - 1);
    float waveMinY, x, y, preX = 0, preY = 0;
    Path path = new Path();
    boolean hadDrawMaxYLine = false;
    float maxY = mCoordinateYValues[mCoordinateYCount - 1];// Y轴坐标的最大值
    for (WaveConfigData wave : mWaves) {
        waveMinY = Float.MAX_VALUE;
        for (int index = 0; index < wave.values.length; index++) {
            // 计算点的位置
            x = getYCoordinator() + mGridWidth * index;
            y = getXCoordinator() - yHeight * (wave.values[index] / maxY);
            waveMinY = Math.min(waveMinY, y);
            if (index == 0)
                path.moveTo(x, y);
            else {
                float centerX = preX + mGridWidth / 2f;
                path.cubicTo(centerX, preY, centerX, y, x, y);//创建三阶贝塞尔曲线
            }
            preX = x;
            preY = y;

            if (wave.dashStartPoint >= 0 && wave.dashStartPoint == index && index + 1 < wave.values.length) {
                // 需要绘制虚线,则先把之前的实线绘制掉
                drawWavePath(wave, canvas, path, waveMinY);
                // 切换画笔为虚线
                mWrapPaint.setPathEffect(mWaveDashedPathEffect);
                path.reset();
                path.moveTo(x, y);
            }
        }
        drawWavePath(wave, canvas, path, waveMinY);
        path.reset();
    }
}

private void drawWavePath(WaveConfigData wave, Canvas canvas, Path path, float waveMinY) {
    if (wave.isCoverRegion) { // 如果是覆盖区域,则连上坐标轴的右下角和原点,圈出整个区域
        mWrapPaint.setStyle(Paint.Style.FILL);
        path.lineTo(getWidth() - getPaddingEnd(), getXCoordinator());
        path.lineTo(getYCoordinator(), getXCoordinator());
        path.close();
    } else {
        mWrapPaint.setStyle(Paint.Style.STROKE);
    }
    if (wave.linearGradientEndColor != 0) { // 如果有渐变颜色endColor,则设置渐变色shader
        LinearGradient linearGradient = new LinearGradient(
                getYCoordinator() + mGridWidth * wave.values.length / 2f, waveMinY,
                getYCoordinator() + mGridWidth * wave.values.length / 2f, getXCoordinator(),
                wave.color, wave.linearGradientEndColor, Shader.TileMode.CLAMP);
        mWrapPaint.setShader(linearGradient);
    } else {
        mWrapPaint.setShader(null);
        mWrapPaint.setColor(wave.color);
    }
    canvas.drawPath(path, mWrapPaint);
    // 清除虚线的配置
    mWrapPaint.setPathEffect(null);
    // 清除渐变色的配置
    mWrapPaint.setShader(null);
}

添加手势

到此,基本的曲线图展示功能就完成了,接下来,就是完善了。
添加手势,用于查看点位的值,如下:

首先是判断点击的位置在那个范围,判断获取到对应的index位置:

@Override
public boolean onTouchEvent(MotionEvent event) {
    if (mWaves.isEmpty()) return super.onTouchEvent(event);

    if (event.getAction() == MotionEvent.ACTION_UP) {
        // 隐藏信息弹窗
        mCurrentShowIndex = -1;
        invalidate();
    } else {
        // 展示信息弹窗
        float x = event.getX();
        int start, center, end;
        int needShowIndex = -1;
        for (int i = 0; i < mCoordinateXValues.length; i++) {
            center = getYCoordinator() + mGridWidth * i;
            if (i == 0) {
                start = center;
                end = center + mGridWidth / 2;
            } else if (i == mCoordinateXValues.length - 1) {
                start = center - mGridWidth / 2;
                end = center;
            } else {
                start = center - mGridWidth / 2;
                end = center + mGridWidth / 2;
            }
            if (x >= start && x <= end) {
                needShowIndex = i;
                break;
            }
        }
        if (needShowIndex >= 0 && needShowIndex != mCurrentShowIndex) {
            mCurrentShowIndex = needShowIndex;
            invalidate();
        }
    }
    Log.i("WaveView", "currentShowIndex: " + mCurrentShowIndex);
    return true;
}

之后进行绘制弹窗:

/**
 * 绘制点击展示的数据弹窗
 */
private void drawShowPop(Canvas canvas) {
    if (mCurrentShowIndex >= 0 && !mWaves.isEmpty()) {
        WaveConfigData wave = mWaves.get(0);
        float value = wave.values[mCurrentShowIndex];

        float maxY = mCoordinateYValues[mCoordinateYCount - 1];// Y轴坐标的最大值
        float yHeight = mGridHeight * (mCoordinateYCount - 1);
        float x = getYCoordinator() + mGridWidth * mCurrentShowIndex;
        float y = getXCoordinator() - yHeight * (wave.values[mCurrentShowIndex] / maxY);

        // 绘制弹窗的圆角矩形
        float offsetY = y - mShowCircleRadius + mWrapPaint.getStrokeWidth();
        mWrapPaint.setStyle(Paint.Style.FILL);
        mWrapPaint.setColor(wave.color);
        float triangleSize = 20;
        String text = String.format(Locale.getDefault(), "%.02f", value);
        float textWidth = mWrapPaint.measureText(text);
        float startX = x - mShowPaddingHorizontal - textWidth / 2f;
        float startY = offsetY - triangleSize - 2 * mShowPaddingVertical - mWrapPaint.getTextSize();
        canvas.drawRoundRect(startX, startY, startX + 2 * mShowPaddingHorizontal + textWidth,
                startY + 2 * mShowPaddingVertical + mWrapPaint.getTextSize(), 15, 15, mWrapPaint);

        // 连线绘制圆角
        Path path = new Path();
        path.moveTo(x - triangleSize, offsetY - triangleSize - mWrapPaint.getStrokeWidth() / 2f);
        path.lineTo(x + triangleSize, offsetY - triangleSize - mWrapPaint.getStrokeWidth() / 2f);
        path.lineTo(x, offsetY);
        path.close();
        canvas.drawPath(path, mWrapPaint);

        // 写中间的字
        mWrapPaint.setColor(Color.WHITE);
        canvas.drawText(text, startX + mShowPaddingHorizontal,
                startY + mShowPaddingVertical + mWrapPaint.getTextSize() / 2f + CanvasUtil.getDrawTextOffsetY(mWrapPaint),
                mWrapPaint);

        // 先画圆环
        mWrapPaint.setStyle(Paint.Style.STROKE);
        mWrapPaint.setColor(wave.color);
        RectF rectF = new RectF();
        rectF.left = x - mShowCircleRadius;
        rectF.right = x + mShowCircleRadius;
        rectF.top = y - mShowCircleRadius;
        rectF.bottom = y + mShowCircleRadius;
        canvas.drawArc(rectF, 360, 360, false, mWrapPaint);

        // 再画白色圆圈叠加在中间进行遮挡
        mWrapPaint.setStyle(Paint.Style.FILL);
        mWrapPaint.setColor(Color.WHITE);
        canvas.drawCircle(x, y, mShowCircleRadius - mWrapPaint.getStrokeWidth() / 2, mWrapPaint);
    }
}

完结撒花

项目源码:Calendar: 周日历、月日历 用于记录展示事件列表 (gitee.com) 首页点击右上角更多按钮,点击查看柱状图就是了哟。

项目里面的ncalendar模块,使用的是GitHub - yannecer/NCalendar: 一款安卓日历,仿miui,钉钉,华为的日历,万年历、365、周日历,月日历,月视图、周视图滑动切换,农历,节气,Andriod Calendar , MIUI Calendar,小米日历
微信搜索“A查佣利小助手”,获取支付宝红包、TB/JD/PDD返利最新优惠资讯
在这里插入图片描述

  • 14
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

虾米~

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值