本来是使用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 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,大致就这么多。怎么样?应该不难理解吧,只要把那些需要计算的搞定就够了。
还是那句话,此篇博客仅仅是提供了一个思路,还有很多需要优化的地方,比如进入页面时,内存会陡增,又瞬间减下去,还不知道该怎么优化……等有空再考虑吧,也希望有大神能够指点一二,谢谢。