最近公司项目需要用到图表,拿到UI设计时,感觉也不是很难。用著名的MPAndroidChart库改改就可以了,可是领导说图表中的点要一闪一闪,还要先亮前一个,再亮下一个。是不是没听懂,我当时也是一脸懵逼。
先看公司给的UI设计:
由于MPAndroidChart没有那个动画效果,于是我就决定自定义一个这样的图表。先看看我实现的DEMO效果,在最后附上一张放入项目中的截图。
DEMO代码地址:https://github.com/jinxiyang/LineChart
自定义控件
自定义控件,有好几种方式。本次我们就继承View实现一个这样的图表。让我们回顾一下,继承View自定义控件的步骤:
- 自定义View的属性
- 在View的构造方法中获取我们定义的属性值
- 重写onMeasure方法,测量View尺寸
- 重写onDraw方法,绘制控件
- 重写onTouchEvent方法,处理点击事件
下面开始自定义我们的图表。
分析图表所需要的属性
绘制图表时,我们要知道图表各个元素的样式,分析一下总共有:
- 图表中文字的颜色、大小
- 各种线条的颜色、宽度
- 阴影的颜色、透明度
- 点的颜色、半径
- 标记maker的字体颜色、大小
……
//默认的动画脉冲间隔
private static final long DEFAULT_INTERVAL_TIME = 20;
//默认x轴最大显示几项
private static final int DEFAULT_X_MAX_ITEM_NUM = 10;
//默认y轴最大显示几格
private static final int DEFAULT_Y_MAX_ITEM_NUM = 5;
//默认点的最大半径,dp
private static final int DEFAULT_MAX_POINT_RADIUS = 6;
//坐标轴文字的颜色
private int axisTextColor = Color.rgb(205, 137, 118);
//标轴文字的大小,sp
private int axisTextSize = 14;
//文字和x坐标轴之间的间距,dp
private int yAxisGap = 3;
//文字和y坐标轴之间的间距,dp
private int xAxisGap = 3;
//x坐标轴的颜色
private int xAxisColor = Color.rgb(205, 137, 118);
//x坐标轴的宽度,dp
private int xAxisWidth = 2;
//x坐标轴下面小竖线的高度,dp
private int xAxisChildLineHeight = 5;
//虚线的颜色
private int dashedLineColor = Color.argb(155, 19, 113, 187);
//虚线的宽度,dp
private int dashedLineWidth = 1;
//虚线中每段实线的宽度,dp
private int dashWidth = 5;
//虚线中实线间的间隔,dp
private int dashGap = 3;
//阴影的颜色
private int shadowColor = Color.argb(100, 177, 234, 253);
//点的颜色
private int pointColor = Color.argb(155, 19, 113, 187);
//点之间连线的颜色
private int lineColor = Color.rgb(0, 220, 255);
//点之间连线的宽度,dp
private int lineWidth = 2;
//悬浮maker标记的字体颜色
private int makerTextColor = Color.rgb(255, 255, 255);
//悬浮maker标记的字体大小,dp
private int makerTextSize = 14;
//悬浮maker标记的背景颜色
private int makerBackgroundColor = Color.argb(155, 19, 113, 187);
//悬浮maker标旁边竖线的颜色
private int makerLineColor = Color.rgb(205, 137, 118);
//悬浮maker标旁边竖线的宽度,dp
private int makerLineWidth = 1;
//悬浮maker的padding,dp
private int makerPadding = 10;
(⊙o⊙)…,不写图表不知道,原来需要这么多属性,怪不得大名鼎鼎的MPAndroidChart也没有定义属性,那我们也不定义了。(偷了会懒,_)。提供一些setter方法,用的时候设置一下就好了。
public void setAxisTextColor(int axisTextColor) {
this.axisTextColor = axisTextColor;
}
public void setAxisTextSize(int axisTextSize) {
this.axisTextSize = axisTextSize;
}
public void setyAxisGap(int yAxisGap) {
this.yAxisGap = yAxisGap;
}
……//省略
这些值在用的时候,我们会把他们从标注的单位sp、dp,转为绘制时的px
//dp转为px
public int dpToPx(DisplayMetrics dm, int dp){
return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, dm);
}
//sp转为px
public int spToPx(DisplayMetrics dm, int sp){
return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, dm);
}
初始化我们的构造函数
public LineChart(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
mPaint = new Paint();
lineHeadPoint = new ChartPoint();
mPoints = new ArrayList<>();
calculatePoint();
}
mPaint 绘图的画笔
lineHeadPoint 动画执行时连线的头的坐标
calculatePoint() 根据提供的数据计算点的坐标,待会再讲这个方法
重写onMeasure方法,测量View尺寸
仔细观察UI给的图表,分析知道这个图表的尺寸肯定是给定的,也就是制定了dp尺寸,或者直接设置了match_parent。所以我们就不要测量了。好吧,我又一次偷了懒。
//不作处理,因为模板可知宽高一定
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
那图表的尺寸我们还是需要知道的,这是我们重写onSizeChanged方法
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mWidth = w;
mHeight = h;
}
onMeasure中系统帮我们测量尺寸,onSizeChanged在view尺寸改变时会回调这个方法,我们就拿到了宽和高。
重写onDraw方法,绘制控件
绘制图形,Canvas、Paint、Path这三个知识点一点要讲一讲。
Canvas
Canvas画布,代表了“依附”于指定View的画布,它提供了丰富的方法绘制各种图形。
- drawArc () //绘制弧
- drawBitmap() //绘制位图
- drawCircle() //绘制圆
- drawOval() //绘制椭圆
- drawLine() //绘制一条直线
- drawPoint() //绘制一个点
- drawRect() //绘制矩形
- drawRoundRect() //绘制圆角矩形
- drawPath() //沿着指定路径绘制任意图形
- drawText() //绘制文字
- drawTextOnPath //沿着指定路径绘制文字
……
观察UI给我们样图,这个图表其实就是,先计算坐标,然后相应位置处的点、线、矩形、多边形、文字。
Paint
Paint类主要用于设置绘制的风格,包括画笔颜色、画笔笔触粗细、填充风格等。
- setARGB/setColor //设置颜色
- setAlpha //设置透明度
- setAntiAlias //设置是否抗锯齿
- setPathEffect //设置绘制路径时的路径效果
- setShader //设置画笔的填充效果
- setShadowLayer //设置阴影
- setStrokeWidth //设置画笔的笔触宽度
- setStyle //设置Paint的填充风格
- setTextAlign //设置绘制文本的文字对齐方式
- setTextSize //设置绘制文本的文字大小
……
我们在画布上绘制东西时,有画笔才能绘制东西,通过画笔我们可以设置绘制的颜色、线的宽度、透明度、文字大小等。
Path
Path路径,将N个点连城一条路径,通过Canvas的drawPath(path,paint)方法沿着路径绘制图形,可以是封闭的也可以是不封闭的路径。PathEffect有一个子类DashPathEffect,我们用它设置给path.setPathEffect()绘制虚线。
有了上面的知识,图表就可以拆分为点、线、虚线、文字、折现、多边形等进行绘制,绘制时计算好正确坐标、设置相应的Paint属性,就可以绘制出图表了。
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (needCalculatePoint){
calculatePoint(); //根据实际数据计算相应的坐标值
}
drawXAxis(canvas);//绘制x坐标轴
drawDashedLines(canvas);//绘制虚线
drawXAxisLabel(canvas);//绘制x坐标轴上的文字
drawYAxisLabel(canvas);//绘制y坐标轴上的文字
if (mPoints.isEmpty()){
isAniming = false;
mProgress = 0;
return;
}
drawLine(canvas);//绘制点和点之间的连线
drawShadow(canvas);//绘制多边形阴影
drawChartPoints(canvas);//绘制数据点
if (pointIsSelected){
drawMakerLine(canvas);//当点击点时,绘制点的垂直标线
drawMaker(canvas);//当点击点时,绘制点旁边的矩形介绍框maker
}
if (isAniming){
mProgress += intervalProgress;
if (mProgress >= mPoints.size()) isAniming = false;
if (onChartAnimatorListener != null){
onChartAnimatorListener.onAnimFinished();
}
postInvalidateDelayed(intervalTime);
}
}
我们把图表拆成几部分内容进行分别绘制:x坐标轴、x坐标轴上的文字、y坐标轴上的文字、虚线、点和点之间的连线、多边形阴影、数据点、垂直标线、矩形介绍框maker。这些部分的绘制流程都是相同的:
- 设置画笔Paint风格
- 设置计算坐标
- canvas绘图
抽几个例子来讲,一、绘制x坐标轴
//绘制x坐标轴
private void drawXAxis(Canvas canvas) {
//设置画笔风格
mPaint.reset();
mPaint.setColor(xAxisColor);
mPaint.setAntiAlias(true);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(dpToPx(mDm, xAxisWidth));//dp换算成px
//计算坐标点并绘制x坐标轴直线
canvas.drawLine(originX, originY, originX + xUnit * xItemNum, originY, mPaint);
int startX = (int) (originX + 0.5 * xUnit);
int childLineHeight = dpToPx(mDm, xAxisChildLineHeight);
for (int i = 0; i < xItemNum; i++){
canvas.drawLine(startX + i * xUnit, originY, startX + i * xUnit, originY + childLineHeight, mPaint);//绘制x坐标轴向下的小锤线
}
}
二、绘制x轴文字
//绘制x轴坐标文字
private void drawXAxisLabel(Canvas canvas) {
//设置画笔风格
mPaint.reset();
mPaint.setColor(axisTextColor);
mPaint.setTextSize(spToPx(mDm, axisTextSize));
mPaint.setAntiAlias(true);
Rect textRect = new Rect();
int childLineHeight = dpToPx(mDm, xAxisChildLineHeight);
int gap = dpToPx(mDm, xAxisGap);
int size = xLabels.size();
for (int i = 0; i < size; i++){
String label = xLabels.get(i);
if (TextUtils.isEmpty(label)){
break;
}
//获取文字的宽和高的矩形框
mPaint.getTextBounds(label, 0, label.length(), textRect);
//计算文字的左边和底边坐标值
int x = (int) (originX + (i + 0.5) * xUnit - textRect.width()/2);
int y = originY + childLineHeight + gap + textRect.height();
//绘制文字
canvas.drawText(label, x, y, mPaint);
}
}
三、绘制多边形阴影
//绘制阴影
private void drawShadow(Canvas canvas) {
int floor = (int) Math.floor(mProgress);
if (floor == 0 || mPoints.size() < 2){
return;
}
//设置画笔风格
mPaint.reset();
mPaint.setColor(shadowColor);
mPaint.setStyle(Paint.Style.FILL);
mPaint.setAntiAlias(true);
Path path = new Path();
ChartPoint firstP = mPoints.get(0);
path.moveTo(firstP.getX(), firstP.getY());
//连接各个数据点的坐标
for (int i = 1; i < floor; i++) {
ChartPoint p = mPoints.get(i);
path.lineTo(p.getX(), p.getY());
}
path.lineTo(lineHeadPoint.getX(), lineHeadPoint.getY());
path.lineTo(lineHeadPoint.getX(), originY);
path.lineTo((float) (originX + 0.5 * xUnit), originY);
//多边形曲线
path.close();
//绘制多边形
canvas.drawPath(path, mPaint);
}
动画的实现原理
随着时间的变化,移动多边形阴影右边竖线的坐标,根据坐标绘制左侧应该显示数据点连线,绘制右侧点,右侧点的半径大小随着坐标离最近的左侧坐标的距离(这个距离下图x)变化而变化。
右边闪烁点的绘制,mProgress - ceil就是x
//绘制闪烁的点
private void drawFlashPoint(Canvas canvas, int ceil) {
ChartPoint flashP = mPoints.get(ceil - 1);
if (isAniming){
//绘制闪烁点
//函数y = |cos(pi * (mProgress - ceil) * 5/2)| 动画实现的关键
double flashParam = Math.abs(Math.cos(Math.PI * (mProgress - Math.floor(mProgress)) * 5 / 2));
canvas.drawCircle(flashP.getX(), flashP.getY(), (float) (maxPointRadius * flashParam), mPaint);
}else {
//绘制最后一个点
canvas.drawCircle(flashP.getX(), flashP.getY(), dpToPx(mDm, pointRadius), mPaint);
}
}
函数y = |cos(pi * (mProgress - ceil) * 5/2)|
重写onTouchEvent方法,处理点击事件
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()){
//处理手指按下的事件
case MotionEvent.ACTION_DOWN:
if (isAniming){
return true;
}
//检查是否点到了数据点上了
selectedPointId = findPointIdNearbyLocation(event.getX(), event.getY());
if (selectedPointId != -1){//如果是,请求重绘界面,绘制maker
pointIsSelected = true;
invalidate();
}
break;
case MotionEvent.ACTION_MOVE://不处理滑动事件
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL://当手指抬起或者画出view时,发送延时消息,隐藏maker
if (pointIsSelected){
mHandler.removeMessages(0x123);
//清空上一次的消息
mHandler.sendEmptyMessageDelayed(0x123, showMakerTime);
}
break;
}
return true;
}
//在所给位置附近找到最近的图表中的点, 范围0-size -1代表没找到
private int findPointIdNearbyLocation(float x, float y) {
if (mPoints.isEmpty() || x < originX || x > originX + xItemNum * xUnit){
return -1;
}
double id = (x - originX) / xUnit - 0.5;
int floor = (int) Math.floor(id);
int ceil = (int) Math.ceil(id);
if (floor >= 0 && floor < mPoints.size()){
ChartPoint p = mPoints.get(floor);
double interval = Math.pow(x - p.getX(), 2) + Math.pow(y - p.getY(), 2) - 30 * 30;
if (interval < 0){
return floor;
}
}
if (ceil >= 0 && ceil < mPoints.size()){
ChartPoint p = mPoints.get(ceil);
double interval = Math.pow(x - p.getX(), 2) + Math.pow(y - p.getY(), 2) - 30 * 30;
if (interval < 0){
return ceil;
}
}
return -1;
}
通过上面5个步骤就可以自定控件了,图表的控件,计算坐标是难点,每个人都已自己的思考方式,可能大家也没读懂,其实这个不要紧,熟悉整个自定义流程就可以了。