Android輕量的線性和百分比圖表實現

Wellijohn發表於2017-09-19

一.寫這篇文章的起因

目前github上有多個關於圖表的框架,比如MPAndroidChart很好,但是很大,沒必要因為一個小的圖示讓工程專案擴大很多,另外有些輕量級的框架,但是個人感覺都很難滿足自己的需求,再者就算很好的框架,那也是別人的,只有自己動手寫起來,瞭解前前後後的坑,自己才能成長,而且在寫的過程,我們能發現更多的細節,比如繪製的時候記憶體分配的問題,Canvas直接繪製和通過Bitmap繪製等等,所以這篇文章的目的:

  • 1.是給大家提供自定義view繪製的思路
  • 2.滑動自定義view的部分割槽域怎麼實現
  • 3.path動畫繪製的實現
  • 4.熟悉canvas的api,總之能直接動手了,那就自定義view就通關了,所以就寫這篇文章主要是鼓勵大家多去實現。

二.實現出來的效果圖

線性圖示
線性圖示

三.線性圖表實現的思路:

由於螢幕的寬度有限,所以我們一屏經過計算,最好顯示的7個點,所以我們首先需要對我們的view寬度進行計算,首先拿到螢幕的寬度,然後再進行/7,得到每個間隔的寬度,然後乘以我們x的座標點的個數,其中的onMeasure的方法:

        int widthParentMeasureMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthParentMeasureSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightParentMeasureMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightParentMeasureSize = MeasureSpec.getSize(heightMeasureSpec);
        int resultWidthSize = 0;
        int resultHeightSize = 0;
        int resultWidthMode = MeasureSpec.EXACTLY;//用來對childView進行計算的
        int resultHeightMode = MeasureSpec.EXACTLY;
        int paddingWidth = getPaddingLeft() + getPaddingRight();
        int paddingHeight = getPaddingTop() + getPaddingBottom();
        ViewGroup.LayoutParams thisLp = getLayoutParams();
        switch (widthParentMeasureMode) {
            //父類不加限制給子類
            case MeasureSpec.UNSPECIFIED:
                //這個代表在佈局寫死了寬度
                if (thisLp.width > 0) {
                    resultWidthSize = thisLp.width;
                    resultWidthMode = MeasureSpec.EXACTLY;
                } else {
                    resultWidthSize = (int) (getYMaxTextWidth() + mXinterval * mXdots.length);
                    resultWidthMode = MeasureSpec.UNSPECIFIED;
                }
                break;
            case MeasureSpec.AT_MOST:
                //這個代表在佈局寫死了寬度
                if (thisLp.width > 0) {
                    resultWidthSize = thisLp.width;
                    resultWidthMode = MeasureSpec.EXACTLY;
                } else if (thisLp.width == ViewGroup.LayoutParams.MATCH_PARENT) {
                    resultWidthSize = Math.max(0, widthParentMeasureSize - paddingWidth);
                    resultWidthMode = MeasureSpec.AT_MOST;
                } else if (thisLp.width == ViewGroup.LayoutParams.WRAP_CONTENT) {
                    resultWidthSize = (int) (getYMaxTextWidth() + mXinterval * mXdots.length);
                    resultWidthMode = MeasureSpec.AT_MOST;
                }
                break;
            case MeasureSpec.EXACTLY:
                //這個代表在佈局寫死了寬度
                if (thisLp.width > 0) {
                    resultWidthSize = Math.min(widthParentMeasureSize, thisLp.width);
                    resultWidthMode = MeasureSpec.EXACTLY;
                } else if (thisLp.width == ViewGroup.LayoutParams.MATCH_PARENT) {
                    resultWidthSize = widthParentMeasureSize;
                    resultWidthMode = MeasureSpec.EXACTLY;
                } else if (thisLp.width == ViewGroup.LayoutParams.WRAP_CONTENT) {
                    resultWidthSize = (int) (getYMaxTextWidth() + mXinterval * mXdots.length);
                    resultWidthMode = MeasureSpec.AT_MOST;
                }
                break;
        }


        switch (heightParentMeasureMode) {
            //父view不加限制
            case MeasureSpec.UNSPECIFIED:
                //這個代表在佈局寫死了寬度
                if (thisLp.height > 0) {
                    resultHeightSize = thisLp.height;
                    resultHeightMode = MeasureSpec.EXACTLY;
                } else {
                    resultHeightSize = (int) (getYMaxTextHeight() + mYvisibleNum * mYinterval + getXMaxTextHeight());
                    resultHeightMode = MeasureSpec.UNSPECIFIED;
                }
                break;
            case MeasureSpec.AT_MOST:
                if (thisLp.height > 0) {
                    resultHeightSize = heightParentMeasureSize;
                    resultHeightMode = MeasureSpec.EXACTLY;
                } else if (thisLp.height == ViewGroup.LayoutParams.MATCH_PARENT) {
                    resultHeightSize = Math.max(0, heightParentMeasureSize - paddingHeight);
                    resultHeightMode = MeasureSpec.AT_MOST;
                } else if (thisLp.height == ViewGroup.LayoutParams.WRAP_CONTENT) {
                    resultHeightSize = (int) (getYMaxTextHeight() + mYvisibleNum * mYinterval + getXMaxTextHeight());
                    resultHeightMode = MeasureSpec.UNSPECIFIED;
                }
                break;
            case MeasureSpec.EXACTLY:
                //這個代表在佈局寫死了寬度
                if (thisLp.height > 0) {
                    resultHeightSize = Math.min(heightParentMeasureSize, getMeasuredWidth());
                    resultHeightMode = MeasureSpec.EXACTLY;
                } else if (thisLp.width == ViewGroup.LayoutParams.MATCH_PARENT) {
                    resultHeightSize = heightParentMeasureSize;
                    resultHeightMode = MeasureSpec.EXACTLY;
                } else if (thisLp.width == ViewGroup.LayoutParams.WRAP_CONTENT) {
                    resultHeightSize = (int) (getYMaxTextHeight() + mYvisibleNum * mYinterval + getXMaxTextHeight());
                    resultHeightMode = MeasureSpec.AT_MOST;
                }
                break;
        }

        setMeasuredDimension(MeasureSpec.makeMeasureSpec(resultWidthSize, resultWidthMode),
                MeasureSpec.makeMeasureSpec(resultHeightSize, resultHeightMode));複製程式碼

