【Android開源專案解析】仿支付寶付款成功及"天女散花"效果實現——看PathMeasure大展身手...

weixin_34075551發表於2015-09-21

話說,在前面兩篇文章中,我們學習了BitmapShader、Path的基本使用,那麼這一篇文章,我們們接著來學習一下PathMeasure的用法。什麼,你沒聽說過PathMeasure?那你就要OUT咯~

專案效果圖

廢話不多說,在開始講解之前,先看下最終實現的效果。

效果一:

仿支付寶支付成功效果

效果二:

這兩個專案都是使用Path和PathMeature配合完成的,由其他專案改造而來

專案一是七叔寫的,我對程式碼進行了大量改造。

專案二是不小心搜到的,然後進行了改造,原文請戳這裡

本文程式碼請到這裡下載

PathMeasure介紹

PathMeasure這個類確實是不太常見的,關於這個類的介紹也是甚少,那麼這個類是用來幹嘛的呢?主要其實是配合Path,來計算Path裡面點的座標的,或者是給一個範圍,來擷取Path其中的一部分的。

這麼說,你肯定也迷糊,我們們先簡單看一下有哪些方法,然後根據案例來進行講解更好一些。

構造方法有兩個,很好理解,不多解釋。

PathMeasure()
PathMeasure(Path path, boolean forceClosed)

重點看下常用方法:

  • float getLength() 返回當前contour(解釋為輪廓不太恰當,我覺得更像是筆畫)的長度,也就是這一個Path有多長
  • boolean getPosTan(float distance, float[] pos, float[] tan) 傳入一個距離distance(0<=distance<=getLength()),然後會計算當前距離的座標點和切線,注意,pos會自動填充上座標,這個方法很重要
  • boolean getSegment(float startD, float stopD, Path dst, boolean startWithMoveTo) 傳入一個開始和結束距離,然後會返回介於這之間的Path,在這裡就是dst,他會被填充上內容,這個方法很重要
  • boolean nextContour() 移動到下一個筆畫,如果你的Path是由多個筆畫組成的話,那麼就可以使用這個方法
  • void setPath(Path path, boolean forceClosed)這個方法也比較重要,用來設定新的Path物件的,算是對第一個建構函式的一個補充

仿支付寶實現原理解析

下面,我將介紹一下如何實現下面的這個效果

首先分析需求:

  • 需要有三種狀態:載入中,成功,失敗
  • 載入中時,需要不斷更換顏色
  • 載入中狀態時,圓弧要不斷的變換長度和位置
  • 成功狀態和失敗狀態,需要把√和×一筆一劃的畫出來

OK,基本就是這些需求,那麼對應著需求,我們們看一下解決方案

  • 有三種狀態好說,用靜態常量或者是列舉型別進行區分
  • 不斷變換顏色也好說,只要改變Paint的顏色就可以啦
  • 不斷的變化長度和位置,從效果圖上可以看出來,我們需要畫一段圓弧,那就要用下面的drawArc(),需要知道範圍,起始角度和繪製角度,由於需要不斷的變化長度,因此就需要用Animator,具體實現一會詳談
Canvas.drawArc(RectF oval, float startAngle, float sweepAngle, boolean useCenter,Paint paint) 
  • 需要畫出來形狀,其實就是一些線段,那麼就需要用Path了,但是如何能一筆一劃的效果呢?那就要靠PathMeasure啦

下面開始講解程式碼實現,最好參照著原始碼看下面的文章。

首先看怎麼用ConfirmView呢?很簡單,只需要呼叫animatedWithState()然後傳入一個列舉型別即可

confirmView.animatedWithState(ConfirmView.State.Progressing);

這個列舉型別在類的內部,代表三種狀態

public enum State {
        Success, Fail, Progressing
    }

再看建構函式,很簡單,只是進行了變數的初始化,這些變數的具體作用,我將在下面用到的時候重點介紹

public ConfirmView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);

        mSuccessPath = new Path();
        mPathMeasure = new PathMeasure(mSuccessPath, false);
        mRenderPaths = new ArrayList<>();

        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setColor(0xFF0099CC);
        mPaint.setStrokeWidth(STROKEN_WIDTH);
        mPaint.setStrokeCap(Paint.Cap.ROUND);

        oval = new RectF();
    }

