最近公司項(xiàng)目需要用到圖表,拿到UI設(shè)計(jì)時(shí),感覺也不是很難。用著名的MPAndroidChart庫改改就可以了,可是領(lǐng)導(dǎo)說圖表中的點(diǎn)要一閃一閃,還要先亮前一個(gè),再亮下一個(gè)。是不是沒聽懂,我當(dāng)時(shí)也是一臉懵逼。
先看公司給的UI設(shè)計(jì):

由于MPAndroidChart沒有那個(gè)動畫效果,于是我就決定自定義一個(gè)這樣的圖表。先看看我實(shí)現(xiàn)的DEMO效果,在最后附上一張放入項(xiàng)目中的截圖。

DEMO代碼地址:https://github.com/jinxiyang/LineChart
自定義控件
自定義控件,有好幾種方式。本次我們就繼承View實(shí)現(xiàn)一個(gè)這樣的圖表。讓我們回顧一下,繼承View自定義控件的步驟:
- 自定義View的屬性
- 在View的構(gòu)造方法中獲取我們定義的屬性值
- 重寫onMeasure方法,測量View尺寸
- 重寫onDraw方法,繪制控件
- 重寫onTouchEvent方法,處理點(diǎn)擊事件
下面開始自定義我們的圖表。
分析圖表所需要的屬性
繪制圖表時(shí),我們要知道圖表各個(gè)元素的樣式,分析一下總共有:
- 圖表中文字的顏色、大小
- 各種線條的顏色、寬度
- 陰影的顏色、透明度
- 點(diǎn)的顏色、半徑
- 標(biāo)記maker的字體顏色、大小
……
//默認(rèn)的動畫脈沖間隔
private static final long DEFAULT_INTERVAL_TIME = 20;
//默認(rèn)x軸最大顯示幾項(xiàng)
private static final int DEFAULT_X_MAX_ITEM_NUM = 10;
//默認(rèn)y軸最大顯示幾格
private static final int DEFAULT_Y_MAX_ITEM_NUM = 5;
//默認(rèn)點(diǎn)的最大半徑,dp
private static final int DEFAULT_MAX_POINT_RADIUS = 6;
//坐標(biāo)軸文字的顏色
private int axisTextColor = Color.rgb(205, 137, 118);
//標(biāo)軸文字的大小,sp
private int axisTextSize = 14;
//文字和x坐標(biāo)軸之間的間距,dp
private int yAxisGap = 3;
//文字和y坐標(biāo)軸之間的間距,dp
private int xAxisGap = 3;
//x坐標(biāo)軸的顏色
private int xAxisColor = Color.rgb(205, 137, 118);
//x坐標(biāo)軸的寬度,dp
private int xAxisWidth = 2;
//x坐標(biāo)軸下面小豎線的高度,dp
private int xAxisChildLineHeight = 5;
//虛線的顏色
private int dashedLineColor = Color.argb(155, 19, 113, 187);
//虛線的寬度,dp
private int dashedLineWidth = 1;
//虛線中每段實(shí)線的寬度,dp
private int dashWidth = 5;
//虛線中實(shí)線間的間隔,dp
private int dashGap = 3;
//陰影的顏色
private int shadowColor = Color.argb(100, 177, 234, 253);
//點(diǎn)的顏色
private int pointColor = Color.argb(155, 19, 113, 187);
//點(diǎn)之間連線的顏色
private int lineColor = Color.rgb(0, 220, 255);
//點(diǎn)之間連線的寬度,dp
private int lineWidth = 2;
//懸浮maker標(biāo)記的字體顏色
private int makerTextColor = Color.rgb(255, 255, 255);
//懸浮maker標(biāo)記的字體大小,dp
private int makerTextSize = 14;
//懸浮maker標(biāo)記的背景顏色
private int makerBackgroundColor = Color.argb(155, 19, 113, 187);
//懸浮maker標(biāo)旁邊豎線的顏色
private int makerLineColor = Color.rgb(205, 137, 118);
//懸浮maker標(biāo)旁邊豎線的寬度,dp
private int makerLineWidth = 1;
//懸浮maker的padding,dp
private int makerPadding = 10;
(⊙o⊙)…,不寫圖表不知道,原來需要這么多屬性,怪不得大名鼎鼎的MPAndroidChart也沒有定義屬性,那我們也不定義了。(偷了會懶,_)。提供一些setter方法,用的時(shí)候設(shè)置一下就好了。
public void setAxisTextColor(int axisTextColor) {
this.axisTextColor = axisTextColor;
}
public void setAxisTextSize(int axisTextSize) {
this.axisTextSize = axisTextSize;
}
public void setyAxisGap(int yAxisGap) {
this.yAxisGap = yAxisGap;
}
……//省略
這些值在用的時(shí)候,我們會把他們從標(biāo)注的單位sp、dp,轉(zhuǎn)為繪制時(shí)的px
//dp轉(zhuǎn)為px
public int dpToPx(DisplayMetrics dm, int dp){
return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, dm);
}
//sp轉(zhuǎn)為px
public int spToPx(DisplayMetrics dm, int sp){
return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, dm);
}
初始化我們的構(gòu)造函數(shù)
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 動畫執(zhí)行時(shí)連線的頭的坐標(biāo)
calculatePoint() 根據(jù)提供的數(shù)據(jù)計(jì)算點(diǎn)的坐標(biāo),待會再講這個(gè)方法
重寫onMeasure方法,測量View尺寸
仔細(xì)觀察UI給的圖表,分析知道這個(gè)圖表的尺寸肯定是給定的,也就是制定了dp尺寸,或者直接設(shè)置了match_parent。所以我們就不要測量了。好吧,我又一次偷了懶。
//不作處理,因?yàn)槟0蹇芍獙捀咭欢? @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中系統(tǒng)幫我們測量尺寸,onSizeChanged在view尺寸改變時(shí)會回調(diào)這個(gè)方法,我們就拿到了寬和高。
重寫onDraw方法,繪制控件
繪制圖形,Canvas、Paint、Path這三個(gè)知識點(diǎn)一點(diǎn)要講一講。
Canvas
Canvas畫布,代表了“依附”于指定View的畫布,它提供了豐富的方法繪制各種圖形。
- drawArc () //繪制弧
- drawBitmap() //繪制位圖
- drawCircle() //繪制圓
- drawOval() //繪制橢圓
- drawLine() //繪制一條直線
- drawPoint() //繪制一個(gè)點(diǎn)
- drawRect() //繪制矩形
- drawRoundRect() //繪制圓角矩形
- drawPath() //沿著指定路徑繪制任意圖形
- drawText() //繪制文字
- drawTextOnPath //沿著指定路徑繪制文字
……
觀察UI給我們樣圖,這個(gè)圖表其實(shí)就是,先計(jì)算坐標(biāo),然后相應(yīng)位置處的點(diǎn)、線、矩形、多邊形、文字。
Paint
Paint類主要用于設(shè)置繪制的風(fēng)格,包括畫筆顏色、畫筆筆觸粗細(xì)、填充風(fēng)格等。
- setARGB/setColor //設(shè)置顏色
- setAlpha //設(shè)置透明度
- setAntiAlias //設(shè)置是否抗鋸齒
- setPathEffect //設(shè)置繪制路徑時(shí)的路徑效果
- setShader //設(shè)置畫筆的填充效果
- setShadowLayer //設(shè)置陰影
- setStrokeWidth //設(shè)置畫筆的筆觸寬度
- setStyle //設(shè)置Paint的填充風(fēng)格
- setTextAlign //設(shè)置繪制文本的文字對齊方式
- setTextSize //設(shè)置繪制文本的文字大小
……
我們在畫布上繪制東西時(shí),有畫筆才能繪制東西,通過畫筆我們可以設(shè)置繪制的顏色、線的寬度、透明度、文字大小等。
Path
Path路徑,將N個(gè)點(diǎn)連城一條路徑,通過Canvas的drawPath(path,paint)方法沿著路徑繪制圖形,可以是封閉的也可以是不封閉的路徑。PathEffect有一個(gè)子類DashPathEffect,我們用它設(shè)置給path.setPathEffect()繪制虛線。
有了上面的知識,圖表就可以拆分為點(diǎn)、線、虛線、文字、折現(xiàn)、多邊形等進(jìn)行繪制,繪制時(shí)計(jì)算好正確坐標(biāo)、設(shè)置相應(yīng)的Paint屬性,就可以繪制出圖表了。
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (needCalculatePoint){
calculatePoint(); //根據(jù)實(shí)際數(shù)據(jù)計(jì)算相應(yīng)的坐標(biāo)值
}
drawXAxis(canvas);//繪制x坐標(biāo)軸
drawDashedLines(canvas);//繪制虛線
drawXAxisLabel(canvas);//繪制x坐標(biāo)軸上的文字
drawYAxisLabel(canvas);//繪制y坐標(biāo)軸上的文字
if (mPoints.isEmpty()){
isAniming = false;
mProgress = 0;
return;
}
drawLine(canvas);//繪制點(diǎn)和點(diǎn)之間的連線
drawShadow(canvas);//繪制多邊形陰影
drawChartPoints(canvas);//繪制數(shù)據(jù)點(diǎn)
if (pointIsSelected){
drawMakerLine(canvas);//當(dāng)點(diǎn)擊點(diǎn)時(shí),繪制點(diǎn)的垂直標(biāo)線
drawMaker(canvas);//當(dāng)點(diǎn)擊點(diǎn)時(shí),繪制點(diǎn)旁邊的矩形介紹框maker
}
if (isAniming){
mProgress += intervalProgress;
if (mProgress >= mPoints.size()) isAniming = false;
if (onChartAnimatorListener != null){
onChartAnimatorListener.onAnimFinished();
}
postInvalidateDelayed(intervalTime);
}
}
我們把圖表拆成幾部分內(nèi)容進(jìn)行分別繪制:x坐標(biāo)軸、x坐標(biāo)軸上的文字、y坐標(biāo)軸上的文字、虛線、點(diǎn)和點(diǎn)之間的連線、多邊形陰影、數(shù)據(jù)點(diǎn)、垂直標(biāo)線、矩形介紹框maker。這些部分的繪制流程都是相同的:
- 設(shè)置畫筆Paint風(fēng)格
- 設(shè)置計(jì)算坐標(biāo)
- canvas繪圖
抽幾個(gè)例子來講,一、繪制x坐標(biāo)軸
//繪制x坐標(biāo)軸
private void drawXAxis(Canvas canvas) {
//設(shè)置畫筆風(fēng)格
mPaint.reset();
mPaint.setColor(xAxisColor);
mPaint.setAntiAlias(true);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(dpToPx(mDm, xAxisWidth));//dp換算成px
//計(jì)算坐標(biāo)點(diǎn)并繪制x坐標(biāo)軸直線
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坐標(biāo)軸向下的小錘線
}
}
二、繪制x軸文字
//繪制x軸坐標(biāo)文字
private void drawXAxisLabel(Canvas canvas) {
//設(shè)置畫筆風(fēng)格
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);
//計(jì)算文字的左邊和底邊坐標(biāo)值
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;
}
//設(shè)置畫筆風(fēng)格
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());
//連接各個(gè)數(shù)據(jù)點(diǎn)的坐標(biāo)
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);
}
動畫的實(shí)現(xiàn)原理
隨著時(shí)間的變化,移動多邊形陰影右邊豎線的坐標(biāo),根據(jù)坐標(biāo)繪制左側(cè)應(yīng)該顯示數(shù)據(jù)點(diǎn)連線,繪制右側(cè)點(diǎn),右側(cè)點(diǎn)的半徑大小隨著坐標(biāo)離最近的左側(cè)坐標(biāo)的距離(這個(gè)距離下圖x)變化而變化。

