安卓动画特效(帧动画、补间动画、属性动画、遮罩动画及滚动器)

本章介绍App开发中常见的动画特效技术,主要包括:如何使用帧动画实现电影播放效果,如何使用补间动画实现视图的4种基本状态变化,如何使用属性动画实现视图各种状态的动态变换效果,以及如何借助绘图层次与滚动器实现动画效果。

帧动画

本节介绍帧动画相关的技术实现,内容包括如何通过动画图形与宿主视图播放帧动画,播放动图的解决思路与技术方案,如何使用过度图形实现两幅图片之间的淡入、淡出动画。注意:本章中关于图形、图片和图像术语的使用,它们的使用不是随意的。Drawable类用图形来描述,Image类对应的是图像,图片则是兼顾上面两种通用说法,源于Picture。

帧动画的实现

Android的动画分为三大类:帧动画、补间动画和属性动画。其中,帧动画是实现原理最简单的一种,跟现实生活中的电影胶卷类似,都是在短时间内连续播放多张图片,从而模拟动态画面的效果。
Android的帧动画由动画图形AnimationDrawable生成。下面是AnimationDrawable的常用方法:

  • addFrame:添加一副图片帧,并指定该帧的持续事件(单位为毫秒)。
  • setOneShot:设置是否只播放一次,为true表示只播放一次,为false表示循环播放。
  • start:开始播放。注意,设置宿主视图后才能进行播放。
  • stop:停止播放。
  • isRunning:判断是否正在播放。

有了动画图形,还得有一个宿主视图现实该动画,一般使用图像承载AnimationDrawable,即调用图像视图的setImageDrawable方法加载动画图形。
下面是利用动画图形播放帧动画的代码片段:

// 在代码中生成并播放帧动画
private void showFrameAnimByCode() {
    ad_frame = new AnimationDrawable(); // 创建一个帧动画图形
    // 下面把每帧图片加入到帧动画的列表中
    ad_frame.addFrame(getDrawable(R.drawable.flow_p1), 50);
    ad_frame.addFrame(getDrawable(R.drawable.flow_p2), 50);
    ad_frame.addFrame(getDrawable(R.drawable.flow_p3), 50);
    ad_frame.addFrame(getDrawable(R.drawable.flow_p4), 50);
    ad_frame.addFrame(getDrawable(R.drawable.flow_p5), 50);
    ad_frame.addFrame(getDrawable(R.drawable.flow_p6), 50);
    ad_frame.addFrame(getDrawable(R.drawable.flow_p7), 50);
    ad_frame.addFrame(getDrawable(R.drawable.flow_p8), 50);
    // 设置帧动画是否只播放一次。为true表示只播放一次,为false表示循环播放
    ad_frame.setOneShot(false);
    // 设置图像视图的图形为帧动画
    iv_frame_anim.setImageDrawable(ad_frame);
    ad_frame.start(); // 开始播放帧动画
}

帧动画的播放效果如下图。这组帧动画由8张瀑布图片构成。
在这里插入图片描述
除了在代码中添加帧图片外,可以先在XML文件中定义帧图片的排列;然后在代码中调用图像视图的setImageResource方法,加载指定的XML图形定义文件;再调用图像视图的getDrawable方法,获得动画图形的实例,并进行后续的播放操作。
下面是定义帧动画排列的XML示例文件:

<?xml version="1.0" encoding="utf-8"?>
<animation-list xmlns:android="http://schemas.android.com/apk/res/android"
    android:oneshot="false">
    <item
        android:drawable="@drawable/flow_p1"
        android:duration="50" />
    <item
        android:drawable="@drawable/flow_p2"
        android:duration="50" />
    <item
        android:drawable="@drawable/flow_p3"
        android:duration="50" />
    <item
        android:drawable="@drawable/flow_p4"
        android:duration="50" />
    <item
        android:drawable="@drawable/flow_p5"
        android:duration="50" />
    <item
        android:drawable="@drawable/flow_p6"
        android:duration="50" />
    <item
        android:drawable="@drawable/flow_p7"
        android:duration="50" />
    <item
        android:drawable="@drawable/flow_p8"
        android:duration="50" />
</animation-list>

根据图形定义文件播放帧动画效果与在代码中添加帧图片是一样的,播放的示例代码如下:

// 从xml文件获取并播放帧动画
private void showFrameAnimByXml() {
    // 设置图像视图的图像来源为帧动画的XML定义文件
    iv_frame_anim.setImageResource(R.drawable.frame_anim);
    // 从图像视图对象中获取帧动画
    ad_frame = (AnimationDrawable) iv_frame_anim.getDrawable();
    ad_frame.start(); // 开始播放帧动画
}

显示动图特效

GIF是Windows常见的图片格式,主要用来播放短小的动画。Android虽然号称支持PNG、JPG、GIF三种格式,但是并不支持直接播放GIF动图,如果在图像视图中加载一个GIF文件,那么只会显示GIF文件的第一帧图片。
若想在手机上显示GIF动图,则需要八仙过海各显神通,具体的实现方式主要有三种:借助帧动画播放拆解后的组图,利用Movie类组合自定义控件播放动图,利用ImageDecoder结合动画图形播放动图。

1.借助帧动画播放拆解后的组图

在代码中将GIF文件分解为一系列图片数据,并获取每帧的持续时间,然后通过动画动态加载每帧图片。
从GIF文件中分解帧图片有现成的开源代码(见com.example.chapter12\util\GifImage.java),分解得到所有帧的组图,再通过帧动画技术显示GIF动图,详细的显示GIF动图的示例代码如下:

// 显示GIF动画
private void showGifAnimationOld(int imageId) {
    tv_info.setText("");
    // 从资源文件中获取输入流对象
    InputStream is = getResources().openRawResource(imageId);
    GifImage gifImage = new GifImage(); // 创建一个GIF图像对象
    int code = gifImage.read(is); // 从输入流中读取gif数据
    if (code == GifImage.STATUS_OK) { // 读取成功
        GifImage.GifFrame[] frameList = gifImage.getFrames();
        // 创建一个帧动画
        AnimationDrawable ad_gif = new AnimationDrawable();
        for (GifImage.GifFrame frame : frameList) {
            // 把Bitmap位图对象转换为Drawable图形格式
            BitmapDrawable drawable = new BitmapDrawable(getResources(), frame.image);
            // 给帧动画添加指定图形,以及该帧的播放延迟
            ad_gif.addFrame(drawable, frame.delay);
        }
        // 设置帧动画是否只播放一次。为true表示只播放一次,为false表示循环播放
        ad_gif.setOneShot(false);
        iv_gif.setImageDrawable(ad_gif); // 设置图像视图的图形为帧动画
        ad_gif.start(); // 开始播放帧动画
    } else if (code == GifImage.STATUS_FORMAT_ERROR) {
        Toast.makeText(this, "该图片不是gif格式", Toast.LENGTH_LONG).show();
    } else {
        Toast.makeText(this, "gif图片读取失败:" + code, Toast.LENGTH_LONG).show();
    }
}

2.利用Movie类结合自定义控件播放动图

借助原生的Movie工具,先加载动图的资源图片,再将每帧图像绘制到视图画布上,使之成为能够播放动图的自定义控件。动图视图的自定义代码如下:

public class GifView extends View {
    private Movie mMovie; // 声明一个电影对象
    private long mBeginTime = 0; // 开始播放时间
    private float mScaleRatio = 1; // 缩放比率

    public GifView(Context context) {
        this(context, null);
    }

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

    // 设置电影对象
    public void setMovie(Movie movie) {
        mMovie = movie;
        requestLayout(); // 请求重新调整视图位置
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        if (mMovie != null) {
            int width = mMovie.width(); // 获取电影动图的宽度
            int height = mMovie.height(); // 获取电影动图的高度
            float widthRatio = 1.0f * getMeasuredWidth() / width;
            float heightRatio = 1.0f * getMeasuredHeight() / height;
            mScaleRatio = Math.min(widthRatio, heightRatio);
        }
    }

    @Override
    public void onDraw(Canvas canvas) {
        long now = SystemClock.uptimeMillis();
        if (mBeginTime == 0) { // 如果是第一帧,就记录起始时间
            mBeginTime = now;
        }
        if (mMovie != null) {
            // 获取电影动图的播放时长
            int duration = mMovie.duration()==0 ? 1000 : mMovie.duration();
            // 计算当前要显示第几帧图画
            int currentTime = (int) ((now - mBeginTime) % duration);
            mMovie.setTime(currentTime); // 设置当前帧的相对时间
            canvas.scale(mScaleRatio, mScaleRatio); // 将画布缩放到指定比率
            mMovie.draw(canvas, 0, 0); // 把当前帧绘制到画布上
            postInvalidate(); // 立即刷新视图(线程安全方式)
        }
    }
}

接着在布局文件中添加上面定义的GifView节点,并给活动代码添加如下加载方法,即可实现GIF动图的播放功能:

// 通过Movie类播放动图
private void showGifMovie(int imageId) {
    // 从资源图片中解码得到电影对象
    Movie movie = Movie.decodeStream(getResources().openRawResource(imageId));
    gv_gif.setMovie(movie); // 设置电影对象
}

3.利用ImageDecoder结合动画图形播放动图

上述两种显示GIF动画的方法显然都不方便,毕竟GIF文件还是很流行的动图格式,因而从Android 9.0开始增加了新的图像解码器ImageDecoder,该解码器支持直接读取GIF文件的图像数据,通过搭配具备动画特征的图形工具Animatable即可轻松实现在App中播放GIF动图。利用图像解码器加载并显示图片的步骤分为以下4步:

  1. 调用ImageDecoder的createSource方法,从指定地方获取数据源。
  2. 调用ImageDecoder的decodeDrawable方法,从数据源解码得到Drawable类型的图形信息。
  3. 调用图像视图的setImageDrawable方法,设置图像视图的图形对象。
  4. 判断解码得到的图形对象是否为Animatable类型,如果是的话,就调用start方法播放动画。

