Android 动画实战

Posted 阎楠

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android 动画实战相关的知识,希望对你有一定的参考价值。

前言

通过之前的《Android 动画总结》,对常用的android动画有了一个整体认识。但是,之前的内容都是概念性的,所列的demo也没有实际意义。这里就通过两个实例了解一下如何在 实际开发中运用Android 动画来实现一些良好的用户体验。

这里通过展示两个常见且较为容易实现的动画效果:

仿支付宝支付完成动画
购物车添加商品动画

动画实战

仿支付宝支付完成动画

首先看一下效果图。

模拟器截取动画真是醉了

支付成功动画

关于这个支付成功的动画,通过之前所说的帧动画(Frame Animation)是可以实现的,但前提是需要完善的图片资源。如果UI 没有提供图片资源,那是否就束手无策了呢?其实不然,对于这种构图比较简单的动画,还是可以通过属性动画实现的。

观察一下这个动画,首先绘制一个圆形,圆形完成的同时绘制“对号”,动画完成的瞬间再执行变色和整个view缩放的效果,同时修改button上的文字

那么我们的动画实现也是按照这个顺序:

public void loadCircle(int mRadius) 
        mRadius = mRadius <= 0 ? DEFAULT_RADIUS : mRadius;
        this.mRadius = mRadius - PADDING;
        if (null != mAnimatorSet && mAnimatorSet.isRunning()) 
            return;
        
        reset();
        reMeasure();
        Log.e("left", "R is -------->" + mRadius);
        mCircleAnim = ValueAnimator.ofInt(0, 360);
        mLineLeftAnimator = ValueAnimator.ofFloat(0, this.mRadius / 2f);
        mLineRightAnimator = ValueAnimator.ofFloat(0, this.mRadius / 2f);
        Log.i(TAG, "mRadius" + mRadius);
        mCircleAnim.setDuration(700);
        mLineLeftAnimator.setDuration(350);
        mLineRightAnimator.setDuration(350);
        mCircleAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() 
            @Override
            public void onAnimationUpdate(ValueAnimator animation) 
                mDegree = (Integer) animation.getAnimatedValue();
                invalidate();
            
        );
        mLineLeftAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() 
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) 
                mLeftValue = (Float) valueAnimator.getAnimatedValue();
                Log.e("left", "-------->" + mLeftValue);
                invalidate();
            
        );
        mLineRightAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() 
            @Override
            public void onAnimationUpdate(ValueAnimator animation) 
                mRightValue = (Float) animation.getAnimatedValue();
                invalidate();
            
        );
        mAnimatorSet.play(mCircleAnim).before(mLineLeftAnimator);
        mAnimatorSet.play(mLineRightAnimator).after(mLineLeftAnimator);
        mAnimatorSet.addListener(new AnimatorListenerAdapter() 
            @Override
            public void onAnimationEnd(Animator animation) 
                stop();
                if (mEndListner != null) 
                    mEndListner.onCircleDone();
                    SuccessAnim();
                

            
        );
        mAnimatorSet.start();
    

我们定义了mCircleAnim,mLineLeftAnimator和mLineRightAnimator 三个属性动画,并依次播放三个动画,同时在各自的update方法中获取动画当前的变化值,同时调用invalidate() ,这样就会不断执行onDraw 方法,不断绘制新的视图,产生动画效果。而在动画执行结束的时候,可以执行接口中定义的监听动画结束的方法,这里这么做是为了方便在Activity中执行一些动画结束后的操作。同时执行了当前view 大小缩放的动画SuccessAnim()。

这里重点看一下onDraw方法,这个方法可以说是实现整个动画最核心的内容。

protected void onDraw(Canvas canvas) 
        super.onDraw(canvas);
        mRectF.left = mCenterX - mRadius;
        mRectF.top = mCenterY - mRadius;
        mRectF.right = mCenterX + mRadius;
        mRectF.bottom = mCenterY + mRadius;
        canvas.drawArc(mRectF, 0, mDegree, false, mCirclePanit);
        canvas.drawLine(mCenterX - mRadius / 2, mCenterY,
                mCenterX - mRadius / 2 + mLeftValue, mCenterY + mLeftValue, mLinePaint);
        canvas.drawLine(mCenterX, mCenterY + mRadius / 2,
                mCenterX + mRightValue, mCenterY + mRadius / 2 - (3f / 2f) * mRightValue, mLinePaint);

    

1.第7行canvas.drawArc 的实现很容易理解,我们在之前的属性动画中,实现一个初始值为0,结束值为360 的ValueAnimator,同时在其执行的过程中,不断将中间值赋给mDegree,这样mDegree值就从0变化到360,从而实现了一个圆形绘制。

2.第8行中绘制的是”对号“中左边的短线。第10行绘制的是右边向上的长线。这里的思路结合下图很容易理解(这只是一个示意图,实际绘制时右边长线的斜率由圆心、半径多个值所决定)。

