本章介绍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步:
- 调用ImageDecoder的createSource方法,从指定地方获取数据源。
- 调用ImageDecoder的decodeDrawable方法,从数据源解码得到Drawable类型的图形信息。
- 调用图像视图的setImageDrawable方法,设置图像视图的图形对象。
- 判断解码得到的图形对象是否为Animatable类型,如果是的话,就调用start方法播放动画。
其中步骤1的createSource方法允许从多种来源读取图像信息,包括但不限于下列来源:
- 来自存储卡的File对象。
- 来自系统相册的Uri对象。
- 来自资源图片的图形编号。(图片放在res/raw目录下,图形编号形如R.raw.***)
- 从输入流获取的字节数组。
举个例子,现在准备通过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出自不同的厂商,但它俩都具备了下列优异特异性:
- 支持透明背景:JPEG不支持透明背景。
- 支持动画效果:JPEG和PNG不支持动画效果。
- 支持有损压缩: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的常用方法:
- 构造方法:指定过度图形的图形数组。该图形数组大小为2,包含前后两张图形。
- startTransition:开始过度操作。这里需要先设置宿主视图再进行渐变显示。
- resetTransition:重置过度操作。
- reverseTransition:倒过来执行过度操作。
下面是使用过度图形的代码片段:
// 开始播放淡入淡出动画
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的常用方法:
- setFillAfter:设置是否维持结束画面。true表示动画结束后停留在结束画面,false表示动画结束后恢复到开始画面。
- setRepeatMode:设置动画的重播模式。Animation.RESTART表示从头开始,Animation.REVERSE表示倒过来播放。默认为Animation.RESTART。
- setRepeatCount:设置动画的重播次数。默认值为0,表示只播放一次;值为Animation.INFINITE时表示持续重播。
- setDuration:设置动画的持续时间,单位为毫秒。
- setInterpolator:设置动画的插值器。
- setAnimationListener:设置动画监听器。需要实现接口AnimationListener的三个方法:
- onAnimationStart:在动画开始时触发。
- onAnimationEnd:在都规划结束时触发。
- onAnimationRepeat:在动画重播时触发。
与帧动画一样,补间动画也需要找一个宿主视图,对宿主视图施展动画效果。不同的是,帧动画的宿主视图只能是由ImageView派生出来的视图家族(图像视图、图像按钮等),而补间动画的宿主视图可以是任意意图视图,只要派生自View类就行。给补间动画指定宿主视图的方式很简单,调用宿主对象的startAnimation方法即可命令宿主视图开始播放动画,调用宿主对象的clearAnimation方法即可要求宿主视图清除动画。
具体到每种补间动画又有不同的初始化方式。下面来看具体说明。
- 初始化灰度动画:在构造方法种指定视图透明度的前后数值,取值0.0~1.0(0表示完全不透明,1表示完全透明)。
- 初始化水平移动画:在构造方法中指定视图在平移前后左上角的坐标值。其中,第一个参数为平移前的横坐标,第二个参数为平移后的横坐标,第三个参数为平移前的纵坐标,第四个参数为平移后的纵坐标。
- 初始化缩放动画:在构造方法中指定视图纵横坐标的前后缩放比例。缩放比例取值0.5时表示缩小到原来的二分之一,取值为2时表示放大到原来的两倍。其中,第一个参数为缩放前的横坐标比例,第二个参数为缩放后的很坐标比例,第三个参数为缩放前的纵坐标比例,第四个参数为缩放后的纵坐标比例。
- 初始化旋转动画:在构造方法中指定视图的旋转角度。其中,第一个参数为旋转前的角度,第二个参数为旋转后的角度,第三个参数为圆心的横坐标类型,第四个参数为圆心横坐标的数值比例,第五个参数为圆心的纵坐标类型,第六个参数为圆心纵坐标的数值比例。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的处理大同小异,对用剑状态的视图变换处理不外乎以下两个步骤:
- 根据插值时间计算当前的状态值(如灰度、平移距离、缩放比例、旋转角度等)。
- 在宿主主视图上使用该状态值执行变换操作。
如此看来,补间动画的关键在于利用插值时间计算状态值。现在回头看看中百的左右摆动,这个摆动操作其实由3段旋转动画构成。
- 以上面的端点为圆心,钟摆以垂直向下的状态向左旋转,转到左边的某个角度停止(比如左转60°)。
- 钟摆从左边向右边旋转,转到右边的某个角度停住(比如右转120°,与垂直方向的夹角为60°)。
- 钟摆从右边再向左旋转,当其摆到垂直方向时完成一个周期的摇摆动作。
清楚了摇摆动画的运动过程后,接下来根据插值时间计算对应角度,具体到代码实现上需要做以下两处调整:
- 旋转动画初始化时只有两个度数,即起始角度和终止角度。摇摆动画需要3个参数,即中间角度(即是起始角度也是终止角度)、摆到左侧的角度和摆到右侧的角度。
- 根据插值时间估算当前所处的角度。对于摇摆动画来说,需要做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。
- setRepeatMode:设置动画的重播模式。ValueAnimator.RESTART表示从头开始,ValueAnimator.REVERSE表示倒过来播放。默认值为ValueAnimator.RESTART。
- setRepeatCount:设置动画的重播次数。默认值为0,表示只播放一次;值为ValueAnimator.INFINITE时表示持续重播。
- setDuration:设置动画的持续播放时间,单位为毫秒。
- setInterpolator:设置动画的插值器。
- setEvaluator:设置动画的估值器。
- start:开始播放动画。
- cancel:取消播放动画。
- end:结束播放动画。
- pause:暂停播放动画。
- resume:恢复播放动画。
- reverse:倒过来播放动画。
- isRunning:判断动画是否在播放。注意,暂停时,isRunning方法仍然返回true。
- isPaused:判断动画是否被暂停。
- isStarted:判断动画是否已经开始。注意,曾经播放与正在播放都算已经开始。
- addListener:添加动画监听器,需要实现接口AnimatorListener的4个方法。
- onAnimationStart:在动画开始播放时触发。
- onAnimationEnd:在都规划结束时触发。
- onAnimationCancel:在动画取消播放时触发。
- onAnimationRepeat:在动画重播时触发。
- removeListener:移除指定的动画监听器。
- removeAllListeners:移除所有动画监听器。
下面是使用属性动画分别实现透明度、平移、缩放、旋转、裁剪等变换操作的示例代码:
// 声明四个属性动画对象
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方法重构属性动画对象,就必须指定该属性的估值器,否则系统不知道如何计算渐变属性值。为方便记忆属性动画的构造方法与估值器的关联关系,下表列出了两者之间的对应关系。
属性动画的构造方法 | 估值器 | 对应的属性说明 |
---|---|---|
ofInt | IntEvaluator | 证书类型的属性 |
ofFloat | FloatEvaluator | 大部分状态属性,如alpha、rotation、scaleY、translationX、textSize等 |
ofArgb | ArgbEvaluator | 颜色,如backgroundColor、textColor等 |
ofObject | RectEvaluator | 裁剪范围,如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查看。
利用估值器实现打赏动画
贝塞尔曲线又叫贝济埃曲线,是一种用于二维图形的数学曲线。贝塞尔曲线由节点和线段构成,其中节点是可拖动的支点,而线段仿佛有弹性的牛皮筋。譬如上班族每天两点一线,一个端点是家,另一个端点是单位,那么从家到单位存在一条通勤路线,该路线弯弯曲曲在大街小巷之间延伸。这个上班路线无疑由许多条折线连接而成,既无规律也无美感,无法通过简洁的数学公式来表达。为此法国数学家贝塞尔研究出一种曲线,除了起点和终点之外,不再描绘中间的折线,而是构建一段运输小球的控制线,控制线本身在移动,然后小球随着在控制线上滑动,小球从起点运动到终点的轨迹便形成了贝塞尔曲线。
贝塞尔曲线又分为以下三类曲线:
-
一次贝塞尔曲线
此时曲线只是一条两点间的线段,它的函数公式为:B(t) = (1 - t) * P0 + t * P1,其中 t 是参数,取值范围是 [0, 1]。 -
二次贝塞尔曲线
此时除了起点和终点,曲线还存在一个控制点,它的函数公式为:B(t) = (1 - t)^2 * P0 + 2 * (1 - t) * t * P1 + t^2 * P2,其中 t 是参数,取值范围是 [0, 1]。
二次贝塞尔曲线的小球运动轨迹如下图所示。
-
三次贝塞尔曲线
此时除了起点和终点,曲线还存在两个控制点,它的函数公式为: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中有个常见的应用,就像时兴的给主播打赏礼物,点击爱心打赏之后,礼物图标会在屏幕上走出一条优雅的漂移曲线。这个漂移曲线在前进途中左右摇摆,不拘一格款款前行。
具体到代码上,可将漂移动画的实现步骤分解为下列几项:
- 创建一个缩放动画,让礼物图标在爱心处从小变大,呈现礼物孵化效果。
- 创建一个属性动画,指定礼物漂移的起点和终点,并在动画过程中动态改变贝塞尔的控制点。
- 定义一个添加打赏的方法,该方法先把礼物图标添加到视图上,再依次播放前两部的缩放动画和属性动画。
按照以上步骤的描述,自定义打赏视图的示例代码如下:
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后尝试左滑与右滑屏幕,可观察到平滑翻书效果如下图所示。其中,左侧图片为松开手指时的画面,此时拉动距离超过屏幕一半;右侧图片为书页滚动即将结束时的画面,图片朝向方向继续滚动。
工程源码
文章涉及所有代码可点击工程源码下载。