【Android开源项目解析】仿支付宝付款成功及"天女散花"效果实现——看PathMeasure大展身手

话说,在前面两篇文章中,我们学习了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 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里面!

好了,点的坐标都有了,剩下的还需要说么...

不行了,再不回家,就真回不去了,拜拜,同学们

更多参考资料

  • Path特效之PathMeasure打造万能路径动效
  • android 路径动画制作

尊重原创,转载请注明:From 凯子哥(http://blog.csdn.net/zhaokaiqiang1992) 侵权必究!

你可能感兴趣的:(【Android开源项目解析】仿支付宝付款成功及"天女散花"效果实现——看PathMeasure大展身手)