Android 动画实战

前言##

通过之前的《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


最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,520评论 25 707
  • 前言## 在应用中使用动画,可以给用户带来良好的交互体验。通过之前对Android动画的分类总结,尝试了使用属性动...
    IAM四十二阅读 1,289评论 0 21
  • 在iOS中随处都可以看到绚丽的动画效果,实现这些动画的过程并不复杂,今天将带大家一窥ios动画全貌。在这里你可以看...
    每天刷两次牙阅读 8,465评论 6 30
  • 转学后的凡,第一次段考,成绩极不好,甚至说很低的分数。这个分数和名次,深深刺痛了我,以至于情绪失控,几次忍不住咆...
    素月1阅读 286评论 2 3
  • 圣诞假的时候,我又去了一次台湾。 因为不想去太冷的地方,从11月开始,上海就开始了无穷无尽漫长的冬季。 也没办法去...
    Couch阅读 566评论 0 51