两点确定一条直线,就是这么简单。

之前说过, 属性动画的运行机制是通过不断地对值进行操作来实现的,而初始值和结束值之间的动画过渡就是由ValueAnimator这个类来负责计算的。

这里我们就是利用这个原理实现了这个动画。

理解了这点,下面支付失败的动画,也是相似的原理,中间绘制的内容不再是一个“对号”,而是一个巨大的X。这个很容易实现,以圆心为坐标轴中点,在四个象限绘制45度方向绘制四个点,分别作为起始点和终点即可,结合代码很容易理解。

        int mViewWidth = getWidth();
        int mViewHeight = getHeight();
        mCenterX = mViewWidth / 2;
        mCenterY = mViewHeight / 2;

        temp = mRadius / 2.0f * factor;
        Path path = new Path();
        path.moveTo(mCenterX - temp, mCenterY - temp);
        path.lineTo(mCenterX + temp, mCenterY + temp);
        pathLeftMeasure = new PathMeasure(path, false);

        path = new Path();
        path.moveTo(mCenterX + temp, mCenterY - temp);
        path.lineTo(mCenterX - temp, mCenterY + temp);
        pathRightMeasure = new PathMeasure(path, false);

绘制方法onDraw

    protected void onDraw(Canvas canvas) 
        super.onDraw(canvas);
        mRectF.left = mCenterX - mRadius;
        mRectF.top = mCenterY - mRadius;
        mRectF.right = mCenterX + mRadius;
        mRectF.bottom = mCenterY + mRadius;
        canvas.drawArc(mRectF, 0, mDegree, false, mCirclePanit);
        if (mLeftPos[1] > (mCenterY - temp) && mRightPos[1] > (mCenterY - temp)) 
            canvas.drawLine(mCenterX - temp, mCenterY - temp, mLeftPos[0], mLeftPos[1], mLinePaint);
            canvas.drawLine(mCenterX + temp, mCenterY - temp, mRightPos[0], mRightPos[1], mLinePaint);
        
    

这里的mLeftPos和mRightPos,就是属性动画由初始值过渡到结束值时,中间变化值所对应的位置。具体可结合源码理解。

最后再说一下,使用帧动画的方式实现这个动画,为了适配不同的机型,必然需要多份不同分辨率的图片,适配效果不得而知,同时也会增加应用的大小。但是使用帧动画就不同了,把握好整个view的大小,适配起来应该相对会容易一些。同时应用大小也不会变化,同时可扩展性也更高。

购物车添加商品动画

购物添加动画可以说是,属性动画最经典的例子;很早以前就有人实现了。这里就从学习属性动画的角度出发加以理解。

这里轨迹的绘制并不完全是靠属性动画完成,很大一部分的功劳要算在贝塞尔曲线的身上。关于贝塞尔曲线的理解,可以看看这里

private void addToCarAnimation(ImageView goodsImg) 
        //获取需要进行动画的ImageView
        final ImageView animImg = new ImageView(mContext);
        animImg.setImageDrawable(goodsImg.getDrawable());
        RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(100, 100);
        shellLayout.addView(animImg, params);
        //
        final int shellLocation[] = new int[2];
        shellLayout.getLocationInWindow(shellLocation);
        int animImgLocation[] = new int[2];
        goodsImg.getLocationInWindow(animImgLocation);
        int carLocation[] = new int[2];
        carImage.getLocationInWindow(carLocation);
        //
        // 起始点:图片起始点-父布局起始点+该商品图片的一半-图片的marginTop || marginLeft 的值
        float startX = animImgLocation[0] - shellLocation[0] + goodsImg.getWidth() / 2 - DpConvert.dip2px(mContext, 10.0f);
        float startY = animImgLocation[1] - shellLocation[1] + goodsImg.getHeight() / 2 - DpConvert.dip2px(mContext, 10.0f);

        // 商品掉落后的终点坐标:购物车起始点-父布局起始点+购物车图片的1/5
        float endX = carLocation[0] - shellLocation[0] + carImage.getWidth() / 5;
        float endY = carLocation[1] - shellLocation[1];

        //控制点,控制贝塞尔曲线
        float ctrlX = (startX + endX) / 2;
        float ctrlY = startY - 100;

        Log.e("num", "-------->" + ctrlX + " " + startY + " " + ctrlY + " " + endY);

        Path path = new Path();
        path.moveTo(startX, startY);
        // 使用二阶贝塞尔曲线
        path.quadTo(ctrlX, ctrlY, endX, endY);
        mPathMeasure = new PathMeasure(path, false);

        ObjectAnimator scaleXanim = ObjectAnimator.ofFloat(animImg, "scaleX", 1, 0.5f, 0.2f);
        ObjectAnimator scaleYanim = ObjectAnimator.ofFloat(animImg, "scaleY", 1, 0.5f, 0.2f);

        ValueAnimator valueAnimator = ValueAnimator.ofFloat(0, mPathMeasure.getLength());
        valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() 
            @Override
            public void onAnimationUpdate(ValueAnimator animation) 
                // 这里这个值是中间过程中的曲线长度(下面根据这个值来得出中间点的坐标值)
                float value = (Float) animation.getAnimatedValue();
                // 获取当前点坐标封装到mCurrentPosition
                // mCurrentPosition此时就是中间距离点的坐标值
                mPathMeasure.getPosTan(value, mCurrentPosition, null);
                // 移动的商品图片(动画图片)的坐标设置为该中间点的坐标
                animImg.setTranslationX(mCurrentPosition[0]);
                animImg.setTranslationY(mCurrentPosition[1]);
            
        );

        valueAnimator.addListener(new AnimatorListenerAdapter() 
            @Override
            public void onAnimationEnd(Animator animation) 
                super.onAnimationEnd(animation);
                goodsCount++;
                if (goodsCount < 100) 
                    carCount.setText(String.valueOf(goodsCount));
                 else 
                    carCount.setText("99+");
                

                // 把执行动画的商品图片从父布局中移除
                shellLayout.removeView(animImg);
                shopCarAnim();

            
        );

        AnimatorSet animatorSet = new AnimatorSet();
        animatorSet.setDuration(500);
        animatorSet.setInterpolator(new AccelerateInterpolator());
        animatorSet.playTogether(scaleXanim, scaleYanim, valueAnimator);
        animatorSet.start();

    

