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返利最新优惠资讯

相关推荐
幻雨様2 小时前
UE5多人MOBA+GAS 45、制作冲刺技能
android·ue5
Jerry说前后端3 小时前
Android 数据可视化开发:从技术选型到性能优化
android·信息可视化·性能优化
Meteors.4 小时前
Android约束布局(ConstraintLayout)常用属性
android
alexhilton5 小时前
玩转Shader之学会如何变形画布
android·kotlin·android jetpack
whysqwhw9 小时前
安卓图片性能优化技巧
android
风往哪边走9 小时前
自定义底部筛选弹框
android
Yyyy48210 小时前
MyCAT基础概念
android
Android轮子哥10 小时前
尝试解决 Android 适配最后一公里
android
雨白11 小时前
OkHttp 源码解析:enqueue 非同步流程与 Dispatcher 调度
android
风往哪边走12 小时前
自定义仿日历组件弹框
android