其中步骤1的createSource方法允许从多种来源读取图像信息,包括但不限于下列来源:

  1. 来自存储卡的File对象。
  2. 来自系统相册的Uri对象。
  3. 来自资源图片的图形编号。(图片放在res/raw目录下,图形编号形如R.raw.***)
  4. 从输入流获取的字节数组。

举个例子,现在准备通过ImageDecoder加载来自res/raw目录的GIF动图,则详细的演示代码如下:

    @RequiresApi(api = Build.VERSION_CODES.P)
    private void showAnimateDrawable(int imageId) {
        try {
            // 利用Android9新增的ImageDecoder获取图像来源
            ImageDecoder.Source source = ImageDecoder.createSource(getResources(), imageId);
            // 从数据源解码得到图形信息
	        Drawable drawable = ImageDecoder.decodeDrawable(source);
	        iv_gif.setImageDrawable(drawable); // 设置图像视图的图形对象
	        if (drawable instanceof Animatable) { // 如果是动画图形,则开始播放动画
	            ((Animatable) iv_gif.getDrawable()).start();
	        }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

GIF文件的播放效果如下图所示。
在这里插入图片描述
早期的Android只支持3种图像格式,分别是JPEG、PNG和GIF,由于这三种图片格式历史悠久,当时的图像压缩算法不尽完美,并且手机摄像头的分辨率越来越高,导致一张高清照片动辄几兆字节乃至十几兆字节大小,使得手机的存储控件越发吃紧,这也是更高效的压缩算法。
目前智能手机行业仅剩安卓和IOS两大阵营,为了争夺移动互联网时代的技术高地,两大阵营的盟主纷纷推出新的图像压缩算法,安卓阵营的谷歌推出了WebP格式,而iOS阵营的苹果推出了HEIF格式。尽管WebP与HEIF出自不同的厂商,但它俩都具备了下列优异特异性:

  1. 支持透明背景:JPEG不支持透明背景。
  2. 支持动画效果:JPEG和PNG不支持动画效果。
  3. 支持有损压缩:PNG和GIF不支持有损压缩,因此它们的图片体积较大。

正因为WebP与HEIF如此优秀,所以它们在手机上愈加流行,从Android 9.0开始便支持浏览这两种格式的图片,从Android 10开始更允许将拍摄的照片保存为HEIF格式(同时需要硬件支持)。ImageDecoder正是Android 9.0推出的新型图像解码器,它不但兼容常规的JPEG和PNG图片,还适配GIF、WebP、HEIF的动态效果,可谓新老图片类型一网打尽。
从Android 12开始新增支持AVIF图像格式,它是目前为止最高效的高级图像压缩编解码器。在同等的图像质量情况下,AVIF格式的文件大小仅为JPEG格式的三分之一。AVIF还是符合HEIF标准的图像格式,这意味着我们能够利用ImageDeccoder解析AVIF图片。
使用ImageDecoder解析WebP、HEIF、AVIF图片的编码步骤与解析GIF图片一致,图像解码器播放WebP动图效果如下图所示。
在这里插入图片描述
图像解码器解析HEIF与AVIF文件的效果如下图所示。
在这里插入图片描述
在这里插入图片描述

淡入淡出动画

帧动画采取后面一帧直接覆盖前面一帧的显示方式,这在快速轮播时没有什么问题,但是如果每帧的间隔时间比较长(比如0.5秒),那么两帧之间的画面切换就会很生硬,直接从前一帧变成后一帧会让人觉得很突兀。为了解决这种长间隔切换图片在视觉方面的问题,Android提供了过度图形TransitionDrawable处理两张图片之间的渐变显示,即淡入淡出的动画效果。
过度图形同样需要宿主视图显示该图形,即调用图像视图的setImageDrawable方法进行图形加载操作。下面是TransitionDrawable的常用方法:

下面是使用过度图形的代码片段:

// 开始播放淡入淡出动画
private void showFadeAnimation() {
    // 淡入淡出动画需要先定义一个图形资源数组,用于变换图片
    Drawable[] drawableArray = {getDrawable(R.drawable.fade_begin), getDrawable(R.drawable.fade_end)};
    // 创建一个用于淡入淡出动画的过渡图形
    TransitionDrawable td_fade = new TransitionDrawable(drawableArray);
    iv_fade_anim.setImageDrawable(td_fade); // 设置过渡图形
    td_fade.setCrossFadeEnabled(true); // 是否启用交叉淡入。启用后淡入效果更柔和
    td_fade.startTransition(3000); // 开始时长3秒的过渡转换
}

过度图形的播放效果如下图所示。
在这里插入图片描述

补间动画

本节介绍补间动画的原理与用法,内容包括4种补间动画及其基本用法、补间动画的原理和基于旋转动画的思想实现摇摆动画、如果通过集合动画同时展示多种动画效果。

补间动画的种类

上一小节提到,两种图片之间的渐变效果可以使用过度图形实现,那么一张图形内部能否运用渐变效果呢?比如展示图片的逐步缩放过程等。正好,Android提供了补间动画,它允许开发者实现某个视图的动态变换,具体包括4种动画效果,分别是灰度动画(AplhaAnimation)、平移动画(TranslateAnimation)、缩放动画(ScaleAnimation)和旋转动画(RotateAnimation)。为什么把这4种动画称作补间动画呢?因为由开发者提供动画的起始状态值与终止状态值,然后系统按照时间推移计算中间的状态值,并自动把中间状态的视图补充到起止视图的变化视图的变化过程中,自动补充中间视图的动画就被简称为“补间动画”。
4种补间动画都来自于共同的动画类Animation,因此同时拥有Animation的属性与方法。下面是Animation的常用方法:

与帧动画一样,补间动画也需要找一个宿主视图,对宿主视图施展动画效果。不同的是,帧动画的宿主视图只能是由ImageView派生出来的视图家族(图像视图、图像按钮等),而补间动画的宿主视图可以是任意意图视图,只要派生自View类就行。给补间动画指定宿主视图的方式很简单,调用宿主对象的startAnimation方法即可命令宿主视图开始播放动画,调用宿主对象的clearAnimation方法即可要求宿主视图清除动画。
具体到每种补间动画又有不同的初始化方式。下面来看具体说明。

  1. 初始化灰度动画:在构造方法种指定视图透明度的前后数值,取值0.0~1.0(0表示完全不透明,1表示完全透明)。
  2. 初始化水平移动画:在构造方法中指定视图在平移前后左上角的坐标值。其中,第一个参数为平移前的横坐标,第二个参数为平移后的横坐标,第三个参数为平移前的纵坐标,第四个参数为平移后的纵坐标。
  3. 初始化缩放动画:在构造方法中指定视图纵横坐标的前后缩放比例。缩放比例取值0.5时表示缩小到原来的二分之一,取值为2时表示放大到原来的两倍。其中,第一个参数为缩放前的横坐标比例,第二个参数为缩放后的很坐标比例,第三个参数为缩放前的纵坐标比例,第四个参数为缩放后的纵坐标比例。
  4. 初始化旋转动画:在构造方法中指定视图的旋转角度。其中,第一个参数为旋转前的角度,第二个参数为旋转后的角度,第三个参数为圆心的横坐标类型,第四个参数为圆心横坐标的数值比例,第五个参数为圆心的纵坐标类型,第六个参数为圆心纵坐标的数值比例。Animation类的坐标类型的取值说明见下表。
Animation类的坐标类型说明
ABSOLUTE绝对位置
RELATIVE_TO_SELF相对自身位置
RELATIVE_TO_PARENT相对父视图的位置

下面是分别使用4种补间动画的示例代码:

// 声明四个补间动画对象
private Animation alphaAnim, translateAnim, scaleAnim, rotateAnim; 

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_tween_anim);
    iv_tween_anim = findViewById(R.id.iv_tween_anim);
    initTweenAnim(); // 初始化补间动画
    initTweenSpinner(); // 初始化动画类型下拉框
}

// 初始化补间动画
private void initTweenAnim() {
    // 创建一个灰度动画。从完全透明变为即将不透明
    alphaAnim = new AlphaAnimation(1.0f, 0.1f);
    alphaAnim.setDuration(3000); // 设置动画的播放时长
    alphaAnim.setFillAfter(true); // 设置维持结束画面
    // 创建一个平移动画。向左平移100dp
    translateAnim = new TranslateAnimation(1.0f, Utils.dip2px(this, -100), 1.0f, 1.0f);
    translateAnim.setDuration(3000); // 设置动画的播放时长
    translateAnim.setFillAfter(true); // 设置维持结束画面
    // 创建一个缩放动画。宽度不变,高度变为原来的二分之一
    scaleAnim = new ScaleAnimation(1.0f, 1.0f, 1.0f, 0.5f);
    scaleAnim.setDuration(3000); // 设置动画的播放时长
    scaleAnim.setFillAfter(true); // 设置维持结束画面
    // 创建一个旋转动画。围绕着圆心顺时针旋转360度
    rotateAnim = new RotateAnimation(0f, 360f, Animation.RELATIVE_TO_SELF,
            0.5f, Animation.RELATIVE_TO_SELF, 0.5f);
    rotateAnim.setDuration(3000); // 设置动画的播放时长
    rotateAnim.setFillAfter(true); // 设置维持结束画面
}

