先来看看效果图
上面的柱状图之前的文章已经讲过了: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返利最新优惠资讯