那麼呼叫了animatedWithState()之後,進行了什麼操作呢?

public void animatedWithState(State state) {

        if (mCurrentState != state) {
            mCurrentState = state;
            if (mPhareAnimator != null && mPhareAnimator.isRunning()) {
                stopPhareAnimation();
            }
            switch (state) {
                case Fail:
                case Success:
                    updatePath();
                    if (mCircleAnimator != null && mCircleAnimator.isRunning()) {
                        mCircleAngle = (Float) mCircleAnimator.getAnimatedValue();
                        mCircleAnimator.end();
                    }

                    if ((mStartAngleAnimator == null || !mStartAngleAnimator.isRunning() || !mStartAngleAnimator.isStarted()) &&
                            (mEndAngleAnimator == null || !mEndAngleAnimator.isRunning() || !mEndAngleAnimator.isStarted())) {
                        mStartAngle = 360;
                        mEndAngle = 0;
                        startPhareAnimation();
                    }

                    break;
                case Progressing:
                    mCircleAngle = 0;
                    startCircleAnimation();
                    break;
            }
        }

    }

結合著上面的程式碼,我簡單解釋一下。

首先進行重複性的判斷,如果當前所處的狀態與要改變的狀態相同則不進行操作。

接下來,對動畫狀態進行了判斷,mPhareAnimator是用來實現√和×的動畫繪製效果的,如果正在執行,則停掉。

再往下的一個switch則是開始真正的操作了,updatePath()是更新Path,一會重點看下,mCircleAnimator這個則是實現外部弧形的偏移量的控制的,現在看不明白也沒事,重點看下下面的程式碼,當mStartAngleAnimator和mEndAngleAnimator都不在執行狀態的時候(這兩個Animator是為了控制外部弧形的起點和終點的),會進入下面的程式碼,

mStartAngle = 360;
mEndAngle = 0;
startPhareAnimation();

mStartAngle和mEndAngle分別代表起點轉過的角度和終點轉過的角度,然後就startPhareAnimation(),這個時候,真正的繪製√和×的動畫才開始執行。

如果是Progressing呢,則執行下面的程式碼,重置mCircleAngle,startCircleAnimation()這個方法是繪製外部的弧形的動畫

mCircleAngle = 0;
startCircleAnimation();

至此,我們們知道了傳入不同狀態的列舉型別會進行什麼操作,下面,開始看真正的操作。

我們先看一個簡單的,就是startCircleAnimation()到底做了什麼。

前面說過,這個方法是為了繪製載入中狀態時,外部不斷變化的彩色弧形的,下面是程式碼實現

public void startCircleAnimation() {
        if (mCircleAnimator == null || mStartAngleAnimator == null || mEndAngleAnimator == null) {
            initAngleAnimation();
        }
        mStartAngleAnimator.setDuration(NORMAL_ANGLE_ANIMATION_DURATION);
    mEndAngleAnimator.setDuration(NORMAL_ANGLE_ANIMATION_DURATION);
        mCircleAnimator.setDuration(NORMAL_CIRCLE_ANIMATION_DURATION);
        mStartAngleAnimator.start();
        mEndAngleAnimator.start();
        mCircleAnimator.start();
    }

首先前面的if語句是為空判斷,從而進行初始化的操作,後面則是簡單的設定動畫的持續時間和開啟動畫。這裡一共出現了三個動畫,完成外部弧形的效果控制

  • mStartAngleAnimator 控制圓弧起點
  • mEndAngleAnimator 控制圓弧終點
  • mCircleAnimator 控制圓弧的整體偏移量