設定好了尺寸,我們就可以繪製介面,這裡我們onDraw的時候,就依次繪製橫線和豎線,在繪製橫線的時候,將Y座標的數字一起繪製上去,同理繪製豎線的時候,把x座標的數字繪製上去,折線的畫根據數字計算出座標點,然後建立一個path,首先moveTo(firstX,firstY),然後lineTo下面的點就可以了,最後繪製上path,然而這樣的話,我們在滑動的時候,會發現這個view都會跟著一起滾動了,那麼我們怎樣才能實現view的部分pinned呢?在這個時候,我們就需要先建立一個bitmap,將需要滑動的部分繪製到這個bitmap上去,然後bitmap在繪製到這個canvas上的時候,保持固定的位置就行了,好了再說就懵逼了,還是上程式碼吧:

        float tempTableLeftPadding = getYMaxTextWidth();
        if (mBitmap == null || mYNumCanvas == null) {
            mBitmap = Bitmap.createBitmap((int) (getMeasuredWidth() - getYMaxTextWidth()), getMeasuredHeight(), Bitmap.Config.ARGB_8888);
            mYNumCanvas = new Canvas(mBitmap);
        }

        mYNumCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
        mYNumCanvas.translate(mScrollPosX,0);//這段程式碼就是來實現滑動的操作

        //繪製橫線
        for (int y = 0, size = mYdots.length; y < size; y++) {
            String tempText = String.valueOf(mYdots[mYdots.length - 1 - y]);
            mYNumCanvas.drawLine(0, (float) (mYinterval * y), (float) (mXdots.length * mXinterval), (float) (mYinterval * y), mXlinePaint);
            canvas.drawText(tempText, getYMaxTextWidth() - mYNumPaint.measureText(tempText), getYMaxTextHeight() + (float) (mYinterval * y), mYNumPaint);
        }
        //繪製豎線
        for (int x = 0, size = mXdots.length; x <= size; x++) {
            mYNumCanvas.drawLine((float) (mXinterval * x), 0, (float) (mXinterval * x), (float) (mYinterval * mYvisibleNum), mXlinePaint);
            if (x >= 1) {
                String tempText = mXdots[x - 1];
                mYNumCanvas.drawText(tempText, (float) (mXinterval * x) - mYNumPaint.measureText(tempText) / 2, (float) (mYvisibleNum * mYinterval + getYMaxTextHeight()), mYNumPaint);
            }
        }

        if (isAnimationOpen)//是否需要開啟動畫繪製,這個後面會解釋實現方式
            mYNumCanvas.drawPath(mLineDrawPath, mLinePaint);
        else
            mYNumCanvas.drawPath(mLinePath, mLinePaint);
        canvas.drawBitmap(mBitmap, tempTableLeftPadding, getYMaxTextHeight() / 2, null);複製程式碼

上面的mScrollPosX是根據手勢監聽類GestureDetector來獲取的:

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (!isAnimationOpen || isDrawOver)
            return mGestureDetector.onTouchEvent(event);
        return super.onTouchEvent(event);
    }複製程式碼

