Android仿余额宝实现七天年化收益率图表

先看效果图
Android仿余额宝实现七天年化收益率图表_第1张图片
接着是源码下载地址

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

你可能感兴趣的:(开发笔记)