這麼說,你可能還是不很明白,沒關係,我們們一點點的看程式碼,首先,我們們看在初始化的時候,到底做了什麼操作,也就是initAngleAnimation()。

 private void initAngleAnimation() {

        mStartAngleAnimator = ValueAnimator.ofFloat(0.0F, 1.0F);
        mEndAngleAnimator = ValueAnimator.ofFloat(0.0F, 1.0F);
        mCircleAnimator = ValueAnimator.ofFloat(0.0F, 1.0F);

        mStartAngleAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                float value = (Float) animation.getAnimatedValue();
                setStartAngle(value);
            }
        });
        mEndAngleAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                float value = (Float) animation.getAnimatedValue();
                setEndAngle(value);
            }
        });

        mStartAngleAnimator.addListener(new Animator.AnimatorListener() {
            @Override
            public void onAnimationStart(Animator animation) {

                if (mCurrentState == State.Progressing) {
                    if (mEndAngleAnimator != null) {
                        new Handler().postDelayed(new Runnable() {
                            @Override
                            public void run() {
                                mEndAngleAnimator.start();
                            }
                        }, 400L);
                    }
                }
            }

            @Override
            public void onAnimationEnd(Animator animation) {
                if (mCurrentState != State.Progressing && mEndAngleAnimator != null && !mEndAngleAnimator.isRunning() && !mEndAngleAnimator.isStarted()) {
                    startPhareAnimation();
                }
            }

            @Override
            public void onAnimationCancel(Animator animation) {
            }

            @Override
            public void onAnimationRepeat(Animator animation) {
            }
        });

        mEndAngleAnimator.addListener(new Animator.AnimatorListener() {
            @Override
            public void onAnimationStart(Animator animation) {

            }

            @Override
            public void onAnimationEnd(Animator animation) {
                if (mStartAngleAnimator != null) {
                    if (mCurrentState != State.Progressing) {
                        mStartAngleAnimator.setDuration(NORMAL_ANIMATION_DURATION);
                    }
                    colorCursor++;
                    if (colorCursor >= colors.length) colorCursor = 0;
                    mPaint.setColor(colors[colorCursor]);
                    mStartAngleAnimator.start();
                }
            }

            @Override
            public void onAnimationCancel(Animator animation) {
            }

            @Override
            public void onAnimationRepeat(Animator animation) {
            }
        });

        mCircleAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                float value = (float) animation.getAnimatedValue();
                setCircleAngle(value);
            }
        });


        mStartAngleAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
        mEndAngleAnimator.setInterpolator(new AccelerateDecelerateInterpolator());


        mCircleAnimator.setInterpolator(new LinearInterpolator());
        mCircleAnimator.setRepeatCount(-1);
    }

這段程式碼雖然長,但是也沒有太大的難度,無非就是進行了初始化操作,ValueAnimator的範圍是0-1,這個在後面將用於計算角度。在值不斷的更新的過程中,分別呼叫了下面這三個方法,更新一些值

setStartAngle(value);
setEndAngle(value);
setCircleAngle(value);

在這三個方法裡面,都對成員變數進行了更新,並且!呼叫了invalidate()!看到這裡是不是激動了,改變一次就重繪一次,這三個值肯定和弧形的動畫效果有關啊!

   private void setStartAngle(float startAngle) {
        this.mStartAngle = startAngle;
        invalidate();
    }

    private void setEndAngle(float endAngle) {
        this.mEndAngle = endAngle;
        invalidate();
    }

    private void setCircleAngle(float circleAngle) {
        this.mCircleAngle = circleAngle;
        invalidate();
    }

我們知道了這個,先不著急去看onDraw(),仔細看下動畫的執行順序。

在mStartAngleAnimator執行之後,呼叫了下面的方法,這當然很簡單,就是說,mStartAngleAnimator執行了400毫秒之後,mEndAngleAnimator才會執行,而且插值器設定的是AccelerateDecelerateInterpolator,為啥呢?很簡單,因為只有這樣,才能做出弧形長度先長後短的效果呀~

new Handler().postDelayed(new Runnable() {
       @Override
        public void run() {
            mEndAngleAnimator.start();
        }
       }, 400L);

而在mEndAngleAnimator執行結束之後,會呼叫下面的程式碼

if (mStartAngleAnimator != null) {
    if (mCurrentState != State.Progressing) {
        mStartAngleAnimator.setDuration(NORMAL_ANIMATION_DURATION);
    }
    colorCursor++;
    if (colorCursor >= colors.length) colorCursor = 0;
    mPaint.setColor(colors[colorCursor]);
    mStartAngleAnimator.start();
}