右邊閃爍點(diǎn)的繪制,mProgress - ceil就是x
//繪制閃爍的點(diǎn)
private void drawFlashPoint(Canvas canvas, int ceil) {
ChartPoint flashP = mPoints.get(ceil - 1);
if (isAniming){
//繪制閃爍點(diǎn)
//函數(shù)y = |cos(pi * (mProgress - ceil) * 5/2)| 動畫實(shí)現(xiàn)的關(guān)鍵
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 {
//繪制最后一個(gè)點(diǎn)
canvas.drawCircle(flashP.getX(), flashP.getY(), dpToPx(mDm, pointRadius), mPaint);
}
}
函數(shù)y = |cos(pi * (mProgress - ceil) * 5/2)|

重寫onTouchEvent方法,處理點(diǎn)擊事件
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()){
//處理手指按下的事件
case MotionEvent.ACTION_DOWN:
if (isAniming){
return true;
}
//檢查是否點(diǎn)到了數(shù)據(jù)點(diǎn)上了
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://當(dāng)手指抬起或者畫出view時(shí),發(fā)送延時(shí)消息,隱藏maker
if (pointIsSelected){
mHandler.removeMessages(0x123);
//清空上一次的消息
mHandler.sendEmptyMessageDelayed(0x123, showMakerTime);
}
break;
}
return true;
}
//在所給位置附近找到最近的圖表中的點(diǎn), 范圍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個(gè)步驟就可以自定控件了,圖表的控件,計(jì)算坐標(biāo)是難點(diǎn),每個(gè)人都已自己的思考方式,可能大家也沒讀懂,其實(shí)這個(gè)不要緊,熟悉整個(gè)自定義流程就可以了。