项目需要一个折线图,又不想引入那个MPAndroidChart和HelloCharts框架,看了看他们的原理和微信推荐的内容,修改整理出了下面的内容。
在此感谢原作者。
我们大致要实现的形式如下:
在看这篇文章之前,首先建议去看我的上一篇文章
Android PathEffect 自定义折线图必备
看完之后,让我们进入正题:
自定义View四步骤走起;
还是我们自定View的那几个步骤:
- 1、自定义View的属性
- 2、在View的构造方法中获得我们自定义的属性
- [ 3、重写onMesure ]
- 4、重写onDraw
- 5、重写onTouchEvent(如果你需要这个控件对手是操作进行特殊的处理)
1,在attrs里面进行声明
<!--这里为什么抽出去来?因为假如有两个textSize分别在不同的自定义view下,构建的时候会报错,抽取出来重复利用-->
<attr name="textSize" format="dimension|reference"/>
<attr name="textColor" format="color"/>
<declare-styleable name="ChartView">
<attr name="max_score" format="integer"/>
<attr name="min_score" format="integer"/>
<attr name="broken_line_color" format="color"/>
<attr name="textColor"/>
<attr name="textSize"/>
<attr name="dottedlineColor" format="color"/>
</declare-styleable>
2、在View的构造方法中获得我们自定义的属性
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ChartView);
maxScore = a.getInt(R.styleable.ChartView_max_score, 800);
minScore = a.getInt(R.styleable.ChartView_min_score, 600);
brokenLineColor = a.getColor(R.styleable.ChartView_broken_line_color, brokenLineColor);
textNormalColor = a.getColor(R.styleable.ChartView_textColor, textNormalColor);
textSize = a.getDimensionPixelSize(R.styleable.ChartView_textSize, (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP,
15, getResources().getDisplayMetrics()));
straightLineColor = a.getColor(R.styleable.ChartView_dottedlineColor, straightLineColor);
a.recycle();
在View的构造方法中获得我们自定义的属性后,我们要对Paint,Path进行初始化:
//初始化path以及Paint
brokenPath = new Path();
brokenPaint = new Paint();
brokenPaint.setAntiAlias(true);
brokenPaint.setStyle(Paint.Style.STROKE);
brokenPaint.setStrokeWidth(dipToPx(brokenLineWith));
brokenPaint.setStrokeCap(Paint.Cap.ROUND);
straightPaint = new Paint();
straightPaint.setAntiAlias(true);
straightPaint.setStyle(Paint.Style.STROKE);
straightPaint.setStrokeWidth(brokenLineWith);
straightPaint.setColor((straightLineColor));
straightPaint.setStrokeCap(Paint.Cap.ROUND);
dottedPaint = new Paint();
dottedPaint.setAntiAlias(true);
dottedPaint.setStyle(Paint.Style.STROKE);
dottedPaint.setStrokeWidth(brokenLineWith);
dottedPaint.setColor((straightLineColor));
dottedPaint.setStrokeCap(Paint.Cap.ROUND);
textPaint = new Paint();
textPaint.setAntiAlias(true);
textPaint.setTextAlign(Paint.Align.CENTER);
textPaint.setStyle(Paint.Style.FILL);
textPaint.setColor((textNormalColor));
textPaint.setTextSize(textSize);
3、重写onMesure(此View不需要我们去计算,但我们可以重写onSizeChanged进行一些宽高确定,数据的获取等等)
由于onSizeChanged方法在构造方法、onMeasure之后,又在onDraw之前,此时已经完成全局变量初始化,也得到了控件的宽高,所以可以在这个方法中确定一些与宽高有关的数值。
比如这个View的半径啊、padding值等,方便绘制的时候计算大小和位置:
View的坐标轴及获取方法如图:
下面是onSizeChanged方法:
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
viewWith = w;
viewHeight = h;
initData();
}
//初始化数据,这里将数据转换成point点集合,在ondraw的时候取出来画好,连接
private void initData() {
scorePoints = new ArrayList<Point>();
float maxScoreYCoordinate = viewHeight * 0.1f;
float minScoreYCoordinate = viewHeight * 0.6f;
Log.v(TAG, "initData: " + maxScoreYCoordinate);
float newWith = viewWith - (viewWith * 0.15f) * 2;//分隔线距离最左边和最右边的距离是0.15倍的viewWith
int coordinateX;
for (int i = 0; i < score.length; i++) {
Log.v(TAG, "initData: " + score[i]);
Point point = new Point();
coordinateX = (int) (newWith * ((float) (i) / (monthCount - 1)) + (viewWith * 0.15f));//确定point的X坐标
point.x = coordinateX;
if (score[i] > maxScore) {
score[i] = maxScore;
} else if (score[i] < minScore) {
score[i] = minScore;
}
point.y = (int) (((float) (maxScore - score[i]) / (maxScore - minScore)) * (minScoreYCoordinate - maxScoreYCoordinate) + maxScoreYCoordinate);////确定point的Y坐标
scorePoints.add(point);
}
}
4、重写onDraw(一般来说展现出view的形态最复杂的地方)
onDraw方法如下
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
drawDottedLine(canvas, viewWith * 0.15f, viewHeight * 0.1f, viewWith, viewHeight * 0.1f);//上面一条虚线的画法,不懂看坐标系那一张图
drawDottedLine(canvas, viewWith * 0.15f, viewHeight * 0.6f, viewWith, viewHeight * 0.6f);//下面一条虚线的画法
drawText(canvas);//绘制文字,minScore,maxScore
drawMonthLine(canvas);//月份的线及坐标点
drawBrokenLine(canvas);//绘制折线,就是画点,moveto连接
drawPoint(canvas);//绘制穿过折线的点
}
下面,让我们来一步步对其进行分解:
- 1,画两条虚线
/**
* @param canvas 画布
* @param startX 起始点X坐标
* @param startY 起始点Y坐标
* @param stopX 终点X坐标
* @param stopY 终点Y坐标
*/
private void drawDottedLine(Canvas canvas, float startX, float startY, float stopX,
float stopY) {
dottedPaint.setPathEffect(new DashPathEffect(new float[]{20, 10}, 4));//DashPathEffect如果不理解,看我上一篇文章
dottedPaint.setStrokeWidth(1);
// 实例化路径
Path mPath = new Path();
mPath.reset();
// 定义路径的起点
mPath.moveTo(startX, startY);
mPath.lineTo(stopX, stopY);
canvas.drawPath(mPath, dottedPaint);
}
- 2,绘制文字,minScore,maxScore等等
/**
* @param canvas
* */
private void drawText(Canvas canvas) {
textPaint.setTextSize(textSize);//默认字体15
textPaint.setColor(textNormalColor);
canvas.drawText(String.valueOf(maxScore), viewWith * 0.1f - dipToPx(10), viewHeight * 0.1f + textSize * 0.25f, textPaint);
canvas.drawText(String.valueOf(minScore), viewWith * 0.1f - dipToPx(10), viewHeight * 0.6f + textSize * 0.25f, textPaint);
textPaint.setColor(0xff7c7c7c);
float newWith = viewWith - (viewWith * 0.15f) * 2;//分隔线距离最左边和最右边的距离是0.15倍的viewWith
float coordinateX;//分隔线X坐标
textPaint.setTextSize(textSize);
textPaint.setStyle(Paint.Style.FILL);
textPaint.setColor(textNormalColor);
textSize = (int) textPaint.getTextSize();
for (int i = 0; i < monthText.length; i++) {//这里是绘制月份,从数组中取出来,一个个的写
coordinateX = newWith * ((float) (i) / (monthCount - 1)) + (viewWith * 0.15f);
if (i == selectMonth - 1)//被选中的月份要单独画出来多几个圈圈
{
textPaint.setStyle(Paint.Style.STROKE);
textPaint.setColor(brokenLineColor);
RectF r2 = new RectF();
r2.left = coordinateX - textSize - dipToPx(4);
r2.top = viewHeight * 0.7f + dipToPx(4) + textSize / 2;
r2.right = coordinateX + textSize + dipToPx(4);
r2.bottom = viewHeight * 0.7f + dipToPx(4) + textSize + dipToPx(8);
canvas.drawRoundRect(r2, 10, 10, textPaint);
}
//绘制月份
canvas.drawText(monthText[i], coordinateX, viewHeight * 0.7f + dipToPx(4) + textSize + dipToPx(5), textPaint);//不是就正常的画出
textPaint.setColor(textNormalColor);
}
}
- 3,月份的坐标轴线及坐标点的绘制
//绘制月份的直线(包括刻度)
private void drawMonthLine(Canvas canvas) {
straightPaint.setStrokeWidth(dipToPx(1));
canvas.drawLine(0, viewHeight * 0.7f, viewWith, viewHeight * 0.7f, straightPaint);
float newWith = viewWith - (viewWith * 0.15f) * 2;//分隔线距离最左边和最右边的距离是0.15倍的viewWith
float coordinateX;//分隔线X坐标
for (int i = 0; i < monthCount; i++) {
coordinateX = newWith * ((float) (i) / (monthCount - 1)) + (viewWith * 0.15f);
canvas.drawLine(coordinateX, viewHeight * 0.7f, coordinateX, viewHeight * 0.7f + dipToPx(4), straightPaint);
//viewHeight * 0.7f + dipToPx(4)这个方法就是坐标轴上的竖杠杠,你可以修改这里来修改竖条的长度
}
}
- 4,绘制折线,就是画点,lineTo连接drawPath画出来。
//绘制折线
private void drawBrokenLine(Canvas canvas) {
brokenPath.reset();
brokenPaint.setColor(brokenLineColor);
brokenPaint.setStyle(Paint.Style.STROKE);
if (score.length == 0) {
return;
}
Log.v(TAG, "drawBrokenLine: " + scorePoints.get(0));
brokenPath.moveTo(scorePoints.get(0).x, scorePoints.get(0).y);
for (int i = 0; i < scorePoints.size(); i++) {
brokenPath.lineTo(scorePoints.get(i).x, scorePoints.get(i).y);
}
canvas.drawPath(brokenPath, brokenPaint);
}
- 5,绘制折线穿过的点
protected void drawPoint(Canvas canvas) {
if (scorePoints == null) {
return;
}
brokenPaint.setStrokeWidth(dipToPx(1));
for (int i = 0; i < scorePoints.size(); i++) {
brokenPaint.setColor(brokenLineColor);
brokenPaint.setStyle(Paint.Style.STROKE);
canvas.drawCircle(scorePoints.get(i).x, scorePoints.get(i).y, dipToPx(3), brokenPaint);
brokenPaint.setColor(Color.WHITE);
brokenPaint.setStyle(Paint.Style.FILL);
if (i == selectMonth - 1) {//默认选中的才会绘制不同的点,如图
brokenPaint.setColor(0xffd0f3f2);
canvas.drawCircle(scorePoints.get(i).x, scorePoints.get(i).y, dipToPx(8f), brokenPaint);
brokenPaint.setColor(0xff81dddb);
canvas.drawCircle(scorePoints.get(i).x, scorePoints.get(i).y, dipToPx(5f), brokenPaint);
//绘制浮动文本背景框
drawFloatTextBackground(canvas, scorePoints.get(i).x, scorePoints.get(i).y - dipToPx(8f));
textPaint.setColor(0xffffffff);
//绘制浮动文字
canvas.drawText(String.valueOf(score[i]), scorePoints.get(i).x, scorePoints.get(i).y - dipToPx(5f) - textSize, textPaint);
}
brokenPaint.setColor(0xffffffff);
canvas.drawCircle(scorePoints.get(i).x, scorePoints.get(i).y, dipToPx(1.5f), brokenPaint);
brokenPaint.setStyle(Paint.Style.STROKE);
brokenPaint.setColor(brokenLineColor);
canvas.drawCircle(scorePoints.get(i).x, scorePoints.get(i).y, dipToPx(2.5f), brokenPaint);
}
}
//这个方法是利用path和point画出图形,并设置背景颜色
private void drawFloatTextBackground(Canvas canvas, int x, int y) {
brokenPath.reset();
brokenPaint.setColor(brokenLineColor);
brokenPaint.setStyle(Paint.Style.FILL);
//P1
Point point = new Point(x, y);
brokenPath.moveTo(point.x, point.y);
//P2
point.x = point.x + dipToPx(5);
point.y = point.y - dipToPx(5);
brokenPath.lineTo(point.x, point.y);
//P3
point.x = point.x + dipToPx(12);
brokenPath.lineTo(point.x, point.y);
//P4
point.y = point.y - dipToPx(17);
brokenPath.lineTo(point.x, point.y);
//P5
point.x = point.x - dipToPx(34);
brokenPath.lineTo(point.x, point.y);
//P6
point.y = point.y + dipToPx(17);
brokenPath.lineTo(point.x, point.y);
//P7
point.x = point.x + dipToPx(12);
brokenPath.lineTo(point.x, point.y);
//最后一个点连接到第一个点
brokenPath.lineTo(x, y);
canvas.drawPath(brokenPath, brokenPaint);
}
5、重写onTouchEvent
需求:点击一个点上面就会出现和默认选种一样的效果,显示背景圆圈和文字。底部文字也是选中状态
//重写ontouchevent,
@Override
public boolean onTouchEvent(MotionEvent event) {
this.getParent().requestDisallowInterceptTouchEvent(true);
//一旦底层View收到touch的action后调用这个方法那么父层View就不会再调用onInterceptTouchEvent了,也无法截获以后的action,这个事件被消费了
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_MOVE:
break;
case MotionEvent.ACTION_UP://触摸(ACTION_DOWN操作),滑动(ACTION_MOVE操作)和抬起(ACTION_UP)
onActionUpEvent(event);
this.getParent().requestDisallowInterceptTouchEvent(false);
break;
case MotionEvent.ACTION_CANCEL:
this.getParent().requestDisallowInterceptTouchEvent(false);
break;
}
return true;
}
private void onActionUpEvent(MotionEvent event) {
boolean isValidTouch = validateTouch(event.getX(), event.getY());//判断是否是指定的触摸区域
if (isValidTouch) {
invalidate();
}
}
//是否是有效的触摸范围
private boolean validateTouch(float x, float y) {
//曲线触摸区域
for (int i = 0; i < scorePoints.size(); i++) {
// dipToPx(8)乘以2为了适当增大触摸面积
if (x > (scorePoints.get(i).x - dipToPx(8) * 2) && x < (scorePoints.get(i).x + dipToPx(8) * 2)) {
if (y > (scorePoints.get(i).y - dipToPx(8) * 2) && y < (scorePoints.get(i).y + dipToPx(8) * 2)) {
selectMonth = i + 1;
return true;
}
}
}
//月份触摸区域
//计算每个月份X坐标的中心点
float monthTouchY = viewHeight * 0.7f - dipToPx(3);//减去dipToPx(3)增大触摸面积
float newWith = viewWith - (viewWith * 0.15f) * 2;//分隔线距离最左边和最右边的距离是0.15倍的viewWith
float validTouchX[] = new float[monthText.length];
for (int i = 0; i < monthText.length; i++) {
validTouchX[i] = newWith * ((float) (i) / (monthCount - 1)) + (viewWith * 0.15f);
}
if (y > monthTouchY) {
for (int i = 0; i < validTouchX.length; i++) {
Log.v(TAG, "validateTouch: validTouchX:" + validTouchX[i]);
if (x < validTouchX[i] + dipToPx(8) && x > validTouchX[i] - dipToPx(8)) {
Log.v(TAG, "validateTouch: " + (i + 1));
selectMonth = i + 1;
return true;
}
}
}
return false;
}
整体已经完成了,总结一下大致步骤:
- 初始化View的属性
- 初始化画笔
- 绘制代表最高分和最低分的两根虚线
- 绘制文字
- 绘制代表月份的属性
- 绘制芝麻分折线
- 绘制代表芝麻分的圆点
- 绘制选中分数的悬浮文字以及背景
- 处理点击事件
- 如果你想画出多条折线图,看我上篇博客的方法即可Android PathEffect 自定义折线图必备
大家在看的时候按照这个逻辑走,很好理解自定义View的流程。
作者:zhcswlp0625 发表于2017/3/29 12:34:49 原文链接
阅读:890 评论:0 查看评论