在這個設定mStartAngleAnimator的動畫時間,是為了畫√或者是×的時候快一些效果更流暢。下面的程式碼很簡單了吧,改變畫筆顏色,然後mStartAngleAnimator又開啟啦!這就是為啥一直轉啊轉的原因。

但是說到這裡,我們們還沒看onDraw()做了什麼呢!

 @Override
    public void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        switch (mCurrentState) {
            case Fail:
                for (int i = 0; i < PATH_SIZE_TWO; i++) {
                    Path p = mRenderPaths.get(i);
                    if (p != null) {
                        canvas.drawPath(p, mPaint);
                    }
                }
                drawCircle(canvas);
                break;
            case Success:
                Path p = mRenderPaths.get(0);
                if (p != null) {
                    canvas.drawPath(p, mPaint);
                }
                drawCircle(canvas);
                break;
            case Progressing:
                drawCircle(canvas);
                break;
        }

    }

我們先看Progressing分支裡面的drawCircle(canvas),其他的先不要管

private void drawCircle(Canvas canvas) {
        float offsetAngle = mCircleAngle * 360;
        float startAngle = mEndAngle * 360;
        float sweepAngle = mStartAngle * 360;

        if (startAngle == 360)
            startAngle = 0;
        sweepAngle = sweepAngle - startAngle;
        startAngle += offsetAngle;

        if (sweepAngle < 0)
            sweepAngle = 1;

        canvas.drawArc(oval, startAngle, sweepAngle, false, mPaint);
    }

是的,上面這段程式碼就是繪製不斷變幻的環的程式碼咯

float startAngle = mEndAngle * 360;是計算終點的位置,有人會感到奇怪,為啥終點的位置叫startAngle啊!因為終點的位置就是開始繪製的位置,所以不要奇怪了。

sweepAngle = sweepAngle - startAngle;則是計算要畫多少角度的弧線,因為起點先跑到前面的,所以減去終點的位置,就是旋轉角度。

startAngle += offsetAngle;那麼這句是幹嘛的?這個就是所謂的偏移量,為了要實現更隨性的從非固定點開始結束的效果。沒聽懂?我給你去掉你看下效果!


    private void drawCircle(Canvas canvas) {
        float offsetAngle = mCircleAngle * 360;
        float startAngle = mEndAngle * 360;
        float sweepAngle = mStartAngle * 360;

        if (startAngle == 360)
            startAngle = 0;
        sweepAngle = sweepAngle - startAngle;
//        startAngle += offsetAngle;

        if (sweepAngle < 0)
            sweepAngle = 1;

        canvas.drawArc(oval, startAngle, sweepAngle, false, mPaint);
    }

這下子明白了吧,去掉漂移量效果就沒有之前那麼隨性了~


ok,關於弧線的問題就說這麼多,下面就要說我們們今天的主角PathMeasure了。

在前面的程式碼中,我們提到,成功和失敗狀態會執行updatePath()和startPhareAnimation(),那麼到底做了些什麼呢?

private void updatePath() {

        int offset = (int) (mSignRadius * 0.15F);
        mRenderPaths.clear();

        switch (mCurrentState) {
            case Success:
                mSuccessPath.reset();
                mSuccessPath.moveTo(mCenterX - mSignRadius, mCenterY + offset);
                mSuccessPath.lineTo(mCenterX - offset, mCenterY + mSignRadius - offset);
                mSuccessPath.lineTo(mCenterX + mSignRadius, mCenterY - mSignRadius + offset);
                mRenderPaths.add(new Path());
                break;
            case Fail:
                mSuccessPath.reset();
                float failRadius = mSignRadius * 0.8F;
                mSuccessPath.moveTo(mCenterX - failRadius, mCenterY - failRadius);
                mSuccessPath.lineTo(mCenterX + failRadius, mCenterY + failRadius);
                mSuccessPath.moveTo(mCenterX + failRadius, mCenterY - failRadius);
                mSuccessPath.lineTo(mCenterX - failRadius, mCenterY + failRadius);
                for (int i = 0; i < PATH_SIZE_TWO; i++) {
                    mRenderPaths.add(new Path());
                }
                break;
            default:
                mSuccessPath.reset();
        }

        mPathMeasure.setPath(mSuccessPath, false);

    }

