Android中你不得不知道的动画知识 (二)

达芬奇密码2018-06-27 13:59
ObjectAnimator

ObjectAnimator是ValueAnimator的子类,相比起ValueAnimation,它是可以直接对任意对象的任意属性进行动画操作的,比如说View的alpha属性。我们看一个透明度的小实例:

ObjectAnimator animator = ObjectAnimator.ofFloat(textview, "alpha", 1f, 0f, 1f);  
animator.setDuration(5000);  
animator.start();

可以看到,我们还是调用了ofFloat()方法来去创建一个ObjectAnimator的实例,只不过ofFloat()方法当中接收的参数有点变化了。这里第一个参数要求传入一个object对象,我们想要对哪个对象进行动画操作就传入什么,这里我传入了一个textview。第二个参数是想要对该对象的哪个属性进行动画操作,由于我们想要改变TextView的不透明度,因此这里传入"alpha"。后面的参数就是不固定长度了,想要完成什么样的动画就传入什么值,这里传入的值就表示将TextView从常规变换成全透明,再从全透明变换成常规。之后调用setDuration()方法来设置动画的时长,然后调用start()方法启动动画,效果如下图所示:

如果要将一个TextView旋转,可以这样:

ObjectAnimator animator = ObjectAnimator.ofFloat(textview, "rotation", 0f, 360f);  
animator.setDuration(5000);  
animator.start();

再比如模拟实现一个跑马灯效果:

float curTranslationX = textview.getTranslationX();  
ObjectAnimator animator = ObjectAnimator.ofFloat(textview, "translationX", curTranslationX, 1500f);  
animator.setDuration(5000);  
animator.start();

其实ObjectAnimation的工作原理很简单,我们拿透明度变化的那个动画来看:

ObjectAnimator animator = ObjectAnimator.ofFloat(textview, "alpha", 1f, 0f, 1f);  
animator.setDuration(5000);  
animator.start();

// ObjectAnimator会不断的去修改`textview`的alpha的值,并且在修改后将View标记为invalidate,
// 等到屏幕下次再次绘制View的时候将会根据新的alpha值去绘制View
// 这样View的透明度就会不断的变化了,也就实现了动画效果。
AnimatorSet

与AnimationSet相对应,属性动画也提供了一个组合动画管理类。独立的动画能够实现的视觉效果毕竟是相当有限的,因此将多个动画组合到一起播放就显得尤为重要。AnimatorSet提供了一套非常丰富的API来让我们将多个动画组合到一起。

实现组合动画功能主要需要借助AnimatorSet这个类,这个类提供了一个play()方法,如果我们向这个方法中传入一个Animator对象(ValueAnimator或ObjectAnimator)将会返回一个AnimatorSet.Builder的实例,AnimatorSet.Builder中包括以下四个方法:

  • after(Animator anim) 将现有动画插入到传入的动画之后执行
  • after(long delay) 将现有动画延迟指定毫秒后执行
  • before(Animator anim) 将现有动画插入到传入的动画之前执行
  • with(Animator anim) 将现有动画和传入的动画同时执行

比如我们可以将我们上面的例子组合一下:

ObjectAnimator moveIn = ObjectAnimator.ofFloat(textview, "translationX", -500f, 0f);  
ObjectAnimator rotate = ObjectAnimator.ofFloat(textview, "rotation", 0f, 360f);  
ObjectAnimator fadeInOut = ObjectAnimator.ofFloat(textview, "alpha", 1f, 0f, 1f);  
AnimatorSet animSet = new AnimatorSet();  
animSet.play(rotate).with(fadeInOut).after(moveIn);  
animSet.setDuration(5000);  
animSet.start();
AnimatorListener

在很多时候,我们希望可以监听到动画的各种事件,比如动画何时开始,何时结束,然后在开始或者结束的时候去执行一些逻辑处理。为了方便开发,Google为我们提供了AnimatorListener来监听动画的状态:

anim.addListener(new AnimatorListener() {  
    @Override  
    public void onAnimationStart(Animator animation) {  
        // 动画开始执行
        // do something
    }  

    @Override  
    public void onAnimationRepeat(Animator animation) {  
        // 动画重复的时候
        // do something
    }  

    @Override  
    public void onAnimationEnd(Animator animation) {  
        // 动画结束
        // do something
    }  

    @Override  
    public void onAnimationCancel(Animator animation) {
        // 动画取消
        // do something
    }  
});

以上就是Animator的简单使用,一般来说已经可以满足我们绝大多数的动画需求了。但是对于一个复杂绚丽变化不是很规律的动画实现起来可能会比较复杂,Airbnb公司开源了一个Lottie工具很好地解决的这个问题,我们继续往下看。

Lottie动画库

简介

Lottie is a mobile library for Android and iOS that parses Adobe After Effects animations exported as json with Bodymovin and renders them natively on mobile! For the first time, designers can create and ship beautiful animations without an engineer painstakingly recreating it by hand.

