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

先来看看效果图

上面的柱状图之前的文章已经讲过了:Android自定义View-柱状图(java) - 掘金 (juejin.cn)

这次我们讲讲下面这个曲线图

分析一波

曲线图包含的元素:

1、左边的纵坐标轴值

2、底部的横坐标轴值

3、中间的坐标轴虚线

4、各位点值连接而成的曲线

5、曲线往下覆盖住的区域

一共包含这五个东西。

看看整体的属性

arduino 复制代码
// 坐标轴上描述性文字的空间大小
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轴数据

看看数据类:

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

以下是View初始化逻辑:

ini 复制代码
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);
    }

首先看下数据创建逻辑

ini 复制代码
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

scss 复制代码
/**
 * 获取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三个值了,坐标系的位置也很好确认了,开画:

ini 复制代码
  /**
     * 绘制坐标系
     */
    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);
        }
    }

接着画曲线:

scss 复制代码
/**
 * 绘制曲线
 */
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位置:

ini 复制代码
@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;
}

之后进行绘制弹窗:

ini 复制代码
/**
 * 绘制点击展示的数据弹窗
 */
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返利最新优惠资讯

相关推荐
雨白6 小时前
Jetpack系列(二):Lifecycle与LiveData结合,打造响应式UI
android·android jetpack
kk爱闹8 小时前
【挑战14天学完python和pytorch】- day01
android·pytorch·python
每次的天空10 小时前
Android-自定义View的实战学习总结
android·学习·kotlin·音视频
恋猫de小郭10 小时前
Flutter Widget Preview 功能已合并到 master,提前在体验毛坯的预览支持
android·flutter·ios
断剑重铸之日11 小时前
Android自定义相机开发(类似OCR扫描相机)
android
随心最为安11 小时前
Android Library Maven 发布完整流程指南
android
岁月玲珑11 小时前
【使用Android Studio调试手机app时候手机老掉线问题】
android·ide·android studio
还鮟15 小时前
CTF Web的数组巧用
android
小蜜蜂嗡嗡17 小时前
Android Studio flutter项目运行、打包时间太长
android·flutter·android studio
aqi0017 小时前
FFmpeg开发笔记(七十一)使用国产的QPlayer2实现双播放器观看视频
android·ffmpeg·音视频·流媒体