在updatePath()我們可以很清楚的看到,在這裡初始化了mSuccessPath,通過moveTo()和lineTo()�首先勾勒除了√和×的形狀,至於這個座標是怎麼確定的,這個可以自己想法來,我就不介紹了。還要需要注意的是,Success中最後在mRenderPaths中新增了一個Path物件,而在Fail則新增了兩個物件,這個其實是和要繪製的圖形的筆畫數有關的,×是兩筆,所以是兩個,這裡新增的Path議會將用來紀錄每一筆畫的形狀。

最後,我們們的主角終於現身了

 mPathMeasure.setPath(mSuccessPath, false);

呼叫完這個方法,會馬上呼叫下面的方法

public void startPhareAnimation() {
        if (mPhareAnimator == null) {
            mPhareAnimator = ValueAnimator.ofFloat(0.0F, 1.0F);
            mPhareAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                @Override
                public void onAnimationUpdate(ValueAnimator animation) {
                    float value = (Float) animation.getAnimatedValue();
                    setPhare(value);
                }
            });

            mPhareAnimator.setDuration(NORMAL_ANIMATION_DURATION);
            mPhareAnimator.setInterpolator(new LinearInterpolator());
        }
        mPhare = 0;
        mPhareAnimator.start();
    }

其實也很簡單,初始化了mPhareAnimator,然後開啟動畫,不斷呼叫setPhare(value),

private void setPhare(float phare) {
        mPhare = phare;
        updatePhare();
        invalidate();
    }

在這裡updatePhare(),然後重繪介面,那麼玄機應該都在updatePhare()了吧!

private void updatePhare() {

        if (mSuccessPath != null) {
            switch (mCurrentState) {
                case Success: {
                    if (mPathMeasure.getSegment(0, mPhare * mPathMeasure.getLength(), mRenderPaths.get(0), true)) {
                        mRenderPaths.get(0).rLineTo(0, 0);
                    }
                }
                break;
                case Fail: {
                    //i = 0,畫一半,i=1,畫另一半
                    float seg = 1.0F / PATH_SIZE_TWO;

                    for (int i = 0; i < PATH_SIZE_TWO; i++) {
                        float offset = mPhare - seg * i;
                        offset = offset < 0 ? 0 : offset;
                        offset *= PATH_SIZE_TWO;
                        Log.d("i:" + i + ",seg:" + seg, "offset:" + offset + ", mPhare:" + mPhare + ", size:" + PATH_SIZE_TWO);
                        boolean success = mPathMeasure.getSegment(0, offset * mPathMeasure.getLength(), mRenderPaths.get(i), true);

                        if (success) {
                            mRenderPaths.get(i).rLineTo(0, 0);
                        }
                        mPathMeasure.nextContour();
                    }
                    mPathMeasure.setPath(mSuccessPath, false);
                }
                break;
            }
        }
    }

在這裡,一個很重要的方法呼叫了,那就是mPathMeasure.getSegment()

當Success的時候,會執行下面的程式碼。mPhare就是動畫的百分比,從0到1,那麼,下面的這段程式碼就很好理解了,這是為了根據動畫的百分比,獲取畫出√的整個Path的一部分,然後把這部分,填充到了mRenderPaths.get(0)裡面,這裡面存放的就是在上面方法中新增進去的一個Path物件。mPhare不斷的變化,我們就能獲取到畫整個√形狀所需的所有Path物件,還記得這個方法之後是什麼嗎?invalidate()!所以,現在在onDraw()裡面肯定用這Path物件,畫出√的一部分,不斷的更新從mPhare,不斷繪製,從無到有,而出現了動畫效果。

mRenderPaths.get(0).rLineTo(0, 0);這個程式碼則是為了在4.4以下不能繪製出圖形BUG的解決方法,不要在意。

