自定義圖表,附帶星星閃爍動畫

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

先看公司給的UI設(shè)計(jì):


9CCF48D3-14AC-4B00-BDE1-A69A8DC58DC6.png

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

linechart.gif

DEMO代碼地址:https://github.com/jinxiyang/LineChart

自定義控件

自定義控件,有好幾種方式。本次我們就繼承View實(shí)現(xiàn)一個(gè)這樣的圖表。讓我們回顧一下,繼承View自定義控件的步驟:

  1. 自定義View的屬性
  2. 在View的構(gòu)造方法中獲取我們定義的屬性值
  3. 重寫onMeasure方法,測量View尺寸
  4. 重寫onDraw方法,繪制控件
  5. 重寫onTouchEvent方法,處理點(diǎn)擊事件

下面開始自定義我們的圖表。

分析圖表所需要的屬性

繪制圖表時(shí),我們要知道圖表各個(gè)元素的樣式,分析一下總共有:

  1. 圖表中文字的顏色、大小
  2. 各種線條的顏色、寬度
  3. 陰影的顏色、透明度
  4. 點(diǎn)的顏色、半徑
  5. 標(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)變化而變化。

123.png

右邊閃爍點(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)|

7670550A1295E4FD7F0EB80AB24FBAB8.jpeg

重寫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è)自定義流程就可以了。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 178,872評論 25 709
  • 一、概述 1. 四線格與基線 小時(shí)候,我們在剛開始學(xué)習(xí)寫字母時(shí),用的本子是四線格的,我們必須把字母按照規(guī)則寫在四線...
    addapp閱讀 8,029評論 2 17
  • 一、Android開發(fā)初體驗(yàn) 二、Android與MVC設(shè)計(jì)模式模型對象存儲著應(yīng)用的數(shù)據(jù)和業(yè)務(wù)邏輯。模型類通常用來...
    為夢想戰(zhàn)斗閱讀 1,065評論 0 3
  • 我走上荊州古城墻, 刺骨的寒風(fēng)吹來歷史的蒼涼。 茂盛的古樹下, 旅客們的游車鈴聲回蕩, 播送出時(shí)間在悠閑中的空曠。...
    曹煥甫閱讀 1,124評論 0 2
  • 綠綠青葉板栗果、見證數(shù)年風(fēng)雨雪;五年堅(jiān)守為一栗、刺向未來心華麗!
    神于天圣于地閱讀 221評論 0 0

友情鏈接更多精彩內(nèi)容