Android 动画实战

1,576 阅读8分钟
原文链接: www.jianshu.com

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

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

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

动画实战

仿支付宝支付完成动画

首先看一下效果图。


alipay

模拟器截取动画真是醉了

支付成功动画

关于这个支付成功的动画,通过之前所说的帧动画(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