然而繪製了,我們感覺還缺少了什麼,嗯,沒錯就是動畫效果,這裡我們用到通過的path繪製實現動畫的方案,就是先通過PathMeasure得到path的長度,然後根據動畫時間,通過ValueAnimator計算它在某個時刻的座標,然後重新進行繪製path路徑:

    private void startPathAnim(long duration) {
        ValueAnimator valueAnimator = ValueAnimator.ofFloat(0, mLineLength);
        valueAnimator.setDuration(duration);
        valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                float value = (Float) animation.getAnimatedValue();
                // 獲取當前點座標封裝到mCurrentPosition
                mPathMeasure.getPosTan(value, mCurrentPosition, null);
                mLineDrawPath.lineTo(mCurrentPosition[0], mCurrentPosition[1]);
                invalidate();
            }
        });
        valueAnimator.start();
    }複製程式碼

四.百分比圓形圖表實現

百分比圖示
百分比圖示

其實這個的實現,相比上一個少了很多,大多是集中在onDraw方法裡面,關鍵點是在百分比的數字,怎麼橫向顯示在扇形區域,這裡我就主要這個計算規則提出來:

 private void drawText(Canvas canvas, float sweepAngle, float startAngle, ArcVo temp) {
        float middleAngle;
        middleAngle = startAngle + sweepAngle / 2;
        float startX;
        float startY;
        float endX;
        float endY;
        String drawText = temp.getPercentInCircle() * 100 + "%";
        if (middleAngle <= 90) {
            //在第四象限
            double angle = middleAngle;
            angle = Math.toRadians(angle);
            startY = endY = (float) (Math.sin(angle) * mRaduis + mRaduis);
            endX = (float) (mRaduis + Math.cos(angle) * mRaduis);
            startX = endX - UiUtils.getTextWidth(drawText, mTextPaint);
        } else if (middleAngle <= 180) {
            //在第三象限
            double angle = 180 - middleAngle;
            angle = Math.toRadians(angle);
            startY = endY = (float) (Math.sin(angle) * mRaduis + mRaduis);
            startX = (float) (mRaduis - Math.cos(angle) * mRaduis);
            endX = startX + UiUtils.getTextWidth(drawText, mTextPaint);
        } else if (middleAngle <= 270) {
            //在第二象限
            double angle = 270 - middleAngle;
            angle = Math.toRadians(angle);
            startY = endY = (float) (mRaduis - Math.cos(angle) * mRaduis);
            startX = (float) (mRaduis - Math.sin(angle) * mRaduis);
            endX = startX + UiUtils.getTextWidth(drawText, mTextPaint);
        } else {
            //在第一象限
            double angle = 360 - middleAngle;
            angle = Math.toRadians(angle);
            startY = endY = (float) (mRaduis - Math.sin(angle) * mRaduis);
            endX = (float) (mRaduis + Math.cos(angle) * mRaduis);
            startX = endX - UiUtils.getTextWidth(drawText, mTextPaint);
        }

        mTextPath.reset();
        mTextPath.moveTo(startX, startY);
        mTextPath.lineTo(endX, endY);
        if (middleAngle > 180) {
            canvas.drawTextOnPath(drawText, mTextPath, 0, UiUtils.getTextHeight(drawText, mTextPaint), mTextPaint);
        } else {
            canvas.drawTextOnPath(drawText, mTextPath, 0, -UiUtils.getTextHeight(drawText, mTextPaint), mTextPaint);

        }
    }

     @Override
    protected void onDraw(Canvas canvas) {
        if (!canDraw()) return;
        float sweepAngle;
        float startAngle = 0;
        for (int i = 0, size = mDisArcList.size(); i < size; i++) {
            ArcVo temp = mDisArcList.get(i);
            mArcPaint.setColor(temp.getScanColor());
            sweepAngle = temp.getPercentInCircle() * 360;
            canvas.drawArc(mDrawCircleRect, startAngle, sweepAngle, true, mArcPaint);
            drawText(canvas, sweepAngle, startAngle, temp);
            startAngle = startAngle + sweepAngle;
        }
    }複製程式碼

五.使用方式:

如果你覺得你們的專案正好要用到類似的圖示,在專案的gradle檔案中,增加compile 'wellijohn.org.simplelinechart:linechart:0.0.2'具體的方法,歡迎移步到github上去看,已經封裝成庫上傳至jcenter,上面有具體的使用方法(圖表地址),目前暴露的方法不多,可以留言增加
github地址:github.com/WelliJohn/L…)
如果覺得專案對你們的自定義view有一定的啟發的話,麻煩幫忙star一下,如果有更好的實現方案,歡迎留言交流!!

相關文章