先看效果圖
接着是源碼下載地址
本來是使用MPAndroidChartLib,但是有一個業務上的bug,後來就想着自己練練手自己寫一個。
因爲以前沒有真正自定義過涉及到onDraw()的控件,所以目前來看,這個控件的可擴展性還是很差的,留作以後有時間再做吧,主要就是抽象出來一些操作來實現其他樣式或者功能的操作。
水平有限,在此只是幫助一些初學者提供一些思路上的幫助吧
這個View沒什麼複雜的地方,就是比較麻煩,主要流程有:
1、先畫出表格中X軸和Y軸和表格裏的虛線;
2、計算Y軸的刻度
3、遍歷傳入的數據列表繪製各個點的位置到Path中,數據線和陰影區域的區別就是:線只需要畫到最後一個點就行了,而陰影則需要添加兩個點,形成閉合區域。
4、動畫的實現。
5、手勢操作。
View的具體實現步驟和方法:
自定義屬性(如果需要)
定義一個同名的declare-styleable。
<declare-styleable name="CashChart">
<attr name="line_color" format="color"/>
<attr name="shadow_color" format="color"/>
<attr name="y_axis_rows" format="integer"/>
</declare-styleable>
獲取自定義屬性
public CashChart(Context context) {
this(context, null);
}
public CashChart(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public CashChart(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
this.mContext = context;
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.CashChart);
mLineColor = ta.getColor(R.styleable.CashChart_line_color, LINE_COLOR_DEFAULT);
mShadowColor = ta.getColor(R.styleable.CashChart_shadow_color, SHADOW_COLOR_DEFAULT);
mLineWidth = ta.getDimension(R.styleable.CashChart_line_width, LINE_WIDTH_DEFAULT);
mYAxisRows = ta.getInteger(R.styleable.CashChart_y_axis_rows, CHART_Y_AXIS_ROWS_DEFAULT);
ta.recycle();
init();
}
接着在init()方法中,初始化各種UI值、Paint、動畫,在這裏我爲每個操作都定義了一個的畫筆。動畫後面再說。
接着有一個setData(List< ChartBean > data)方法,在設置後,計算出來最大最小值和差值,
private Paint mLinePaint = new Paint(Paint.ANTI_ALIAS_FLAG);//畫線用的
private Paint mLineEffectPaint = new Paint(Paint.ANTI_ALIAS_FLAG);//畫虛線用的
private Paint mChartScaleTxtPaint = new Paint(Paint.ANTI_ALIAS_FLAG);//表格刻度文本
private Paint mCurPointPaint = new Paint(Paint.ANTI_ALIAS_FLAG);//選中的點
private Paint mBubbleTxtPaint = new Paint(Paint.ANTI_ALIAS_FLAG);//選中的點的文本Paint
private Paint mShadowPaint = new Paint(Paint.ANTI_ALIAS_FLAG);//陰影區域的畫筆
public void setData(List<ChartBean> data) {
if (data == null) return;
this.mDatas = data;
//找出數據列表中最大值和最小值,來確定表格Y軸上的數值
float[] values = new float[mDatas.size()];
for (int i = 0; i < mDatas.size(); i++) {
if (mDatas.get(i) == null) continue;
values[i] = mDatas.get(i).getValue();
}
// 這裏使用了冒泡算法
float[] sortValues = bubbleSort(values);
mMinValue = sortValues[0];
mMaxValue = sortValues[sortValues.length - 1];
float offsetValue = (mMaxValue - mMinValue) / mYAxisRows;
mPerValueY = (offsetValue < 0.02f) ? (offsetValue + 0.02f) : offsetValue;
mCurrentSelectedIndex = data.size() - 1;
//Y軸刻度的最大值,比數據中的最大值還大一個單位
mMaxValueYAxis = mMaxValue + mPerValueY;
//Y軸刻度的最小值,比數據中的最大值還小一個單位
mMinValueYAxis = mMinValue - mPerValueY;
reset();
}
// 該方法主要是爲了動態設置數據時,清空一下圖表上的數據
private void reset() {
mPointsPath.reset();
mPointsShadowPath.reset();
mPathDst.reset();
mAnimAlphaPercent = 0;
invalidate();
mLineValueAnimator.start();
}
接下來就是繪製onDraw()了,哦,還有一個確定表格的寬高問題:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//這裏可以重新設置View的寬高
setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.getSize(widthMeasureSpec) * 2 / 3);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
//這裏獲取的是表格真正的寬高
mWidth = w;
mHeight = h;
}
拿到寬高,和UI給定的一系列的padding值,差不多就可以確定表格原點的位置了。接着進入onDraw()方法:
@Override
protected void onDraw(Canvas canvas) {
mChartHeight = mHeight - dp2px(34);//表格高度 = View的高度 - 底部文本區域的高度
// 表格寬度 = View的寬度 - 右邊距14dp - 表格X軸起點座標
mChartWidth = mWidth - dp2px(14) - mChartAxisX;
//先畫表格基線
drawChartLine(canvas);
//再畫數據的刻度和座標點
drawChartText(canvas);
//最後再畫氣泡
drawBubble(canvas);
}
繪製應該是最繁瑣的一步,不過不用着急,慢慢分析,一步一步來,無非就是一些UI上的計算。
表格基線繪製
// 繪製表格基線
private void drawChartLine(Canvas canvas) {
int perHeight = mChartHeight / mYAxisRows;
mLineEffectPaint.setPathEffect(null);
mLineEffectPaint.setColor(mLineColor);
mLineEffectPaint.setStrokeWidth(2);
mLineEffectPaint.setColor(Color.parseColor("#e0e0e0"));
//左邊豎線
canvas.drawLine(mChartAxisX, 0, mChartAxisX, mChartHeight, mLineEffectPaint);
//底部橫線
canvas.drawLine(mChartAxisX, mChartHeight, mChartAxisX + mChartWidth, mChartHeight, mLineEffectPaint);
//中間虛線
PathEffect pathEffect = new DashPathEffect(new float[]{10, 4}, 1);
mLineEffectPaint.setPathEffect(pathEffect);
for (int i = 1; i < mYAxisRows + 1; i++) {
canvas.drawLine(mChartAxisX, mChartHeight - perHeight * i, mChartAxisX + mChartWidth, mChartHeight - perHeight * i, mLineEffectPaint);
}
}
其中有需要說明的是虛線部分的繪製,在Android3.0以後默認開啓了硬件加速,但是這個可能引起一些繪製上的問題,比如這個虛線,但是可以給當前自定義View關閉硬件加速就行了,代碼如下:
// 關閉硬件加速
@Override
public int getLayerType() {
return LAYER_TYPE_SOFTWARE;
}
表格刻度實現
代碼有點多,因爲X軸方向的數據點包括了X軸刻度、數據點、陰影區域,Y軸上倒是隻有刻度的繪製:
// 繪製表格刻度
private void drawChartText(Canvas canvas) {
if (mDatas == null || mDatas.size() == 0) {
return;
}
//跟表格基線的Paint共用
mLinePaint.setPathEffect(null);
mLinePaint.setStrokeWidth(LINE_WIDTH_DEFAULT);
mLinePaint.setColor(LINE_COLOR_DEFAULT);
mChartPerWidth = mChartWidth / (mDatas.size() - 1);
// 繪製X軸刻度 & 數據值點的位置
// 因爲X軸刻度和數據點是一致的,所以在此放在同一個for循環中
for (int i = 0; i < mDatas.size(); i++) {
ChartBean bean = mDatas.get(i);
if (i == 0) {
canvas.drawText(bean.getDate(), mChartAxisX + mChartPerWidth * i, mChartHeight + dp2px(14), mChartScaleTxtPaint);
mPointsPath.moveTo(mChartAxisX, mChartHeight - ((bean.getValue() - mMinValueYAxis) / (mMaxValueYAxis - mMinValueYAxis) * mChartHeight));
mPointsShadowPath.moveTo(mChartAxisX, mChartHeight - ((bean.getValue() - mMinValueYAxis) / (mMaxValueYAxis - mMinValueYAxis) * mChartHeight));
} else if (i == mDatas.size() - 1) {
canvas.drawLine(mChartAxisX + mChartPerWidth * i, 0, mChartAxisX + mChartPerWidth * i, mChartHeight, mLineEffectPaint);
canvas.drawText(bean.getDate(), mChartAxisX + mChartPerWidth * i - mBottomTxtWidth, mChartHeight + dp2px(14), mChartScaleTxtPaint);
mPointsPath.lineTo(mChartAxisX + mChartPerWidth * i, mChartHeight - ((bean.getValue() - mMinValueYAxis) / (mMaxValueYAxis - mMinValueYAxis) * mChartHeight));
mPointsShadowPath.lineTo(mChartAxisX + mChartPerWidth * i, mChartHeight - ((bean.getValue() - mMinValueYAxis) / (mMaxValueYAxis - mMinValueYAxis) * mChartHeight));
} else {
canvas.drawLine(mChartAxisX + mChartPerWidth * i, 0, mChartAxisX + mChartPerWidth * i, mChartHeight, mLineEffectPaint);
canvas.drawText(bean.getDate(), mChartAxisX + mChartPerWidth * i - mBottomTxtWidth / 2, mChartHeight + dp2px(14), mChartScaleTxtPaint);
mPointsPath.lineTo(mChartAxisX + mChartPerWidth * i, mChartHeight - ((bean.getValue() - mMinValueYAxis) / (mMaxValueYAxis - mMinValueYAxis) * mChartHeight));
mPointsShadowPath.lineTo(mChartAxisX + mChartPerWidth * i, mChartHeight - ((bean.getValue() - mMinValueYAxis) / (mMaxValueYAxis - mMinValueYAxis) * mChartHeight));
}
}
//繪製值線的動畫效果
mPathMeasure.setPath(mPointsPath, false);
mPathMeasure.getSegment(0, mAnimValuePercent * mPathMeasure.getLength(), mPathDst, true);
canvas.drawPath(mPathDst, mLinePaint);
//繪製陰影區域
mPointsShadowPath.lineTo(mChartAxisX + mChartWidth, mChartHeight);
mPointsShadowPath.lineTo(mChartAxisX, mChartHeight);
mShadowPaint.setAlpha((int) (mAnimAlphaPercent * 0x19));//漸隱效果
canvas.drawPath(mPointsShadowPath, mShadowPaint);
int chartPerHeight = mChartHeight / mYAxisRows;
// 繪製Y軸刻度
for (int i = 0; i < mYAxisRows + 1; i++) {
if (i == 0) {
canvas.drawText(MathUtil.getFormat4DecimalNoSeperator().format((mMaxValueYAxis - mMinValueYAxis) / mYAxisRows * (mYAxisRows - i) + mMinValueYAxis), mViewPadding, mChartScaleSize, mChartScaleTxtPaint);
} else if (i == mYAxisRows) {
canvas.drawText(MathUtil.getFormat4DecimalNoSeperator().format((mMaxValueYAxis - mMinValueYAxis) / mYAxisRows * (mYAxisRows - i) + mMinValueYAxis), mViewPadding, chartPerHeight * i, mChartScaleTxtPaint);
} else {
canvas.drawText(MathUtil.getFormat4DecimalNoSeperator().format((mMaxValueYAxis - mMinValueYAxis) / mYAxisRows * (mYAxisRows - i) + mMinValueYAxis), mViewPadding, chartPerHeight * i + mChartScaleSize / 2, mChartScaleTxtPaint);
}
}
}
動畫的實現原理
通過ValueAnimator來計算出0->1之前的數值,設置duration,這樣在指定的時間內,可以拿到一個百分比的數值。代碼如下:
// 初始化動畫
private void initAnimator() {
mLineValueAnimator = ValueAnimator.ofFloat(0, 1);
mLineValueAnimator.setDuration(1000);
mLineValueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mAnimValuePercent = (float) animation.getAnimatedValue();
// 當線條繪製結束時,開始陰影面積的繪製。
if (mAnimValuePercent == 1) {
mAlphaValueAnimator.start();
}
invalidate();
}
});
mAlphaValueAnimator = ValueAnimator.ofFloat(0, 1);
mAlphaValueAnimator.setDuration(600);
mAlphaValueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mAnimAlphaPercent = (float) animation.getAnimatedValue();
invalidate();
}
});
}
在onAnimationUpdate()
方法中不停的去調用invalidate()
實現重繪,然後在onDraw()
的方法中使用PathMeasure
類來實現部分區域的繪製,請看下面代碼,其實在繪製線條的時候,並不是使用mPointsPath
(雖然mPointsPath是完整的路徑),而是先給setPath()
到mPathMeasure
類中,然後通過PathMeasure
的getSegment
獲取到路徑的片段,並賦值給mPathDst
,最後使用mPathDst
在canvas上繪圖。
mPathMeasure.setPath(mPointsPath, false);
mPathMeasure.getSegment(0, mAnimValuePercent * mPathMeasure.getLength(), mPathDst, true);
canvas.drawPath(mPathDst, mLinePaint);
繪製完線條,再來繪製陰影區域。首先得讓陰影區域的Path完善最後的兩個點,然後調用如下代碼實現陰影的漸隱出現。
mShadowPaint.setAlpha((int) (mAnimAlphaPercent * 0x19));//漸隱效果
canvas.drawPath(mPointsShadowPath, mShadowPaint);
注:如果是靜態的線條,可以採用mPointsShadowPath.addPath(mPointsPath),現在做的是動態添加的,只能老老實實的給mPointsShadowPath 挨個兒賦值。
Y軸的刻度實現自行閱讀代碼,不再贅述。
氣泡繪製
這部分需要說的是,使用Matrix實現bitmap的旋轉控制,當然,Matrix的功能是很強大的,這裏只使用到了背景圖的旋轉,就是爲了讓氣泡的小箭頭指向所選擇的點。matrix.postScale(X, Y);
可以實現在X軸和Y軸上的反轉。還使用到drawCircle來畫圓,這裏是先畫了個橙色的圓圈,再畫一個同心圓(半徑略小,實心)。
//繪製選中的點
private void drawBubble(Canvas canvas) {
if (mDatas == null || mDatas.size() == 0) {
return;
}
ChartBean bean = mDatas.get(mCurrentSelectedIndex);
if (bean == null) return;
float curPointX = mChartAxisX + mChartPerWidth * mCurrentSelectedIndex;
float curPointY = mChartHeight - ((bean.getValue() - mMinValueYAxis) / (mMaxValueYAxis - mMinValueYAxis) * mChartHeight);
mCurPointPaint.setColor(Color.parseColor("#ff6200"));
mCurPointPaint.setStrokeWidth(dp2px(0.7f));
mCurPointPaint.setStyle(Paint.Style.STROKE);
canvas.drawCircle(curPointX, curPointY, dp2px(4.8f), mCurPointPaint);
mCurPointPaint.setStyle(Paint.Style.FILL);
mCurPointPaint.setColor(Color.WHITE);
canvas.drawCircle(curPointX, curPointY, dp2px(4.1f), mCurPointPaint);
Matrix matrix = new Matrix();
int offsetX = 0;
int offsetY = 0;
int offsetTxtY = mBubbleBitmap.getHeight();
int offsetTxtX = 0;
if (mCurrentSelectedIndex <= mDatas.size() / 2 && curPointY < mChartHeight / 2) {
// 左上
matrix.postScale(1, -1);
offsetTxtY = mBubbleBitmap.getHeight() * 3 / 4 + 4;
offsetTxtX = dp2px(5);
} else if (mCurrentSelectedIndex >= mDatas.size() / 2 && curPointY < mChartHeight / 2) {
// 右上
matrix.postRotate(180);
offsetX = -mBubbleBitmap.getWidth();
offsetTxtY = mBubbleBitmap.getHeight() * 3 / 4 + 4;
offsetTxtX = -dp2px(6) - getTextWidth(mBubbleTxtPaint, "0,0000");
} else if (mCurrentSelectedIndex <= mDatas.size() / 2 && curPointY > mChartHeight / 2) {
// 左下
offsetY = -mBubbleBitmap.getHeight();
offsetTxtY = -mBubbleBitmap.getHeight() / 2;
offsetTxtX = dp2px(5);
} else if (mCurrentSelectedIndex >= mDatas.size() / 2 && curPointY > mChartHeight / 2) {
// 右下
offsetX = -mBubbleBitmap.getWidth();
offsetY = -mBubbleBitmap.getHeight();
matrix.postScale(-1, 1);
offsetTxtY = -mBubbleBitmap.getHeight() / 2;
offsetTxtX = -dp2px(6) - getTextWidth(mBubbleTxtPaint, "0,0000");
} else {
matrix.postScale(1, -1);
}
Bitmap dstBmp = Bitmap.createBitmap(mBubbleBitmap, 0, 0, mBubbleBitmap.getWidth(), mBubbleBitmap.getHeight(), matrix, true);
canvas.drawBitmap(dstBmp, curPointX + offsetX, curPointY + offsetY, null);
canvas.drawText(bean.getValueString(), curPointX + offsetTxtX, curPointY + offsetTxtY, mBubbleTxtPaint);
}
至此,繪製方面的工作差不多完工了。
手勢操作
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_MOVE:
checkInside(event);
break;
}
return true;
}
private void checkInside(MotionEvent event) {
int x = (int) event.getX();
for (int i = 0; i < mDatas.size(); i++) {
int resultMin = mChartAxisX + mChartPerWidth * i - mChartPerWidth / 2;
int resultMax = mChartAxisX + mChartPerWidth * i + mChartPerWidth / 2;
if (x >= resultMin && x < resultMax) {
mCurrentSelectedIndex = i;
invalidate();
break;
}
}
}
在onTouchEvent()
中的MotionEvent.ACTION_DOWN & MotionEvent.ACTION_MOVE
事件中 return true;
聲明View需要做一些操作。然後就是判斷手指滑動到了哪個區域裏,見checkInside()
方法。
OK,大致就這麼多。怎麼樣?應該不難理解吧,只要把那些需要計算的搞定就夠了。
還是那句話,此篇博客僅僅是提供了一個思路,還有很多需要優化的地方,比如進入頁面時,內存會陡增,又瞬間減下去,還不知道該怎麼優化……等有空再考慮吧,也希望有大神能夠指點一二,謝謝。