大体意思就是,通过Lottie,可以将Bodymovin这个AE插件导出的动画Json文件在Android和ios上面解析并渲染在屏幕上面。Lottie第一次实现了视觉设计师设计动画后就可以直接使用了而不需要开发人员再次开发。

对于开发者来说,这真是一个天大的喜讯啊,再也不需要也视觉争论实现难度(你现在没有机会争论了),讲什么开发成本了。直接将Json丢给你,然后就可以在设备上面实现动画效果了,是不是很酷!那就让我们来看一下怎么用吧~

使用方法

  1. 首先第一个就是要为我们的项目添加依赖:

Gradle is the only supported build configuration, so just add the dependency to your project build.gradle file:

目前官方仅支持gradle方式。

dependencies {
    ...
    compile 'com.airbnb.android:lottie:2.2.5'
    ...
}
  1. 在Layout里面添加LottieAnimationView

<com.airbnb.lottie.LottieAnimationView
        android:id="@+id/animation_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:lottie_fileName="hello-world.json"
        app:lottie_loop="true"
        app:lottie_autoPlay="true" />

注意:Lottie现在支持的最低版本是level 14,LottieAnimationView继承自ImageView

  • lottie_fileName:是指需要加载的json动画文件
  • app:lottie_loop="true":动画是否需要循环播放
  • app:lottie_autoPlay="true":动画是否需要自动播放

  1. 在Java代码中执行动画

LottieAnimationView animationView = (LottieAnimationView) findViewById(R.id.animation_view);
animationView.setAnimation("hello-world.json");
animationView.loop(true);
animationView.playAnimation();

这个方法会在后台加载并解析动画,解析完成后将会开始并渲染动画。这样的AE里面的动画就会很神奇的运行在你的设备上了。

Lottie还提供了一些其他的东西:

如果想要加载一个网络Json资源可以这样:

LottieAnimationView animationView = (LottieAnimationView) findViewById(R.id.animation_view);
 ...
 Cancellable compositionCancellable = LottieComposition.Factory.fromJson(getResources(), jsonObject, (composition) -> {
     animationView.setComposition(composition);
     animationView.playAnimation();
 });

 // Cancel to stop asynchronous loading of composition
 // compositionCancellable.cancel();

还可以通过下面的方法控制监听动画的状态:

animationView.addAnimatorUpdateListener((animation) -> {
    // Do something.
});
animationView.playAnimation();
...
if (animationView.isAnimating()) {
    // Do something.
}
...
animationView.setProgress(0.5f);
...
// Custom animation speed or duration.
ValueAnimator animator = ValueAnimator.ofFloat(0f, 1f)
    .setDuration(500);
animator.addUpdateListener(animation -> {
    animationView.setProgress(animation.getAnimatedValue());
});
animator.start();
...
animationView.cancelAnimation();

项目实际效果实践

近期项目中有几个动画效果要实现,结合上面所讲的内容,一起来看一个例子:

思路分析

首先点赞手势的那个粒子效果是通过Lottie来实现的,所以我们使用上面讲到的Lottie去加载json文件就好了。至于下面的按钮的波纹效果就需要我们自己去实现了。所以可以将这个动画简单的拆分成两部分,一个是点赞手势的粒子效果,一个是按钮的博文效果。

效果实现

  1. 由于粒子效果的扩散,所以这个点赞动画的View有200px*200px这么大,比这个Button的高度还要大。所以我们首先要解决这个button和动画的显示问题。重叠显示两个View只能使用FrameLayout来实现了。按照View的绘制规则,只要将动画视图放在按钮的下面,那么动画就会悬浮显示在button上面了。根据这个思路,我们可以将布局修改成这样:

<FrameLayout
        android:layout_gravity="center_horizontal"
        android:layout_width="180dp"
        android:layout_height="100dp">

        <com.netease.snailread.view.WaterRippleButton
            android:id="@+id/bt_like"
            android:gravity="center_vertical|right"
            android:textSize="16sp"
            android:text="@string/activity_bookreview_detail_like"
            android:textColor="@color/color_ffffff"
            android:layout_gravity="center"
            android:background="@drawable/selector_like_btn_bg"
            android:layout_width="180dp"
            android:layout_height="55dp" />

        <ImageView
            android:id="@+id/iv_good"
            android:layout_marginLeft="39dp"
            android:layout_gravity="center_vertical"
            android:src="@drawable/like_ic_big_outline"
            android:elevation="10dp"
            android:translationZ="10dp"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" />

        <com.airbnb.lottie.LottieAnimationView
            android:id="@+id/iv_like"
            android:elevation="12dp"
            android:translationZ="12dp"
            android:layout_width="100dp"
            android:layout_height="100dp" />

    </FrameLayout>