// 初始化动画类型下拉框
private void initTweenSpinner() {
    ArrayAdapter<String> tweenAdapter = new ArrayAdapter<>(this,
            R.layout.item_select, tweenArray);
    Spinner sp_tween = findViewById(R.id.sp_tween);
    sp_tween.setPrompt("请选择补间动画类型");
    sp_tween.setAdapter(tweenAdapter);
    sp_tween.setOnItemSelectedListener(new TweenSelectedListener());
    sp_tween.setSelection(0);
}

private String[] tweenArray = {"灰度动画", "平移动画", "缩放动画", "旋转动画"};
class TweenSelectedListener implements OnItemSelectedListener {
    public void onItemSelected(AdapterView<?> arg0, View arg1, int arg2, long arg3) {
        playTweenAnim(arg2); // 播放指定类型的补间动画
    }

    public void onNothingSelected(AdapterView<?> arg0) {}
}

// 播放指定类型的补间动画
private void playTweenAnim(int type) {
    if (type == 0) { // 灰度动画
        iv_tween_anim.startAnimation(alphaAnim); // 开始播放灰度动画
        // 给灰度动画设置动画事件监听器
        alphaAnim.setAnimationListener(TweenAnimActivity.this);
    } else if (type == 1) { // 平移动画
        iv_tween_anim.startAnimation(translateAnim); // 开始播放平移动画
        // 给平移动画设置动画事件监听器
        translateAnim.setAnimationListener(TweenAnimActivity.this);
    } else if (type == 2) { // 缩放动画
        iv_tween_anim.startAnimation(scaleAnim); // 开始播放缩放动画
        // 给缩放动画设置动画事件监听器
        scaleAnim.setAnimationListener(TweenAnimActivity.this);
    } else if (type == 3) { // 旋转动画
        iv_tween_anim.startAnimation(rotateAnim); // 开始播放旋转动画
        // 给旋转动画设置动画事件监听器
        rotateAnim.setAnimationListener(TweenAnimActivity.this);
    }
}

// 在补间动画开始播放时触发
@Override
public void onAnimationStart(Animation animation) {}

// 在补间动画结束播放时触发
@Override
public void onAnimationEnd(Animation animation) {
    if (animation.equals(alphaAnim)) { // 灰度动画
        // 创建一个灰度动画。从即将不透明变为完全透明
        Animation alphaAnim2 = new AlphaAnimation(0.1f, 1.0f);
        alphaAnim2.setDuration(3000); // 设置动画的播放时长
        alphaAnim2.setFillAfter(true); // 设置维持结束画面
        iv_tween_anim.startAnimation(alphaAnim2); // 开始播放灰度动画
    } else if (animation.equals(translateAnim)) { // 平移动画
        // 创建一个平移动画。向右平移100dp
        Animation translateAnim2 = new TranslateAnimation(Utils.dip2px(this, -100), 1.0f, 1.0f, 1.0f);
        translateAnim2.setDuration(3000); // 设置动画的播放时长
        translateAnim2.setFillAfter(true); // 设置维持结束画面
        iv_tween_anim.startAnimation(translateAnim2); // 开始播放平移动画
    } else if (animation.equals(scaleAnim)) { // 缩放动画
        // 创建一个缩放动画。宽度不变,高度变为原来的两倍
        Animation scaleAnim2 = new ScaleAnimation(1.0f, 1.0f, 0.5f, 1.0f);
        scaleAnim2.setDuration(3000); // 设置动画的播放时长
        scaleAnim2.setFillAfter(true); // 设置维持结束画面
        iv_tween_anim.startAnimation(scaleAnim2); // 开始播放缩放动画
    } else if (animation.equals(rotateAnim)) { // 旋转动画
        // 创建一个旋转动画。围绕着圆心逆时针旋转360度
        Animation rotateAnim2 = new RotateAnimation(0f, -360f,
                Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f);
        rotateAnim2.setDuration(3000); // 设置动画的播放时长
        rotateAnim2.setFillAfter(true); // 设置维持结束画面
        iv_tween_anim.startAnimation(rotateAnim2); // 开始播放旋转动画
    }
}

// 在补间动画重复播放时触发
@Override
public void onAnimationRepeat(Animation animation) {}

补间动画的播放效果让如下图所示,可以通过下拉框选择查看不同效果。
在这里插入图片描述

补间动画的原理

补间动画只提供了基本的动态变换,如果想要复杂的动画效果,比如像钟摆一样左摆一下再右摆一下,补间动画就无能为力了。因而有必要了解补间动画的实现原理,这样才能适当的改造,使其符合实际的业务需求。
以旋转动画RotateAnimation为例,接下来进一步阐述补间动画的实现原理。查看RotateAnimation的源码,发现除了一堆构造方法外剩下的代码只有3个方法:

/**
 * Called at the end of constructor methods to initialize, if possible, values for
 * the pivot point. This is only possible for ABSOLUTE pivot values.
 */
private void initializePivotPoint() {
    if (mPivotXType == ABSOLUTE) {
        mPivotX = mPivotXValue;
    }
    if (mPivotYType == ABSOLUTE) {
        mPivotY = mPivotYValue;
    }
}

@Override
protected void applyTransformation(float interpolatedTime, Transformation t) {
    float degrees = mFromDegrees + ((mToDegrees - mFromDegrees) * interpolatedTime);
    float scale = getScaleFactor();
    
    if (mPivotX == 0.0f && mPivotY == 0.0f) {
        t.getMatrix().setRotate(degrees);
    } else {
        t.getMatrix().setRotate(degrees, mPivotX * scale, mPivotY * scale);
    }
}

@Override
public void initialize(int width, int height, int parentWidth, int parentHeight) {
    super.initialize(width, height, parentWidth, parentHeight);
    mPivotX = resolveSize(mPivotXType, mPivotXValue, width, parentWidth);
    mPivotY = resolveSize(mPivotYType, mPivotYValue, height, parentHeight);
}

两个初始化方法都在处理圆心的坐标,与动画播放有关的方法只有applyTransformation。该方法很简单,提供了两个参数:第一个参数为插值时间,即逝去的时间所占的百分比;第二个参数为转换器。方法内部根据插值时间计算当前所处的角度数值,最后使用转换器把视图旋转到该角度。
查看其他补间动画的源码,发现都与RotateAnimation的处理大同小异,对用剑状态的视图变换处理不外乎以下两个步骤:

  1. 根据插值时间计算当前的状态值(如灰度、平移距离、缩放比例、旋转角度等)。
  2. 在宿主主视图上使用该状态值执行变换操作。

如此看来,补间动画的关键在于利用插值时间计算状态值。现在回头看看中百的左右摆动,这个摆动操作其实由3段旋转动画构成。

  1. 以上面的端点为圆心,钟摆以垂直向下的状态向左旋转,转到左边的某个角度停止(比如左转60°)。
  2. 钟摆从左边向右边旋转,转到右边的某个角度停住(比如右转120°,与垂直方向的夹角为60°)。
  3. 钟摆从右边再向左旋转,当其摆到垂直方向时完成一个周期的摇摆动作。

清楚了摇摆动画的运动过程后,接下来根据插值时间计算对应角度,具体到代码实现上需要做以下两处调整:

  1. 旋转动画初始化时只有两个度数,即起始角度和终止角度。摇摆动画需要3个参数,即中间角度(即是起始角度也是终止角度)、摆到左侧的角度和摆到右侧的角度。
  2. 根据插值时间估算当前所处的角度。对于摇摆动画来说,需要做3个分支判断(对应之前3段旋转动画)。如果整个动画持续4秒,那么0~1秒为往左的旋转动画,该区间的起始角度为中间角度,终止角度为摆到左侧的角度;1~3秒为往右的旋转动画,该区间的起始角度为摆到左侧的角度,终止角度为摆到右侧的角度;3~4秒为往左的旋转动画,该区间的起始角度为摆到右侧的角度,终止角度为中间角度。

分析完毕,下面为修改后的摇摆动画代码片段:

// 在动画变换过程中调用
@Override
protected void applyTransformation(float interpolatedTime, Transformation t) {
    float degrees;
    float leftPos = (float) (1.0 / 4.0); // 摆到左边端点时的时间比例
    float rightPos = (float) (3.0 / 4.0); // 摆到右边端点时的时间比例
    if (interpolatedTime <= leftPos) { // 从中间线往左边端点摆
        degrees = mMiddleDegrees + ((mLeftDegrees - mMiddleDegrees) * interpolatedTime * 4);
    } else if (interpolatedTime > leftPos && interpolatedTime < rightPos) { // 从左边端点往右边端点摆
        degrees = mLeftDegrees + ((mRightDegrees - mLeftDegrees) * (interpolatedTime - leftPos) * 2);
    } else { // 从右边端点往中间线摆
        degrees = mRightDegrees + ((mMiddleDegrees - mRightDegrees) * (interpolatedTime - rightPos) * 4);
    }
    float scale = getScaleFactor(); // 获得缩放比率
    if (mPivotX == 0.0f && mPivotY == 0.0f) {
        t.getMatrix().setRotate(degrees);
    } else {
        t.getMatrix().setRotate(degrees, mPivotX * scale, mPivotY * scale);
    }
}

摇摆动画的播放效果如下图所示。其中,左侧为左摆时的画面,右侧为右摆时的画面。
在这里插入图片描述

集合动画

有时一个动画效果会加入多种动画技术,比如一边旋转一边缩放,这时便会用到集合动画AnimationSet把几个补间动画组装起来,实现让某视图同时呈现多种动画的效果。
因为集合动画与补间动画一样集成自Animation类,所以拥有补间动画的基本方法。集合动画不像一般补间动画那样提供构造方法,而是通过addAnimation方法把别的补间动画加入本集合动画中。
下面是使用集合动画的代码片段:

private AnimationSet setAnim; // 声明一个集合动画对象

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_anim_set);
    iv_anim_set = findViewById(R.id.iv_anim_set);
    iv_anim_set.setOnClickListener(v -> startAnim());
    initAnimation(); // 初始化集合动画
}

