直接来看看效果图吧
需求分析
从效果图上,我们可以得出以下结论
- 与传统的曲线图不同,x,y轴比较另类,x轴是下方的“地区一”,“地区二”,y轴可以把上方的具体数字看做y轴,即“82.66“,”88.86“这些数字
- 曲线覆盖的下方,有渐变色,颜色从曲线画笔颜色到完全透明
- 有点击效果以及滑动效果
- 选中某个点后,弹出浮窗,浮窗有阴影
曲线的绘制方法有两种:
- 利用贝塞尔曲线绘制
- 使用CornerPathEffect绘制
CornerPathEffect可以将各个连接线之间的夹角用更平滑的方式连接,具体用法本篇文章不做叙述,毕竟我选择用贝塞尔曲线,理由很简单,自己计算控制点,可以做出更多的效果,从效果图可以看出,该曲线图用二阶贝塞尔即可完成
那么,先根据效果图来制定下自定义属性
自定义属性
属性名 | 值 | 作用 |
---|---|---|
titleText | String | title的文字 |
titleBgColor | color | title栏的背景颜色 |
titleTextColor | color | title文字的颜色 |
titleTextSize | sp | title文字大小 |
chartBgColor | color | 图表的背景颜色 |
xCoordinateBgColor | color | x轴背景颜色 |
lineColor | color | y轴线的颜色 |
textDefaultColor | color | x轴与y轴为选中时的默认颜色 |
textSelectedColor | color | x轴与y轴被选中时的颜色 |
xCoordinateTextSize | sp | x轴的文字大小 |
yCoordinateTextSize | sp | y轴的文字大小 |
curveColor | color | 曲线颜色 |
chartPointDefaultStyle | color | 曲线表中点的默认样式 |
chartPointSelectedStyle | color | 曲线表中点被选中的样式 |
xCoordinateSpacing | dp | x轴坐标间的距离 |
chartMinimumColor | color | 图表渐变色中最浅的颜色 |
floatBoxBgColor | color | 浮窗的背景颜色 |
制定好自定义属性后,接下来就需要正式开始撸代码实现效果了
具体实现
onMeasure
一般来说,图表view的宽度始终都是充满整个屏幕的,根据效果图,高度并没有充满屏幕,那么在onMeasure中,我们只需要关心view的高度在wrap模式下,该如何展示,我用了一个比较简单粗暴的方法:
如果高度是wrap模式,那么设定高度为350dp
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
//宽度始终为match,只考虑高为自适应的情况
if (heightMode == MeasureSpec.AT_MOST) {
heightSize = dip2px(350);
}
height = heightSize;
setMeasuredDimension(widthSize, height);
x = firstSpacing;
//减去0.1f是因为最后一个X周刻度距离右边的长度为X轴可见长度的10%
minX = getWidth() - (getWidth() * 0.1f) - xCoordinateSpacing * (xCoordinateData.size() - 1);
maxX = x;
}
onDraw
在开始写onDraw方法前,我们先制定一些小目标:
- 绘制title
- 绘制x轴
- 绘制y轴
- 绘制图表
- 绘制数据点
- 绘制曲线及渐变色
- 绘制浮窗
绘制title
首先,我们要计算title的高度,根据效果图的标注,我们将title部分的高度设置为view高度的1/8
//绘制title
private void drawTitle(Canvas canvas) {
bgColorPaint.setColor(titleBgColor);
int titleBottom = height / 8;
canvas.drawRect(0, 0, getWidth(), titleBottom, bgColorPaint);
Rect mTextBound = new Rect();
titlePaint.getTextBounds(titleText, 0, titleText.length(), mTextBound);
Paint.FontMetrics fontMetrics = titlePaint.getFontMetrics();
float textX = getWidth() / 2 - mTextBound.width() / 2;
float textY = (titleBottom - fontMetrics.bottom + fontMetrics.top) / 2 - fontMetrics.top;
canvas.drawText(titleText, textX, textY, titlePaint);
}
绘制x轴
同样的,x轴部分的高度也为view的1/8,x轴第一个值与原点之间的距离我们设置为x轴坐标间距离的1/3,也就是xCoordinateSpacing / 3;
再根据我们之前的需求分析,该view是有点击效果的,那么我们声明一个boolean类型的变量来判断该点是否被选中
//绘制x轴
private void drawXCoordinate(Canvas canvas) {
int xCoordinateHeight = height - (height / 8);
bgColorPaint.setColor(xCoordinateBgColor);
coordinateTextPaint.setTextSize(xCoordinateTextSize);
//先绘制背景颜色
canvas.drawRect(0, xCoordinateHeight, getWidth(), height, bgColorPaint);
for (int i = 0; i < xCoordinateData.size(); i++) {
float textX = (xCoordinateSpacing / 3) + xCoordinateSpacing * i;
float textY = (height - (height / 8)) + ((height - xCoordinateHeight) / 2);
if (i == selectedIndex) {
coordinateTextPaint.setColor(textSelectedColor);
} else {
coordinateTextPaint.setColor(textDefaultColor);
}
canvas.drawText(xCoordinateData.get(i), textX, textY, coordinateTextPaint);
}
}
绘制y轴
y轴绘制步骤与x轴一致
//绘制y轴
private void drawYCoordinate(Canvas canvas) {
coordinateTextPaint.setTextSize(yCoordinateTextSize);
for (int i = 0; i < yCoordinateData.size(); i++) {
String text = yCoordinateData.get(i) + "";
float textX = (xCoordinateSpacing / 3) + xCoordinateSpacing * i;
float textY = (height / 9) + (height / 8);
if (i == selectedIndex) {
coordinateTextPaint.setColor(textSelectedColor);
} else {
coordinateTextPaint.setColor(textDefaultColor);
}
canvas.drawText(text, textX, textY, coordinateTextPaint);
}
}
绘制图表
//绘制图表
private void drawChart(Canvas canvas) {
bgColorPaint.setColor(chartBgColor);
int chartTop = height / 8;
pointPaint.setColor(Color.BLUE);
bgColorPaint.setColor(chartBgColor);
//先绘制图表背景颜色
canvas.drawRect(0, chartTop, getWidth(), height - chartTop, bgColorPaint);
bgColorPaint.setColor(lineColor);
for (int i = 0; i < yCoordinateData.size(); i++) {
//绘制坐标线
float lineX = (xCoordinateSpacing / 3) + (xCoordinateSpacing * i);
canvas.drawLine(lineX, height / 8, lineX, height - chartTop, bgColorPaint);
}
}
绘制数据点
在绘制数据点前,我们要知道原点在哪儿,根据效果图,原点的x坐标在屏幕的最左侧,也就是0,y坐标在x坐标部分的顶部,也就是height - (height / 8);
而数据点的x坐标,就是x轴的坐标加上x轴文字宽度的一半
private void drawPoint(Canvas canvas) {
yOri = height - (height / 8);
for(int i = 0; i < yCoordinateData.size(); i++) {
int x = (int) (this.x + this.xCoordinateSpacing * i + (getTextWidth(i) / 2));
int y = (int) (yOri - (((yOri * (1 - 0.5f)) * yCoordinateData.get(i)) / Collections.max(yCoordinateData)));
canvas.drawCircle(x, y, 25, pointPaint);
}
}
绘制曲线
前面提到,我们用贝塞尔曲线来完成曲线图中曲线的绘制,那么,我们先计算贝塞尔曲线中的控制点
先根据相邻点(P1,P2, P3)计算出相邻点的中点(P4, P5),然后再计算相邻中点的中点(P6)。然后将(P4,P6, P5)组成的线段平移到经过P2的直线(P8,P2,P7)上。接着根据(P4,P6,P5,P2)的坐标计算出(P7,P8)的坐标。最后根据P7,P8等控制点画出三阶贝塞尔曲线。
private void initPoints() {
yOri = height - (height / 8);
mPoints = new ArrayList<>();
for (int i = 0; i < yCoordinateData.size(); i++) {
int x = (int) (this.x + this.xCoordinateSpacing * i + (getTextWidth(i) / 2));
int y = (int) (yOri - (((yOri * (1 - 0.5f)) * yCoordinateData.get(i)) / Collections.max(yCoordinateData)));
mPoints.add(new Point(x, y));
}
//该点用于绘制从最后一个点到屏幕最右侧到二阶贝塞尔曲线
mPoints.add(new Point(getWidth(), (int) (height - (height / 2.5))));
}
private void initMidPoints() {
mMidPoints = new ArrayList<>();
for (int i = 0; i < mPoints.size(); i++) {
Point midPoint = null;
if (i == mPoints.size() - 1) {
return;
} else {
midPoint = new Point((mPoints.get(i).x + mPoints.get(i + 1).x) / 2,
(mPoints.get(i).y + mPoints.get(i + 1).y) / 2);
}
mMidPoints.add(midPoint);
}
}
private void initMidMidPoints() {
mMidMidPoints = new ArrayList<>();
for (int i = 0; i < mMidPoints.size(); i++) {
Point midMidPoint = null;
if (i == mMidPoints.size() - 1) {
return;
} else {
midMidPoint = new Point((mMidPoints.get(i).x + mMidPoints.get(i + 1).x) / 2,
(mMidPoints.get(i).y + mMidPoints.get(i + 1).y) / 2);
}
mMidMidPoints.add(midMidPoint);
}
}
private void initControlPoints() {
mControlPoints = new ArrayList<>();
for (int i = 0; i < mPoints.size(); i++) {
if (i == 0 || i == mPoints.size() - 1) {
continue;
} else {
Point before = new Point();
Point after = new Point();
before.x = mPoints.get(i).x - mMidMidPoints.get(i - 1).x + mMidPoints.get(i - 1).x;
before.y = mPoints.get(i).y - mMidMidPoints.get(i - 1).y + mMidPoints.get(i - 1).y;
after.x = mPoints.get(i).x - mMidMidPoints.get(i - 1).x + mMidPoints.get(i).x;
after.y = mPoints.get(i).y - mMidMidPoints.get(i - 1).y + mMidPoints.get(i).y;
mControlPoints.add(before);
mControlPoints.add(after);
}
}
}
得出mControlPoints就是我们最终得出来的控制点,接下来的事情就很简单了
,我们再观察观察效果图,从屏幕最左边到第一个数据点和最后一个数据点到屏幕最右边,都有一个曲线连接,那么就随便写两个控制点用于绘制这两根曲线
//绘制曲线
private void drawCurve(Canvas canvas) {
// 重置路径
Path curvePath = new Path();
curvePath.reset();
Path gradientPath = new Path();
gradientPath.reset();
//从屏幕左边绘制到第一个点
curvePath.moveTo(0, (float) (height - (height / 2.5)));// 起点
curvePath.quadTo(0, (float) (height - (height / 2.5)),// 控制点
mPoints.get(0).x, mPoints.get(0).y);
gradientPath.moveTo(0, height - (height / 8));
gradientPath.lineTo(0, (float) (height - (height / 2.5)));
gradientPath.quadTo(0, (float) (height - (height / 2.5)),// 控制点
mPoints.get(0).x, mPoints.get(0).y);
for (int i = 0; i < mPoints.size(); i++) {
if (i == 0) {// 第一条为二阶贝塞尔
curvePath.moveTo(mPoints.get(i).x, mPoints.get(i).y);// 起点
curvePath.quadTo(mControlPoints.get(i).x, mControlPoints.get(i).y,// 控制点
mPoints.get(i + 1).x, mPoints.get(i + 1).y);
gradientPath.quadTo(mControlPoints.get(i).x, mControlPoints.get(i).y,// 控制点
mPoints.get(i + 1).x, mPoints.get(i + 1).y);
} else if (i < mPoints.size() - 1) {// 三阶贝塞尔
//二阶
curvePath.quadTo(mControlPoints.get(2 * i - 1).x, mControlPoints.get(2 * i - 1).y,
mPoints.get(i + 1).x, mPoints.get(i + 1).y);
gradientPath.quadTo(mControlPoints.get(2 * i - 1).x, mControlPoints.get(2 * i - 1).y,
mPoints.get(i + 1).x, mPoints.get(i + 1).y);
} else if (i == mPoints.size() - 2) {// 最后一条为二阶贝塞尔
curvePath.moveTo(mPoints.get(i).x, mPoints.get(i).y);// 起点
curvePath.quadTo(mControlPoints.get(mControlPoints.size() - 1).x,
mControlPoints.get(mControlPoints.size() - 1).y,
mPoints.get(i + 1).x, mPoints.get(i + 1).y);// 终点
gradientPath.quadTo(mControlPoints.get(mControlPoints.size() - 1).x,
mControlPoints.get(mControlPoints.size() - 1).y,
mPoints.get(i + 1).x, mPoints.get(i + 1).y);//终点
}
}
//绘制渐变色
gradientPath.lineTo(getWidth(), height - (height / 8));
gradientPath.lineTo(0, height - (height / 8));
float left = 0;
float top = getPaddingTop();
float bottom = height - (height / 8);
//渐变色中最深的颜色用绘制曲线的颜色
LinearGradient lg = new LinearGradient(left, top, left, bottom, curveColor,
chartMinimumColor, Shader.TileMode.CLAMP);// CLAMP重复最后一个颜色至最后
gradientColorPaint.setShader(lg);
gradientColorPaint.setXfermode(new PorterDuffXfermode(
android.graphics.PorterDuff.Mode.SRC_ATOP));
canvas.drawPath(gradientPath, gradientColorPaint);
canvas.drawPath(curvePath, curvePaint);
}
绘制浮窗
要绘制出效果图那样的阴影很有难度,我暂且没有百分百的还原。我利用BlurMaskFilter来实现了类似阴影的效果,只需要在初始化画笔的时候,设置一下即可,当然,需要关闭硬件加速:
setLayerType(LAYER_TYPE_SOFTWARE, floatBoxPaint);
floatBoxPaint.setMaskFilter(new BlurMaskFilter(30, BlurMaskFilter.Blur.SOLID));
设置好画笔后,接下来就绘制浮窗:
private void drawFloatBox(Canvas canvas) {
RectF rectF = new RectF();
for (int i = 0; i < xCoordinateData.size(); i++) {
if (i == selectedIndex) {
rectF.left = mPoints.get(i).x - dip2px(47);
rectF.right = mPoints.get(i).x + dip2px(47);
rectF.top = mPoints.get(i).y - dip2px(70);
rectF.bottom = mPoints.get(i).y + dip2px(90);
canvas.drawRoundRect(rectF, 5, 5, floatBoxPaint);
coordinateTextPaint.setTextSize(60);
coordinateTextPaint.setColor(textSelectedColor);
canvas.drawText(yCoordinateData.get(i) + "", this.x + xCoordinateSpacing * i,
mPoints.get(i).y - dip2px(35), coordinateTextPaint);
}
}
}
onTouchEvent
点击事件以及滑动事件,其实比较简单,先来看看滑动事件:
在处理滑动事件前,我们要明确几个东西:
数据点的最小x坐标以及最大x坐标,用于记录滑动距离
滑动事件
case MotionEvent.ACTION_MOVE:
//如果屏幕不足以展示所有数据时 if (xCoordinateSpacing * xCoordinateData.size() > getWidth()) { float dis = event.getX() - startX; startX = event.getX(); if (x + dis < minX) { x = minX; } else if (x + dis > maxX) { x = maxX; } else { x = x + dis; } invalidate(); } break;
点击事件
/**
* 点击X轴坐标或者折线节点
*
* @param event
*/
private void clickAction(MotionEvent event) {
int dp10 = dip2px(10);
float eventX = event.getX();
float eventY = event.getY();
for (int i = 0; i < xCoordinateData.size(); i++) {
//节点
float x = mPoints.get(i).x;
float y = mPoints.get(i).y;
if (eventX >= x - dp10 && eventX <= x + dp10 &&
eventY >= y - dp10 && eventY <= y + dp10 && selectedIndex != i) {//每个节点周围10dp都是可点击区域
selectedIndex = i;
invalidate();
return;
}
//X轴刻度
x = mPoints.get(i).x;
y = (height - (height / 8)) + ((height - (height - (height / 8))) / 2);
if (eventX >= x - getTextWidth(i) / 2 - dp10 && eventX <= x + getTextWidth(i) + dp10 / 2 &&
eventY >= y - dp10 && eventY <= y + dp10 && selectedIndex != i) {
selectedIndex = i;
invalidate();
return;
}
}
}
完整的onTouchEvent代码
@Override
public boolean onTouchEvent(MotionEvent event) {
this.getParent().requestDisallowInterceptTouchEvent(true);
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
startX = event.getX();
break;
case MotionEvent.ACTION_MOVE:
//如果屏幕不足以展示所有数据时
if (xCoordinateSpacing * xCoordinateData.size() > getWidth()) {
float dis = event.getX() - startX;
startX = event.getX();
if (x + dis < minX) {
x = minX;
} else if (x + dis > maxX) {
x = maxX;
} else {
x = x + dis;
}
invalidate();
}
break;
case MotionEvent.ACTION_UP:
clickAction(event);
this.getParent().requestDisallowInterceptTouchEvent(false);
break;
case MotionEvent.ACTION_CANCEL:
this.getParent().requestDisallowInterceptTouchEvent(false);
break;
}
return true;
}
到这里,整个曲线图已经完成了,来看看最终效果
结语
其实最终效果与效果图还有些差距,包括浮窗的效果也不完美,不过倒是有个思路,将点击事件暴露出来,提供x,y坐标,点击后,弹出pop,可以达到一样的效果,有兴趣的话,各位可以试试看。
文章中若有错误,还希望各位提出来