if (mPathMeasure.getSegment(0, mPhare * mPathMeasure.getLength(), mRenderPaths.get(0), true)) {
     mRenderPaths.get(0).rLineTo(0, 0);
}

不信我們們看下onDraw(),是不是!那麼現在你應該知道×是怎麼畫出來的吧?

case Success:
Path p = mRenderPaths.get(0);
if (p != null) {
    canvas.drawPath(p, mPaint);
}
drawCircle(canvas);
break;

來來來,我們們看下程式碼!

 case Fail: {
   //i = 0,畫一半,i=1,畫另一半
     float seg = 1.0F / PATH_SIZE_TWO;
     for (int i = 0; i < PATH_SIZE_TWO; i++) {
         float offset = mPhare - seg * i;
         offset = offset < 0 ? 0 : offset;
         offset *= PATH_SIZE_TWO;
         Log.d("i:" + i + ",seg:" + seg, "offset:" + offset + ", mPhare:" + mPhare + ", size:" + PATH_SIZE_TWO);
         boolean success = mPathMeasure.getSegment(0, offset * mPathMeasure.getLength(), mRenderPaths.get(i), true);

         if (success) {
             mRenderPaths.get(i).rLineTo(0, 0);
         }

         mPathMeasure.nextContour();
     }
     mPathMeasure.setPath(mSuccessPath, false);
 }
 break;

與繪製√相比,因為×是兩筆,所以有些小複雜,但是也不難,offset *= PATH_SIZE_TWO;是為了保證在mPhare從0-0.5過程中控制第一筆畫,0.5-1則控制第二條筆畫,你仔細看下程式碼,這樣可以實現offset從0-1兩次。由於×是兩筆畫,所以在i=0取到第一筆畫的Path部分,儲存在mRenderPaths的第一個Path之後,呼叫了mPathMeasure.nextContour();切換到下一筆畫,再次完成相同的操作。

而由於PathMeasure只能往下找Contour,所以最後 mPathMeasure.setPath(mSuccessPath, false);回覆到最初狀態,然後我們看下onDraw()

  for (int i = 0; i < PATH_SIZE_TWO; i++) {
                    Path p = mRenderPaths.get(i);
                    if (p != null) {
                        canvas.drawPath(p, mPaint);
                    }
                }
                drawCircle(canvas);

其實和Success差不多的,只不過是兩個Path,畫出兩筆。

OK,到這裡,這個效果就算是全部實現了,累死我了

"天女散花"實現效果解析

其實這個我並不打算詳細講,因為一通百通,多說無益,更多的東西需要你自己研究程式碼吸收,我們們就重點看下PathMeasure的用法。

其實這種效果實現的真相是這樣滴

YES!就是一些Bitmap物件沿著Path路徑移動!

那麼和PathMeasure有啥關係呢?

看下onDraw()!

 @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        drawFllower(canvas, fllowers1);
        drawFllower(canvas, fllowers2);
        drawFllower(canvas, fllowers3);
    }

OK,再看下drawFllower()

 private void drawFllower(Canvas canvas, List<Fllower> fllowers) {
        for (Fllower fllower : fllowers) {
            float[] pos = new float[2];
            canvas.drawPath(fllower.getPath(), mPaint);
            pathMeasure.setPath(fllower.getPath(), false);
            pathMeasure.getPosTan(height * fllower.getValue(), pos, null);
            canvas.drawBitmap(mBitmap, pos[0], pos[1] - top, null);
        }
    }

首先,遍歷一個Fllower集合,然後把每個Fllower所屬的Path畫出來,就是上面藍色的曲線,然後很眼熟了吧,給PathMeasure設定Path物件,然後呢,就是重點啦!height是螢幕的高度,fllower.getValue()也是一個百分比,從0-1,和前面的Animator作用相同,這句程式碼就是說,我要距離為height * fllower.getValue()處的點的座標,給我放在pos裡面!

好了,點的座標都有了,剩下的還需要說麼...

不行了,再不回家,就真回不去了,拜拜,同學們

更多參考資料

尊重原創,轉載請註明:From 凱子哥(http://blog.csdn.net/zhaokaiqiang1992) 侵權必究!

相關文章