// 初始化集合动画
private void initAnimation() {
    // 创建一个灰度动画
    Animation alphaAnim = new AlphaAnimation(1.0f, 0.1f);
    alphaAnim.setDuration(3000); // 设置动画的播放时长
    alphaAnim.setFillAfter(true); // 设置维持结束画面
    // 创建一个平移动画
    Animation translateAnim = new TranslateAnimation(1.0f, -200f, 1.0f, 1.0f);
    translateAnim.setDuration(3000); // 设置动画的播放时长
    translateAnim.setFillAfter(true); // 设置维持结束画面
    // 创建一个缩放动画
    Animation scaleAnim = new ScaleAnimation(1.0f, 1.0f, 1.0f, 0.5f);
    scaleAnim.setDuration(3000); // 设置动画的播放时长
    scaleAnim.setFillAfter(true); // 设置维持结束画面
    // 创建一个旋转动画
    Animation rotateAnim = new RotateAnimation(0f, 360f, Animation.RELATIVE_TO_SELF,
            0.5f, Animation.RELATIVE_TO_SELF, 0.5f);
    rotateAnim.setDuration(3000); // 设置动画的播放时长
    rotateAnim.setFillAfter(true); // 设置维持结束画面
    // 创建一个集合动画
    setAnim = new AnimationSet(true);
    // 下面在代码中添加集合动画
    setAnim.addAnimation(alphaAnim); // 给集合动画添加灰度动画
    setAnim.addAnimation(translateAnim); // 给集合动画添加平移动画
    setAnim.addAnimation(scaleAnim); // 给集合动画添加缩放动画
    setAnim.addAnimation(rotateAnim); // 给集合动画添加旋转动画
    setAnim.setFillAfter(true); // 设置维持结束画面
    startAnim(); // 开始播放集合动画
}

// 开始播放集合动画
private void startAnim() {
    iv_anim_set.startAnimation(setAnim); // 开始播放动画
    setAnim.setAnimationListener(this); // 设置动画事件监听器
}

集合动画的播放效果如下图所示。其中,下图左侧为开始播放不久后的画面,下图右侧为即将播放结束的画面。
在这里插入图片描述

属性动画

本节介绍属性动画的应用场合与进阶用法,内容包括:为何属性动画时补间动画的升级版以及属性动画的基本用法;运用属性动画组合实现多个属性动画的同时播放与顺序播放效果;对动画技术中的插值器和估值器进行分析,并演示不同插值器的动画效果;如何利用估值其实现直播网站常见的打赏动画。

常规的属性动画

视图View类虽有许多状态属性,但补间动画只对其中6种属性进行操作,具体说明见下表。

View类的属性名称属性说明属性设置方法对应的补间动画
alpha透明度setAlpha灰度动画
rotation旋转角度setRotation旋转动画
scaleX横坐标的缩放比例setScaleX缩放动画
scaleY纵坐标的缩放比例setScaleY缩放动画
translationX横坐标的平移距离setTranslationX平移动画
translationY纵坐标的平移距离setTranslationY平移动画

实际上每个控件的属性远不止这6种,如果要求对视图的背景颜色做渐变处理,补间动画就无能为力了。为此,Android又引入了属性动画ObjectAnimator。属性动画突破了补间动画的局限,允许视图的所有属性都能实现渐变的动画效果,例如背景颜色、文字颜色、文字大小等。只要设定某属性的起始值、渐变的持续时间,属性动画即可实现渐变效果。
下面是ObjectAnimator的常用方法:

  • ofInt:定义整型属性的属性动画。
  • ofFloat:定义浮点型属性的属性动画。
  • ofArgb:定义颜色属性的属性动画。
  • ofObject:定义对象属性的属性动画,用于不足上述三种类型的属性,例如Rect对象。

以上4个of方法的第一个参数为宿主视图对象,第二个参数为需要变化的属性名称,第三个参数以及后面的参数为属性变化的各个状态值。注意,of方法后面的参数个数是变化的。如果第三个参数是状态A、第四个参数是状态B,属性动画就从A状态变为状态B状态;如果第三个参数是状态A、第四个参数是状态B、第五个参数是状态C,属性动画就先从状态A变为状态B,再从状态B变为状态C。

下面是使用属性动画分别实现透明度、平移、缩放、旋转、裁剪等变换操作的示例代码:

// 声明四个属性动画对象
private ObjectAnimator alphaAnim, translateAnim, scaleAnim, rotateAnim; 

// 初始化属性动画
private void initObjectAnim() {
    // 构造一个在透明度上变化的属性动画
    alphaAnim = ObjectAnimator.ofFloat(iv_object_anim, "alpha", 1f, 0.1f, 1f);
    // 构造一个在横轴上平移的属性动画
    translateAnim = ObjectAnimator.ofFloat(iv_object_anim, "translationX", 0f, -200f, 0f, 200f, 0f);
    // 构造一个在纵轴上缩放的属性动画
    scaleAnim = ObjectAnimator.ofFloat(iv_object_anim, "scaleY", 1f, 0.5f, 1f);
    // 构造一个围绕中心点旋转的属性动画
    rotateAnim = ObjectAnimator.ofFloat(iv_object_anim, "rotation", 0f, 360f, 0f);
}

// 播放指定类型的属性动画
private void playObjectAnim(int type) {
    ObjectAnimator anim = null;
    if (type == 0) { // 灰度动画
        anim = alphaAnim;
    } else if (type == 1) { // 平移动画
        anim = translateAnim;
    } else if (type == 2) { // 缩放动画
        anim = scaleAnim;
    } else if (type == 3) { // 旋转动画
        anim = rotateAnim;
    } else if (type == 4) { // 裁剪动画
        int width = iv_object_anim.getWidth();
        int height = iv_object_anim.getHeight();
        // 构造一个从四周向中间裁剪的属性动画
        ObjectAnimator clipAnim = ObjectAnimator.ofObject(iv_object_anim, "clipBounds",
                new RectEvaluator(), new Rect(0, 0, width, height),
                new Rect(width / 3, height / 3, width / 3 * 2, height / 3 * 2),
                new Rect(0, 0, width, height));
        anim = clipAnim;
    }
    if (anim != null) {
        anim.setDuration(3000); // 设置动画的播放时长
        anim.start(); // 开始播放属性动画
    }
}

在上述代码演示的属性动画中,补间动画已经实现的效果就不再给出图例了,补间动画未实现的裁剪动画效果如下图所示。其中,左侧为开始时画面,右侧为裁剪过程画面。
在这里插入图片描述

属性动画组合

补间动画可以通过集合动画AnimationSet组装多种动画效果,属性动画也有类似的做法,即通过属性动画组合AnimatorSet组装多种属性动画。
AnimatorSet虽然与ObjectAnimator都继承自Animator,但是两者的使用方法略有不同,主要是属性动画组合少了部分方法。下面是AnimatorSet的常用方法:

  • setDuration:设置动画组合的持续时间,单位为毫秒。
  • setInterpolator:设置动画组合的插值器。
  • play:设置当前动画。该方法返回一个AnimatorSet.Builder对象,可对该对象调用组装方法添加新动画,从而实现动画组装功能。下面是Builder的组装方法说明。
    • with:指定该动画与当前动画一起播放。
    • before:指定该动画在当前动画之前播放。
    • after:指定该动画在当前动画之后播放。
  • start:开始播放动画组合。
  • pause:暂停播放动画组合。
  • resume:恢复播放组合动画。
  • cancel:取消播放动画组合。
  • end:结束播放动画组合。
  • isRunning:判断动画组合是否在播放。
  • isStarted:判断动画组合是否已经开始。

下面是使用属性动画组合组装多种属性动画的示例代码:

// 声明一个属性动画组合对象
private AnimatorSet animSet; 

// 初始化属性动画
private void initObjectAnim() {
    // 构造一个在横轴上平移的属性动画
    ObjectAnimator anim1 = ObjectAnimator.ofFloat(iv_object_group, "translationX", 0f, 100f);
    // 构造一个在透明度上变化的属性动画
    ObjectAnimator anim2 = ObjectAnimator.ofFloat(iv_object_group, "alpha", 1f, 0.1f, 1f, 0.5f, 1f);
    // 构造一个围绕中心点旋转的属性动画
    ObjectAnimator anim3 = ObjectAnimator.ofFloat(iv_object_group, "rotation", 0f, 360f);
    // 构造一个在纵轴上缩放的属性动画
    ObjectAnimator anim4 = ObjectAnimator.ofFloat(iv_object_group, "scaleY", 1f, 0.5f, 1f);
    // 构造一个在横轴上平移的属性动画
    ObjectAnimator anim5 = ObjectAnimator.ofFloat(iv_object_group, "translationX", 100f, 0f);
    animSet = new AnimatorSet(); // 创建一个属性动画组合
    // 把指定的属性动画添加到属性动画组合
    AnimatorSet.Builder builder = animSet.play(anim2);
    // 动画播放顺序为:先执行anim1,再一起执行anim2、anim3、anim3,最后执行anim5
    builder.with(anim3).with(anim4).after(anim1).before(anim5);
    animSet.setDuration(4500); // 设置动画的播放时长
    animSet.start(); // 开始播放属性动画
    animSet.addListener(this); // 给属性动画添加动画事件监听器
}

属性动画组合的演示效果如下图。其中,左侧图片为开始播放不久后的画面,右侧是组合播放过程中的画面。
在这里插入图片描述

插值器和估值器