我们分别获取了整个布局在手机屏幕中的位置: shellLocation
所要进行动画的图片在手机屏幕中的位置:animLocation
购物车在整个手机屏幕中的位置:carLocation

并由这三个值及动画图片的大小布局等因素确定了三个点:

起始位置(startX,startY)、结束位置(endX,endY)和控制点(CtrlX,CtrlY)。
并由这三个点确定了一个二阶贝塞尔曲线 path。

同时使用PathMeasure 类测量这条path,同时使用它的长度length 作为属性动画中的终点值。

ValueAnimator valueAnimator = ValueAnimator.ofFloat(0, mPathMeasure.getLength());

在动画的update回调方法中,我们获取这个长度过渡变化的中间值,然后我们使用了一个很重要的方法

mPathMeasure.getPosTan(value, mCurrentPosition, null);

可以看一下,这个方法的具体实现

/**
     * Pins distance to 0 <= distance <= getLength(), and then computes the
     * corresponding position and tangent. Returns false if there is no path,
     * or a zero-length path was specified, in which case position and tangent
     * are unchanged.
     *
     * @param distance The distance along the current contour to sample
     * @param pos If not null, eturns the sampled position (x==[0], y==[1])
     * @param tan If not null, returns the sampled tangent (x==[0], y==[1])
     * @return false if there was no path associated with this measure object
    */
    public boolean getPosTan(float distance, float pos[], float tan[]) 
        if (pos != null && pos.length < 2 ||
            tan != null && tan.length < 2) 
            throw new ArrayIndexOutOfBoundsException();
        
        return native_getPosTan(native_instance, distance, pos, tan);
    

这个方法有三个参数

  1. 第一个参数,MeasuePath 所测量的path的长度的当前值,也就是我们动画变化中的过渡值。

  2. 第二个参数是个数组,如果不为null,就被赋予当前值所对应位置的坐标。

  3. 第三个参数也是数组,如果不为null,就被赋予当前值所对应的切线坐标。(这个没搞懂神马意思)

如果这个MeasurePath所测量的path不存在,就会返回false。

最终这个方法会执行一个native方法,具体实现我们就不得而知了。

回到我们的代码,这里我们第二参数,传入了一个二维的int 数组,这样随着path总长度的流逝,我们就依次获取了这条path线路上的坐标点mCurrentPosition。然后通过设置动画图片animImg 的位置就实现了动画效果。

这里重点说了一下整体的实现思路,实际中还有很多细节值得考虑,尤其是在切换为GridView模式的时候,动画起点在左右两边是有差异的,具体细节可参考源码自己思考。

总结

看到这里可以发现,ValueAnimator 这个类虽然很简单,但是非常有用。他帮我们实现了一种属性值从开始到结束的自然过渡,而且可以获取到过渡过程的中间值,这样就很方便我们结合这个过渡值做各种各样的动画了。

最后 github 源码欢迎star & fork


以上是关于Android 动画实战的主要内容,如果未能解决你的问题,请参考以下文章

Android 动画实战

Android 动画实战-仿微博雷达功能

android MD 过度动画/共享元素,登陆实战

把商品添加到购物车的动画效果(贝塞尔曲线)

把商品添加到购物车的动画效果(贝塞尔曲线)

Android项目实战:ViewPager切换动画(3.0版本以上有效果)