在4.4的模拟器上运行了一下是没有问题的,但是当我在6.0的模拟器上运行时却出现了奇怪的一幕:Button居然是遮盖住动画的!这就很奇怪了,难道6.0修改了View的绘制顺序?但是跟踪了一个源代码,发现和4.4并没有什么不一样的,无奈只能Google了一下,发现原来在android 5.0之后引入了材料设计的概念,View有了Z轴的概念,而button默认的Z轴数值是2,而这个数值也会影响绘制的结果,所有导致了这个问题。解决办法就是让动画比Button还要高,这样就不会被遮盖了。

android:elevation="12dp"
android:translationZ="12dp"
  1. 解决了这个问题,我们再来实现一下波纹效果。这个波纹效果其实就是在背景上面不断绘制一个更大的圆,最终填充满整个Button就可以了。思路是比较简单的。唯一需要我们解决的问题就是这个Button是一个圆角形状的,但是你的背景绘制的时候都是在矩形的canvas上绘制的。我们可以在绘制的时候先将canvas裁切成一个圆角矩形的形状,然后绘制的时候就不需要考虑边界问题了。

那么问题又来了,怎么裁剪这个canvas呢?我采用的办法是用Path一段一段的将这个形状描绘出来,利用canvas的clipPath方法来剪裁。

if (mPath == null) {
    mPath = new Path();
    int width = getWidth();
    int height = getHeight();
    int radius = height / 2;
    mPath.addCircle(radius, radius, radius, Path.Direction.CCW);
    mPath.addCircle(width - radius, radius, radius, Path.Direction.CCW);
    RectF rectF = new RectF(radius, 0, width - radius, height);
    mPath.addRect(rectF, Path.Direction.CCW);
}
    canvas.save();
    canvas.clipPath(mPath);
    canvas.drawCircle(getWidth() / 2, getHeight() / 2, mRadius, mPaint);
    canvas.restore();

解决了这个问题,剩下的也比较简单了,获取好绘制的圆的圆心,通过ObjectAnimator不断修改这个圆的半径,最终绘制成一个覆盖全部按钮的大的背景即可。

贴一下完整代码:

package com.netease.snailread.view;

import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RectF;
import android.support.annotation.Keep;
import android.util.AttributeSet;

import com.netease.snailread.R;
import com.netease.snailread.skin.SkinManager;

/**
 * description:
 *
 * @author jimbo zhongjinbao1994@gmail.com
 * @since 2017/10/18 下午2:35
 */
public class WaterRippleButton extends android.support.v7.widget.AppCompatButton {

    private int mDrawRadiues;

    private int mRadius;

    private boolean mIsNeedAnimation = false;

    private Paint mPaint;
    private Path mPath;

    public WaterRippleButton(Context context) {
        super(context);
        init();
    }

    public WaterRippleButton(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public WaterRippleButton(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        mPaint = new Paint();
        mPaint.setColor(getResources().getColor(R.color.color_e2e2e2));
        mPaint.setStyle(Paint.Style.FILL);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        mDrawRadiues = getMeasuredWidth() > getMeasuredHeight() ?
                getMeasuredWidth() / 2 : getMeasuredHeight() / 2;
    }

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);
    }

    @Override
    protected void onDraw(Canvas canvas) {

        if (mIsNeedAnimation) {
            if (mPath == null) {
                mPath = new Path();
                int width = getWidth();
                int height = getHeight();
                int radius = height / 2;
                mPath.addCircle(radius, radius, radius, Path.Direction.CCW);
                mPath.addCircle(width - radius, radius, radius, Path.Direction.CCW);
                RectF rectF = new RectF(radius, 0, width - radius, height);
                mPath.addRect(rectF, Path.Direction.CCW);
            }
            canvas.save();
            canvas.clipPath(mPath);
            canvas.drawCircle(getWidth() / 2, getHeight() / 2, mRadius, mPaint);
            canvas.restore();
        }

        super.onDraw(canvas);
    }

    @Keep
    public void setMRadius(int i) {
        mRadius = i;
        invalidate();
    }

    public void startWaterRipple() {

        ObjectAnimator animator = ObjectAnimator.ofInt(this, "mRadius",
                100, mDrawRadiues);
        animator.setDuration(300);

        animator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                super.onAnimationEnd(animation);
                mIsNeedAnimation = false;
                setBackgroundDrawable(getResources().getDrawable(R.drawable.like_btn_bg));
            }

            @Override
            public void onAnimationStart(Animator animation) {
                super.onAnimationStart(animation);
                mIsNeedAnimation = true;
                setBackgroundDrawable(SkinManager.getResourceManager().getPluginDrawable("liked_btn_bg_normal"));
            }
        });

        animator.start();

    }
}

结束语

经过上面这些总结学习以及项目的实践,对于动画的理解更深了一步。对于任何动画来说,无非都是View的属性改变后重新绘制出现的效果而已,只要找到动画适当的描述语言,实现起来其实也没有那么的复杂。这个月的学习就到这里,Keep Moving~

相关阅读:Android中你不得不知道的动画知识 (一)

本文来自网易实践者社区,经作者钟金宝授权发布。