前面在介绍补间动画与属性动画时都提到了插值器,属性动画还提到了估值器,一位内插值器和估值器是相互关联的,所以放在本小节一起介绍。
插值器用来控制属性值的变化速率,也可以理解为动画播放的速度,默认是先加速再减速(AccelerateDecelerateInterpolator)。若要给动画播放指定某种速率形式(比如匀速播放),调用setInterpolator方法设置对应的插值器实现类即可,无论是补间动画、集合动画、属性动画还是属性动画组合,都可以设置插值器。插值器实现类的说明见下表。

插值器实现类说明
LinearInterpolator匀速插值器
AccelerateInterpolator加速插值器
DecelerateInterpolator减速插值器
AccelerateDecelerateInterpolator落水插值器,即前半段加速、后半段减速
AnticipateInterpolator射箭插值器,后退几步再往前冲
OvershootInterpolator回旋插值器,冲过头再归为
AnticipateOvershootInterpolator射箭回旋插值器,后退几步再往前冲,冲过头再归位
BounceInterpolator震荡插值器,类似皮球落地(落地后会弹起几次)
CycleInterpolator钟摆插值器,以开始位置为中线而晃动(类似摇摆动画,开始位置与结束位置的距离就是摇摆的幅度)

估值器专用于属性动画,主要描述该属性的数值变化采用什么单位,比如整数类型的渐变数值要取整,颜色的渐变数值为ARGB格式的颜色对象,矩形的渐变数值为Rect对象等。要给属性动画设置估值器,调用属性动画对象的setEvaluator方法即可。估值器实现类的说明见下表。

估值器的实现类说明
IntEvaluator整数类型估值器
FloatEvaluator浮点类型估值器
ArgbEvaluator颜色估值器
RectEvaluator矩形估值器

一般情况下,无需单独设置属性动画的估值器,使用系统默认的估值器即可。如果属性类型不是int、float、argb三种,只能通过ofObject方法重构属性动画对象,就必须指定该属性的估值器,否则系统不知道如何计算渐变属性值。为方便记忆属性动画的构造方法与估值器的关联关系,下表列出了两者之间的对应关系。

属性动画的构造方法估值器对应的属性说明
ofIntIntEvaluator证书类型的属性
ofFloatFloatEvaluator大部分状态属性,如alpha、rotation、scaleY、translationX、textSize等
ofArgbArgbEvaluator颜色,如backgroundColor、textColor等
ofObjectRectEvaluator裁剪范围,如clipBounds

下面是在属性动画中运用插值器和估值器的示例代码:

// 声明四个属性动画对象
private ObjectAnimator animAcce, animDece, animLinear, animBounce; 

// 初始化属性动画
private void initObjectAnim() {
    // 构造一个在背景色上变化的属性动画
    animAcce = ObjectAnimator.ofInt(tv_interpolator, "backgroundColor", Color.RED, Color.GRAY);
    // 给属性动画设置加速插值器
    animAcce.setInterpolator(new AccelerateInterpolator());
    // 给属性动画设置颜色估值器
    animAcce.setEvaluator(new ArgbEvaluator());
    // 构造一个围绕中心点旋转的属性动画
    animDece = ObjectAnimator.ofFloat(tv_interpolator, "rotation", 0f, 360f);
    // 给属性动画设置减速插值器
    animDece.setInterpolator(new DecelerateInterpolator());
    // 给属性动画设置浮点型估值器
    animDece.setEvaluator(new FloatEvaluator());
    // 构造一个在文字大小上变化的属性动画
    animBounce = ObjectAnimator.ofFloat(tv_interpolator, "textSize", 20f, 60f);
    // 给属性动画设置震荡插值器
    animBounce.setInterpolator(new BounceInterpolator());
    // 给属性动画设置浮点型估值器
    animBounce.setEvaluator(new FloatEvaluator());
}

// 根据插值器类型展示属性动画
private void showInterpolator(int type) {
    ObjectAnimator anim = null;
    if (type == 0) { // 背景色+加速插值器+颜色估值器
        anim = animAcce;
    } else if (type == 1) { // 旋转+减速插值器+浮点型估值器
        anim = animDece;
    } else if (type == 2) { // 裁剪+匀速插值器+矩形估值器
        int width = tv_interpolator.getWidth();
        int height = tv_interpolator.getHeight();
        // 构造一个从四周向中间裁剪的属性动画,同时指定了矩形估值器RectEvaluator
        animLinear = ObjectAnimator.ofObject(tv_interpolator, "clipBounds",
                new RectEvaluator(), new Rect(0, 0, width, height),
                new Rect(width / 3, height / 3, width / 3 * 2, height / 3 * 2),
                new Rect(0, 0, width, height));
        // 给属性动画设置匀速插值器
        animLinear.setInterpolator(new LinearInterpolator());
        anim = animLinear;
    } else if (type == 3) { // 文字大小+震荡插值器+浮点型估值器
        anim = animBounce;
        // 给属性动画添加动画事件监听器。目的是在动画结束时恢复文字大小
        anim.addListener(this);
    }
    anim.setDuration(2000); // 设置动画的播放时长
    anim.start(); // 开始播放属性动画
}

// 在属性动画开始播放时触发
@Override
public void onAnimationStart(Animator animation) {}

// 在属性动画结束播放时触发
@Override
public void onAnimationEnd(Animator animation) {
    if (animation.equals(animBounce)) { // 震荡动画
        // 构造一个在文字大小上变化的属性动画
        ObjectAnimator anim = ObjectAnimator.ofFloat(tv_interpolator, "textSize", 60f, 20f);
        // 给属性动画设置震荡插值器
        anim.setInterpolator(new BounceInterpolator());
        // 给属性动画设置浮点型估值器
        anim.setEvaluator(new FloatEvaluator());
        anim.setDuration(2000); // 设置动画的播放时长
        anim.start(); // 开始播放属性动画
    }
}

// 在属性动画取消播放时触发
@Override
public void onAnimationCancel(Animator animation) {}

// 在属性动画重复播放时触发
@Override
public void onAnimationRepeat(Animator animation) {}

插值器和估值器的演示效果如下图所示。其中,左侧为振荡插值器开始播放文字变大时的画面,右侧为振荡器即将结束播放文字变小时的画面。实际效果请自行点击文章最后的工程源码下载运行App查看。
在这里插入图片描述

利用估值器实现打赏动画

贝塞尔曲线又叫贝济埃曲线,是一种用于二维图形的数学曲线。贝塞尔曲线由节点和线段构成,其中节点是可拖动的支点,而线段仿佛有弹性的牛皮筋。譬如上班族每天两点一线,一个端点是家,另一个端点是单位,那么从家到单位存在一条通勤路线,该路线弯弯曲曲在大街小巷之间延伸。这个上班路线无疑由许多条折线连接而成,既无规律也无美感,无法通过简洁的数学公式来表达。为此法国数学家贝塞尔研究出一种曲线,除了起点和终点之外,不再描绘中间的折线,而是构建一段运输小球的控制线,控制线本身在移动,然后小球随着在控制线上滑动,小球从起点运动到终点的轨迹便形成了贝塞尔曲线。
贝塞尔曲线又分为以下三类曲线:

  1. 一次贝塞尔曲线
    此时曲线只是一条两点间的线段,它的函数公式为:B(t) = (1 - t) * P0 + t * P1,其中 t 是参数,取值范围是 [0, 1]。

  2. 二次贝塞尔曲线
    此时除了起点和终点,曲线还存在一个控制点,它的函数公式为:B(t) = (1 - t)^2 * P0 + 2 * (1 - t) * t * P1 + t^2 * P2,其中 t 是参数,取值范围是 [0, 1]。
    二次贝塞尔曲线的小球运动轨迹如下图所示。
    在这里插入图片描述

  3. 三次贝塞尔曲线
    此时除了起点和终点,曲线还存在两个控制点,它的函数公式为:B(t) = (1 - t)^3 * P0 + 3 * (1 - t)^2 * t * P1 + 3 * (1 - t) * t^2 * P2 + t^3 * P3,其中 t 是参数,取值范围是 [0, 1]。
    三次贝塞尔曲线的小球运动轨迹如下图所示。
    在这里插入图片描述

贝塞尔曲线拥有优美得平滑特性,使得它广泛应用于计算机绘图,甚至Android也自带了与之相关操作的操作方法。这些方法都是由路径工具Path提供的,具体说明如下:

  • moveTo:把画笔移动到指定起点。
  • lineTo:从当前点到目标点画一条直线。
  • quadTo:指定二次贝塞尔曲线的控制点与结束点的绝对坐标,并在当前点到结束点之间绘制贝塞尔曲线。
  • rQuadTo:指定二次贝塞尔曲线的控制点与结束点的相对坐标,并在当前点到结束点之间绘制贝塞尔曲线。
  • cubicTo:指定三次贝塞尔曲线的两个控制点与结束点的绝对坐标,并在当前点到结束点之间绘制贝塞尔曲线。
  • rCubicTo:指定三次贝塞尔曲线的两个控制点与结束点的相对坐标,并在当前点到结束点之间绘制贝塞尔曲线。

注意,quadTo与rQuadTo两个方法的区别在于:前者的坐标参数为绝对坐标,后者的坐标参数为参考当前点偏移的相对坐标。
有了上述的路径方法,开发者就无需自己实现贝塞尔曲线的算法,只要调用相关路径方法即可,于是App绘制贝塞尔曲线就简单多了。
贝塞尔曲线在App中有个常见的应用,就像时兴的给主播打赏礼物,点击爱心打赏之后,礼物图标会在屏幕上走出一条优雅的漂移曲线。这个漂移曲线在前进途中左右摇摆,不拘一格款款前行。
具体到代码上,可将漂移动画的实现步骤分解为下列几项:

  1. 创建一个缩放动画,让礼物图标在爱心处从小变大,呈现礼物孵化效果。
  2. 创建一个属性动画,指定礼物漂移的起点和终点,并在动画过程中动态改变贝塞尔的控制点。
  3. 定义一个添加打赏的方法,该方法先把礼物图标添加到视图上,再依次播放前两部的缩放动画和属性动画。

按照以上步骤的描述,自定义打赏视图的示例代码如下:

public class RewardView extends RelativeLayout{
    private final static String TAG = "RewardView";
    private Context mContext; // 声明一个上下文对象
    private int mLayoutWidth, mLayoutHeight; // 声明当前视图的宽度和高度
    private LayoutParams mLayoutParams; // 声明打赏礼物的布局参数
    private List<Drawable> mDrawableList = new ArrayList<>(); // 打赏礼物的图形列表
    private int dip_35;
    private int[] mDrawableArray = new int[] {R.drawable.gift01, R.drawable.gift02,
            R.drawable.gift03, R.drawable.gift04, R.drawable.gift05, R.drawable.gift06};

    public RewardView(Context context) {
        this(context, null);
    }

    public RewardView(Context context, AttributeSet attrs) {
        super(context, attrs);
        mContext = context;
        for (int drawableId : mDrawableArray) {
            mDrawableList.add(mContext.getDrawable(drawableId));
        }
        dip_35 = Utils.dip2px(mContext, 35);
        mLayoutParams = new LayoutParams(dip_35, dip_35);
        // 代码设置礼物的起始布局方式,底部居中
        mLayoutParams.addRule(CENTER_HORIZONTAL, TRUE);
        mLayoutParams.addRule(ALIGN_PARENT_BOTTOM, TRUE);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        mLayoutWidth = getMeasuredWidth(); // 获取视图的实际宽度
        mLayoutHeight = getMeasuredHeight(); // 获取视图的实际高度
    }

    // 添加打赏礼物的视图并播放打赏动画
    public void addGiftView(){
        int pos = new Random().nextInt(mDrawableList.size());
        ImageView imageView = new ImageView(mContext);
        imageView.setImageDrawable(mDrawableList.get(pos)); // 设置图像视图的图像图形
        imageView.setLayoutParams(mLayoutParams); // 设置图像视图的布局参数
        addView(imageView); // 添加打赏礼物的图像视图
        // 创建礼物的缩放动画(补间动画方式)
        ScaleAnimation scaleAnim = new ScaleAnimation(0.2f, 1.0f, 0.2f, 1.0f,
                Animation.RELATIVE_TO_SELF, 0.5f,Animation.RELATIVE_TO_SELF, 1.0f);
        scaleAnim.setDuration(500); // 设置动画的播放时长
        imageView.startAnimation(scaleAnim); // 启动礼物的缩放动画
        playBezierAnimation(imageView); // 播放礼物的漂移动画(贝塞尔曲线方式)
    }

    // 播放礼物的漂移动画(贝塞尔曲线方式)
    private void playBezierAnimation(View giftView) {
        // 初始化一个贝塞尔计算器
        BezierEvaluator evaluator = new BezierEvaluator(getPoint(), getPoint());
        PointF beginPoint = new PointF(mLayoutWidth/2 - dip_35/2, mLayoutHeight - dip_35/2);
        float endX = (float) (Math.random()*mLayoutWidth - dip_35/2);
        float endY = (float) (Math.random()*10);
        PointF endPoint = new PointF(endX, endY);
        // 创建一个属性动画
        ValueAnimator animator = ValueAnimator.ofObject(evaluator, beginPoint, endPoint);
        // 添加属性动画的刷新监听器
        animator.addUpdateListener(animation -> {
            // 获取二阶贝塞尔曲线的坐标点,用于指定打赏礼物的当前位置
            PointF point = (PointF) animation.getAnimatedValue();
            giftView.setX(point.x); // 设置视图的横坐标
            giftView.setY(point.y); // 设置视图的纵坐标
            giftView.setAlpha(1 - animation.getAnimatedFraction()); // 设置渐变动画
        });
        animator.setTarget(giftView); // 设置动画的播放目标
        animator.setDuration(3000); // 设置动画的播放时长
        animator.start(); // 播放礼物的漂移动画
    }

    // 生成随机控制点
    private PointF getPoint() {
        PointF point = new PointF();
        point.x = (float) (Math.random()*mLayoutWidth - dip_35/2);
        point.y = (float) (Math.random()*mLayoutHeight/5);
        Log.d(TAG, "point.x="+point.x+", point.y="+point.y);
        return point;
    }

    // 贝塞尔估值器,根据输入的两个坐标点,计算二阶贝塞尔曲线上的对应坐标
    public static class BezierEvaluator implements TypeEvaluator<PointF> {
        private PointF mPoint1, mPoint2;
        
        public BezierEvaluator(PointF point1, PointF point2){
            mPoint1 = point1;
            mPoint2 = point2;
        }

        @Override
        public PointF evaluate(float time, PointF startValue, PointF endValue) {
            float leftTime = 1 - time;
            PointF point = new PointF();
            point.x = leftTime * leftTime * leftTime * (startValue.x)
                    + 3 * leftTime * leftTime * time * (mPoint1.x)
                    + 3 * leftTime * time * time * (mPoint2.x)
                    + time * time * time * (endValue.x);
            point.y = leftTime * leftTime * leftTime * (startValue.y)
                    + 3 * leftTime * leftTime * time * (mPoint1.y)
                    + 3 * leftTime * time * time * (mPoint2.y)
                    + time * time * time * (endValue.y);
            return point;
        }
    }
}

然后在布局文件中添加RewardView节点,并在对应的活动页面给爱心图标添加点击事件,每次点击爱心都调用addGiftView方法添加打赏礼物。这样多次点击便会涌现很多个礼物,同时每个礼物图标都沿着自己的曲线蜿蜒前行,从而实现打赏漂移的动画特效。
运行App,可观察到打赏效果如下图所示。其中,左侧图片为刚点击爱心图标时的画面,右侧图片为多次点击爱心图标后的画面,可见礼物分别漂到了不同的位置。
在这里插入图片描述

遮罩动画及滚动器

本节介绍其他几种常见的动画实现手段,内容包括:遮罩动画画布的绘图层次类型及其相互之间的区别;如何利用绘图层次实现百叶窗动画和马赛克动画;滚动器动画在平滑翻书特效中的具体运用。

画布的绘图层次

画布Canvas上的绘图操作都是同一个图层上进行的,这意味着如果存在重叠区域,后面绘制的图形就必然覆盖前面的图形。绘图是比较复杂的事情,不是直接覆盖这么简单,有些特殊的绘图操作往往需要做与、或、非运算,如此才能实现百变的图像特效。
Android给画布的图层显示定制了许多规则,详细的图层显示规则,即图层模式的取值说明见下表。表中的上层指的是后绘制的图形Src,下层指的是先绘制的图形Dst。

PorterDuff.Mode类的图层模式说明
CLEAR不显示任何图形
SRC只显示上层图形
DST只显示下层图形
SRC_OVER按通常情况显示,即重叠部分由上层遮盖下层
DST_OVER重叠部分由下层遮盖上层,其余部分正常显示
SRC_IN只显示重叠部分的上层图形
DST_IN只显示重叠部分的下层图形
SRC_OUT只显示上层图形的为重叠部分
DST_OUT只显示下层图形的为重叠部分
SRC_ATOP只显示上层图形区域,但重叠部分显示下层图形
DST_ATOP只显示下层图形区域,但重叠部分显示上层图形
XOR不显示重叠部分,其余部分正常显示
DARKEN重叠部分按颜料混合方式加深,其余部分正常显示
LIGHTEN重叠部分按光照重合方式加亮,区域部分正常显示
MULTIPLY只显示重叠部分,且重叠部分的颜色混合加深
SCREEN过滤重叠部分的深色,其余部分正常显示

这些图层的文案有点令人费解,还是看画面比较直观。在下图中,圆圈是先绘制的下层图形,正方形是后绘制的上层图形,图例展示了运用不同规则时的显示画面。
在这里插入图片描述

具体到编码而言,需要在当前画布之外再准备一个遮罩画布,遮罩画布绘制上层图形,而当前画布绘制下层图形。同时指定两个画布的混合图层模式,并根据该模式在当前画布盖上遮罩画布,为此自定义演示用的图层视图示例代码如下:

public class LayerView extends View {
    private Paint mUpPaint = new Paint(); // 声明上层的画笔对象
    private Paint mDownPaint = new Paint(); // 声明下层的画笔对象
    private Paint mMaskPaint = new Paint(); // 声明遮罩的画笔对象
    private boolean onlyLine = true; // 是否只绘制轮廓
    private PorterDuff.Mode mMode; // 绘图模式

    public LayerView(Context context) {
        this(context, null);
    }

    public LayerView(Context context, AttributeSet attrs) {
        super(context, attrs);
        mUpPaint.setStrokeWidth(5); // 设置画笔的线宽
        mUpPaint.setColor(Color.CYAN); // 设置画笔的颜色
        mDownPaint.setStrokeWidth(5); // 设置画笔的线宽
        mDownPaint.setColor(Color.RED); // 设置画笔的颜色
    }

    // 设置绘图模式
    public void setMode(PorterDuff.Mode mode) {
        mMode = mode;
        onlyLine = false;
        mUpPaint.setStyle(Paint.Style.FILL); // 设置画笔的类型
        mDownPaint.setStyle(Paint.Style.FILL); // 设置画笔的类型
        postInvalidate(); // 立即刷新视图(线程安全方式)
    }

    // 只显示线条轮廓
    public void setOnlyLine() {
        onlyLine = true;
        mUpPaint.setStyle(Paint.Style.STROKE); // 设置画笔的类型
        mDownPaint.setStyle(Paint.Style.STROKE); // 设置画笔的类型
        postInvalidate(); // 立即刷新视图(线程安全方式)
    }

    @Override
    protected void onDraw(Canvas canvas) {
        int width = getMeasuredWidth(); // 获取视图的实际宽度
        int height = getMeasuredHeight(); // 获取视图的实际高度
        if (onlyLine) { // 只绘制轮廓
            canvas.drawRect(width/3, height/3, width*9/10, height*9/10, mUpPaint);
            canvas.drawCircle(width/3, height/3, height/3, mDownPaint);
        } else if (mMode != null) { // 绘制混合后的图像
            // 创建一个遮罩位图
            Bitmap mask = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
            Canvas canvasMask = new Canvas(mask); // 创建一个遮罩画布
            // 先绘制上层的矩形
            canvasMask.drawRect(width/3, height/3, width*9/10, height*9/10, mUpPaint);
            // 设置离屏缓存
            int saveLayer = canvas.saveLayer(0, 0, width, height, null, Canvas.ALL_SAVE_FLAG);
            // 再绘制下层的圆形
            canvas.drawCircle(width/3, height/3, height/3, mDownPaint);
            mMaskPaint.setXfermode(new PorterDuffXfermode(mMode)); // 设置混合模式
            canvas.drawBitmap(mask, 0, 0, mMaskPaint); // 绘制源图像的遮罩
            mMaskPaint.setXfermode(null); // 还原混合模式
            canvas.restoreToCount(saveLayer); // 还原画布
        }
    }
}

然后在布局文件中添加LayerView节点,并在对应的活动页面调用setMode方法设置绘图模式。运行测试App,可观察到图层覆盖效果如下图所示。各种效果可以点击文末的工程源码下载运行查看。
在这里插入图片描述

实现百叶窗动画

合理运用图层规则可以实现酷炫的动画效果,比如把图片分割成一条一条的,接着每条都逐渐展开,这边产生了百叶窗动画;把图片等分为若干小方格,然后逐次显示几个小方格,直至所有小方格都显示出来,这便形成了马赛克动画。
以百叶窗动画为例,首先定义一个百叶窗视图,并重写onDraw方法,给遮罩画布描绘若干矩形叶片,每次绘制的叶片大小由比率参数决定。按此编写的百叶窗视图定义代码如下:

public class ShutterView extends View {
    private final static String TAG = "ShutterView";
    private Paint mPaint = new Paint(); // 声明一个画笔对象
    private int mOriention = LinearLayout.HORIZONTAL; // 动画方向
    private int mLeafCount = 10; // 叶片的数量
    private PorterDuff.Mode mMode = PorterDuff.Mode.DST_IN; // 绘图模式为只展示交集
    private Bitmap mBitmap; // 声明一个位图对象
    private int mRatio = 0; // 绘制的比率

    public ShutterView(Context context) {
        this(context, null);
    }

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

    // 设置百叶窗的方向
    public void setOriention(int oriention) {
        mOriention = oriention;
    }

    // 设置百叶窗的叶片数量
    public void setLeafCount(int leaf_count) {
        mLeafCount = leaf_count;
    }

    // 设置绘图模式
    public void setMode(PorterDuff.Mode mode) {
        mMode = mode;
    }

    // 设置位图对象
    public void setImageBitmap(Bitmap bitmap) {
        mBitmap = bitmap;
    }

    // 设置绘图比率
    public void setRatio(int ratio) {
        mRatio = ratio;
        postInvalidate(); // 立即刷新视图(线程安全方式)
    }

    @Override
    protected void onDraw(Canvas canvas) {
        if (mBitmap == null) {
            return;
        }
        int width = getMeasuredWidth(); // 获取视图的实际宽度
        int height = getMeasuredHeight(); // 获取视图的实际高度
        // 创建一个遮罩位图
        Bitmap mask = Bitmap.createBitmap(width, height, mBitmap.getConfig());
        Canvas canvasMask = new Canvas(mask); // 创建一个遮罩画布
        for (int i = 0; i < mLeafCount; i++) {
            if (mOriention == LinearLayout.HORIZONTAL) { // 水平方向
                int column_width = (int) Math.ceil(width * 1f / mLeafCount);
                int left = column_width * i;
                int right = left + column_width * mRatio / 100;
                // 在遮罩画布上绘制各矩形叶片
                canvasMask.drawRect(left, 0, right, height, mPaint);
            } else { // 垂直方向
                int row_height = (int) Math.ceil(height * 1f / mLeafCount);
                int top = row_height * i;
                int bottom = top + row_height * mRatio / 100;
                // 在遮罩画布上绘制各矩形叶片
                canvasMask.drawRect(0, top, width, bottom, mPaint);
            }
        }
        // 设置离屏缓存
        int saveLayer = canvas.saveLayer(0, 0, width, height, null, Canvas.ALL_SAVE_FLAG);
        Rect rect = new Rect(0, 0, width, width * mBitmap.getHeight() / mBitmap.getWidth());
        canvas.drawBitmap(mBitmap, null, rect, mPaint); // 绘制目标图像
        mPaint.setXfermode(new PorterDuffXfermode(mMode)); // 设置混合模式
        canvas.drawBitmap(mask, 0, 0, mPaint); // 再绘制源图像的遮罩
        mPaint.setXfermode(null); // 还原混合模式
        canvas.restoreToCount(saveLayer); // 还原画布
    }
}

然后在布局文件中添加ShutterView节点,并在对应的活动页面调用setOriention方法设置百叶窗的方向,调用setLeafCount方法设置百叶窗的叶片数量。再利用属性动画渐进设置true属性,使整个百叶窗的各个叶片逐步合上,从而实现百叶窗的动画效果。播放百叶窗动画的示例代码如下:

// 构造一个按比率逐步展开的属性动画
ObjectAnimator anim = ObjectAnimator.ofInt(sv_shutter, "ratio", 0, 100);
anim.setDuration(3000); // 设置动画的播放时长
anim.start(); // 开始播放属性动画

运行测试App,可观察到百叶窗动画的播放效果如下图所示。其中,左侧为开始播放时的画面,右侧为播放即将结束时的画面。
在这里插入图片描述
基于同样的绘制原理,可以依样画瓢实现马赛克动画,其中马赛克视图的代码片段如下:

public class MosaicView extends View {
    private final static String TAG = "MosaicView";
    private Paint mPaint = new Paint(); // 声明一个画笔对象
    private int mOriention = LinearLayout.HORIZONTAL; // 动画方向
    private int mGridCount = 20; // 格子的数量
    private PorterDuff.Mode mMode = PorterDuff.Mode.DST_IN; // 绘图模式为只展示交集
    private Bitmap mBitmap; // 声明一个位图对象
    private int mRatio = 0; // 绘制的比率
    private int mOffset = 5; // 偏差的比例
    private float FENMU = 100; // 计算比例的分母,其实分母的英语叫做denominator

    public MosaicView(Context context) {
        this(context, null);
    }

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

    // 设置马赛克的方向
    public void setOriention(int oriention) {
        mOriention = oriention;
    }

    // 设置马赛克的格子数量
    public void setGridCount(int grid_count) {
        mGridCount = grid_count;
    }

    // 设置偏差比例
    public void setOffset(int offset) {
        mOffset = offset;
    }

    // 设置绘图模式
    public void setMode(PorterDuff.Mode mode) {
        mMode = mode;
    }

    // 设置位图对象
    public void setImageBitmap(Bitmap bitmap) {
        mBitmap = bitmap;
    }

    // 设置绘图比率
    public void setRatio(int ratio) {
        mRatio = ratio;
        postInvalidate(); // 立即刷新视图(线程安全方式)
    }

    @Override
    protected void onDraw(Canvas canvas) {
        if (mBitmap == null) {
            return;
        }
        int width = getMeasuredWidth(); // 获取视图的实际宽度
        int height = getMeasuredHeight(); // 获取视图的实际高度
        // 创建一个遮罩位图
        Bitmap mask = Bitmap.createBitmap(width, height, mBitmap.getConfig());
        Canvas canvasMask = new Canvas(mask); // 创建一个遮罩画布
        if (mOriention == LinearLayout.HORIZONTAL) { // 水平方向
            float grid_width = height / mGridCount;
            int column_count = (int) Math.ceil(width / grid_width);
            int total_count = mGridCount * column_count;
            int draw_count = 0;
            for (int i = 0; i < column_count; i++) {
                for (int j = 0; j < mGridCount; j++) {
                    int now_ratio = (int) ((mGridCount * i + j) * FENMU / total_count);
                    if (now_ratio < mRatio - mOffset
                            || (now_ratio >= mRatio - mOffset && now_ratio < mRatio &&
                            ((j % 2 == 0 && i % 2 == 0) || (j % 2 == 1 && i % 2 == 1)))
                            || (now_ratio >= mRatio && now_ratio < mRatio + mOffset &&
                            ((j % 2 == 0 && i % 2 == 1) || (j % 2 == 1 && i % 2 == 0)))) {
                        int left = (int) (grid_width * i);
                        int top = (int) (grid_width * j);
                        // 在遮罩画布上绘制各方形格子
                        canvasMask.drawRect(left, top, left + grid_width, top + grid_width, mPaint);
                        if (j < mGridCount) {
                            draw_count++;
                        }
                        if (draw_count * FENMU / total_count > mRatio) {
                            break;
                        }
                    }
                }
                if (draw_count * FENMU / total_count > mRatio) {
                    break;
                }
            }
        } else { // 垂直方向
            float grid_width = width / mGridCount;
            int row_count = (int) Math.ceil(height / grid_width);
            int total_count = mGridCount * row_count;
            int draw_count = 0;
            for (int i = 0; i < row_count; i++) {
                for (int j = 0; j < mGridCount; j++) {
                    int now_ratio = (int) ((mGridCount * i + j) * FENMU / total_count);
                    if (now_ratio < mRatio - mOffset
                            || (now_ratio >= mRatio - mOffset && now_ratio < mRatio &&
                            ((j % 2 == 0 && i % 2 == 0) || (j % 2 == 1 && i % 2 == 1)))
                            || (now_ratio >= mRatio && now_ratio < mRatio + mOffset &&
                            ((j % 2 == 0 && i % 2 == 1) || (j % 2 == 1 && i % 2 == 0)))) {
                        int left = (int) (grid_width * j);
                        int top = (int) (grid_width * i);
                        // 在遮罩画布上绘制各方形格子
                        canvasMask.drawRect(left, top, left + grid_width, top + grid_width, mPaint);
                        if (j < mGridCount) {
                            draw_count++;
                        }
                        if (draw_count * FENMU / total_count > mRatio) {
                            break;
                        }
                    }
                }
                if (draw_count * FENMU / total_count > mRatio) {
                    break;
                }
            }
        }
        // 设置离屏缓存
        int saveLayer = canvas.saveLayer(0, 0, width, height, null, Canvas.ALL_SAVE_FLAG);
        Rect rect = new Rect(0, 0, width, width * mBitmap.getHeight() / mBitmap.getWidth());
        canvas.drawBitmap(mBitmap, null, rect, mPaint); // 绘制目标图像
        mPaint.setXfermode(new PorterDuffXfermode(mMode)); // 设置混合模式
        canvas.drawBitmap(mask, 0, 0, mPaint); // 再绘制源图像的遮罩
        mPaint.setXfermode(null); // 还原混合模式
        canvas.restoreToCount(saveLayer); // 还原画布
    }
}

在布局文件中添加MosaicView节点,并在对应的活动页面调用setGridCount方法设置马赛克的格子数量,再利用动画的渐进设置ratio属性,使得视图中的马赛克逐步清晰显现。下面是播放马赛克动画的示例代码:

// 起始值和结束值要超出一些范围,这样头尾的马赛克看起来才是连贯的
int offset = 5;
mv_mosaic.setOffset(offset); // 设置偏差比例
// 构造一个按比率逐步展开的属性动画
ObjectAnimator anim = ObjectAnimator.ofInt(mv_mosaic, "ratio", 0 - offset, 101 + offset);
anim.setDuration(3000); // 设置动画的播放时长
anim.start(); // 开始播放属性动画

运行测试App,选择不同效果可观察到马不同赛克动画的播放,下图效果为水平三十格显示效果。
在这里插入图片描述

利用滚动器实现平滑翻页

在日常生活中,平移动画较为常见,有时也被称为位移动画。左右翻页和上下滚动其实都用到了平移动画,当然对于滚动视图、列表视图、翻页视图这些常用控件,Android已经实现了位移动画,无须开发者劳心劳力。如果开发者自定义新的控件,就得自己编写这部分的滚动特效。
譬如平滑翻书的动画效果,就是位移动画的一种应用。用户先通过手势拉动书页,不等拉到底就松开手指,此时App需要判断当前书页是继续向前滚动还是往后缩回去。倘若书页的拉动距离超过屏幕宽度一半,那么无疑应当继续前滚动到底;倘若书页的拉动距离尚未达到屏幕宽度的一半,那么应当往相反方向缩回去。对于这种向前滚动抑或向后滚动的判断处理,除了利用补间动画之外,还能借助滚动器(Scroller)加以实现。
滚动器不但可以实现平滑滚动的效果,还能解决拖拽卡顿问题。下面是滚动器的常用方法:

  • startScroll:设置开始滑动参数,包括起始的横、纵坐标,横、纵坐标偏移量和滑动的持续时间。
  • computeScrollOffset:计算滑动偏移量。返回值可判断滑动是否结束,返回false表示滑动结束,返回true表示还在滑动中。
  • getCurrX:获得当前的横坐标。
  • getCurrY:获取当前的纵坐标。
  • getFinalX:获得最终的横坐标。
  • getFinalY:获得最终的纵坐标。
  • getDuration:获得滑动的持续时间。
  • forceFinished:强行停止滑动。
  • isFinished:判断滑动是否结束。返回false表示还未结束,返回true表示滑动结束。该方法与computeScrollOffset的区别在于:
    • computeScrollOffset方法会在内部计算偏移量,isFinished方法只返回是否结束表示,而不做其他处理。
    • computeScrollOffset方法返回false表示滑动结束,isFinished方法返回true表示滑动结束。

仍以平滑翻书为例,在自定义的滚动布局中,需要重写onTouchEvent方法,分别记录手势按下和松开时对应的起点和终点,在计算两点在水平方向上的为位移是否超过屏幕宽度的一半。超过则往前翻页,未超过则往后面缩回,不管是前翻还是后缩,都得调用滚动器的startScroll方法执行滚动操作。同时重写布局的computeScroll方法,根据当前的滚动距离设置书页的偏移量,并在滚到终点时结束滚动操作。据此编写的滚动布局示例代码如下:

public class ScrollLayout extends LinearLayout {
    private Scroller mScroller; // 声明一个滚动器对象
    private PointF mOriginPos; // 按下手指时候的起始点坐标
    private int mLastMargin = 0; // 上次的空白间隔
    private ImageView iv_scene; // 声明一个图像视图对象
    private Bitmap mBitmap; // 声明一个位图对象
    private boolean isScrolling = false; // 是否正在滚动

    public ScrollLayout(Context context) {
        this(context, null);
    }

    public ScrollLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        // 创建一个基于线性插值器的滚动器对象
        mScroller = new Scroller(context, new LinearInterpolator());
        mBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.bj06);
        LayoutParams params = new LayoutParams(
                LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
        iv_scene = new ImageView(context);
        iv_scene.setLayoutParams(params); // 设置图像视图的布局参数
        iv_scene.setImageBitmap(mBitmap); // 设置图像视图的位图对象
        addView(iv_scene); // 把演示图像添加到当前视图之上
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int viewWidth = getMeasuredWidth(); // 获取视图的实际宽度
        int ivHeight = viewWidth * mBitmap.getHeight() / mBitmap.getWidth();
        LayoutParams params = (LayoutParams) iv_scene.getLayoutParams();
        params.height = ivHeight; // 根据位图的尺寸,调整图像视图的高度
        iv_scene.setLayoutParams(params);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (!mScroller.isFinished() && isScrolling) { // 正在滚动则忽略触摸事件
            return super.onTouchEvent(event);
        }
        PointF nowPos = new PointF(event.getX(), event.getY());
        if (event.getAction() == MotionEvent.ACTION_DOWN) { // 按下手指
            mOriginPos = new PointF(event.getX(), event.getY());
        } else if (event.getAction() == MotionEvent.ACTION_MOVE) { // 移动手指
            moveView(mOriginPos, nowPos); // 把视图从起点移到终点
        } else if (event.getAction() == MotionEvent.ACTION_UP) { // 松开手指
            if (moveView(mOriginPos, nowPos)) { // 需要继续滚动
                isScrolling = true;
                judgeScroll(mOriginPos, nowPos); // 判断滚动方向,并发出滚动命令
            }
        }
        return true;
    }

    // 把视图从起点移到终点
    private boolean moveView(PointF lastPos, PointF thisPos) {
        int offsetX = (int) (thisPos.x-lastPos.x);
        LayoutParams params = (LayoutParams) iv_scene.getLayoutParams();
        params.leftMargin = mLastMargin + offsetX;
        params.rightMargin = -mLastMargin - offsetX;
        if (Math.abs(params.leftMargin) < iv_scene.getMeasuredWidth()) { // 还没滚到底,继续滚动
            iv_scene.setLayoutParams(params); // 设置图像视图的布局参数
            iv_scene.postInvalidate(); // 立即刷新视图(线程安全方式)
            return true;
        } else { // 已经滚到底了,停止滚动
            return false;
        }
    }

    // 判断滚动方向,并发出滚动命令
    private void judgeScroll(PointF lastPos, PointF thisPos) {
        int offsetX = (int) (thisPos.x-lastPos.x);
        if (Math.abs(offsetX) < iv_scene.getMeasuredWidth()/2) { // 滚回原处
            mScroller.startScroll(offsetX, 0, -offsetX, 0, 1000);
        } else if (offsetX >= iv_scene.getMeasuredWidth()/2) { // 滚到右边
            mScroller.startScroll(offsetX, 0, iv_scene.getMeasuredWidth()-offsetX, 0, 1000);
        } else if (offsetX <= -iv_scene.getMeasuredWidth()/2) { // 滚到左边
            mScroller.startScroll(offsetX, 0, -iv_scene.getMeasuredWidth()-offsetX, 0, 1000);
        }
    }

    // 在滚动器滑动过程中不断触发,用于计算当前的视图偏移位置
    @Override
    public void computeScroll() {
        if (mScroller.computeScrollOffset() && isScrolling) { // 尚未滚动完毕
            LayoutParams params = (LayoutParams) iv_scene.getLayoutParams();
            params.leftMargin = mLastMargin + mScroller.getCurrX();
            params.rightMargin = -mLastMargin - mScroller.getCurrX();
            iv_scene.setLayoutParams(params); // 设置图像视图的布局参数
            if (mScroller.getFinalX() == mScroller.getCurrX()) { // 已经滚到终点了
                isScrolling = false;
                mLastMargin = params.leftMargin;
            }
        }
    }
}

在布局文件添加ScrollLayout节点,运行该App后尝试左滑与右滑屏幕,可观察到平滑翻书效果如下图所示。其中,左侧图片为松开手指时的画面,此时拉动距离超过屏幕一半;右侧图片为书页滚动即将结束时的画面,图片朝向方向继续滚动。
在这里插入图片描述

工程源码

文章涉及所有代码可点击工程源码下载。

  • 27
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值