前言:你的问题在于读书不多而想得太多 。 -------杨绛
没想到2019年的第一篇文章是在情人节这天更新了,回顾2018年,觉得自己花在健身房的时间太多了,反而在专业上面没有那么用心,2019年还是保持初心,一步一个脚印按时更新专业方面的技能点,做到劳逸结合,厚积薄发,与你们共勉。
属性动画的知识大家可以看看郭霖的三篇属性动画理论知识,已经属于非常全面的了。所以属性动画这块打算举一些例子,并结合设计模式分析一下属性动画的源码。本文实现一个数据加载动效,先看 gif 实现效果图:
那么就一点点带领大家实现这个效果吧。
一、实现“红、黄、蓝”三个图形的切换效果
1、实现基本自定义View
由于很简单,就直接把代码贴在下面了:
首先自定义 View 代码:
public class ShapeView extends View {
private static String TAG = ShapeView.class.getSimpleName();
public enum ShapeType{
Circular,//圆形
Square,//正方形
Triangle//三角形
}
//默认图形
private ShapeType mCurrentShape = Circular;
private Paint mPaint;
private Path mPath;
public ShapeView(Context context) {
this(context,null);
}
public ShapeView(Context context, AttributeSet attrs) {
this(context, attrs,0);
}
public ShapeView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mPaint = new Paint();
mPaint.setAntiAlias(true);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int width = MeasureSpec.getSize(widthMeasureSpec);
int height = MeasureSpec.getSize(heightMeasureSpec);
//设置控件的大小就为手动设置的大小
setMeasuredDimension(Math.min(width,height),Math.min(width,height));
}
/**
* 根据当前枚举类型绘制对应图形
* @param canvas
*/
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
switch (mCurrentShape) {
case Circular:
//绘制圆形
int center = getWidth() / 2;
mPaint.setColor(Color.RED);
canvas.drawCircle(center,center,center,mPaint);
break;
case Square:
//绘制正方形
mPaint.setColor(Color.BLUE);
canvas.drawRect(0, 0, getWidth(), getWidth(), mPaint);//直接构造
break;
case Triangle:
//绘制三角形
mPaint.setColor(Color.YELLOW);
if (mPath == null) {
// 画路径
mPath = new Path();
mPath.moveTo(getWidth() / 2, 0);
mPath.lineTo(0, (float) ((getWidth()/2)*Math.sqrt(3)));
mPath.lineTo(getWidth(), (float) ((getWidth()/2)*Math.sqrt(3)));
// path.lineTo(getWidth()/2,0);
mPath.close();// 把路径闭合
}
canvas.drawPath(mPath, mPaint);
break;
}
}
/**
* 改变形状
*/
public void changeShape() {
switch (mCurrentShape) {
case Circular:
mCurrentShape = Square;
break;
case Square:
mCurrentShape = Triangle;
break;
case Triangle:
mCurrentShape = Circular;
break;
}
invalidate();
}
}
上面是自定义 ShapeView,动画里面的图片是绘制上去的。代码很简单,首先,测量出控件的大小,这里仅仅支持布局写死的大小,并且设置为正方形大小。然后是 onDraw() 方法,在这里使用枚举定义了三种状态。分别是:圆形、矩形、方形状态。且在对应状态绘制对应的图形就好了。我们看到有一个改变行状的方法:
/**
* 改变形状
*/
public void changeShape() {
switch (mCurrentShape) {
case Circular:
mCurrentShape = Square;
break;
case Square:
mCurrentShape = Triangle;
break;
case Triangle:
mCurrentShape = Circular;
break;
}
invalidate();
}
这个方法中没有在 View 内部调用,是一个公共的方法给外面调用的。然后判定当前状态,而且修改为别的状态,比如:当前圆形,下一个就是矩形;当前矩形,下一个就是三角......最后调用重绘,系统就会去调用 onDraw 方法再走其中的逻辑。这样就实现了图形的改变。
可能一个地方稍微有一点点“卡壳”的地方就是绘制三角形,我们单独拿出来分析一下:
//绘制三角形
mPaint.setColor(Color.YELLOW);
if (mPath == null) {
// 画路径
mPath = new Path();
mPath.moveTo(getWidth() / 2, 0);
mPath.lineTo(0, (float) ((getWidth()/2)*Math.sqrt(3)));
mPath.lineTo(getWidth(), (float) ((getWidth()/2)*Math.sqrt(3)));
// path.lineTo(getWidth()/2,0);
mPath.close();// 把路径闭合
}
canvas.drawPath(mPath, mPaint);
使用 path 来进行化画路线操作,讲一个:
mPath.lineTo(0, (float) ((getWidth()/2)*Math.sqrt(3)));
x表示相对坐标为0,y=((getWidth()/2)*Math.sqrt(3))
看图解:
这里是需要画一个正三角形,因此 x 边和 y 边夹角是60°。利用正比关系,容易得到计算 y 的公式。
2、测试View改变效果:
(为了测试效果,以下代码不求规范)
在 xml 中:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/activity_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.itydl.property.MainActivity">
<Button
android:onClick="changeShape"
android:text="测试"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<com.itydl.property.view.ShapeView
android:id="@+id/shapeView"
android:layout_centerInParent="true"
android:layout_height="45dp"
android:layout_width="45dp">
</com.itydl.property.view.ShapeView>
</RelativeLayout>
然后在 Activity 中使用:
public class MainActivity extends AppCompatActivity {
private ShapeView mShapeView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mShapeView = (ShapeView) findViewById(R.id.shapeView);
}
public void changeShape(View view){
new Thread(new Runnable() {
@Override
public void run() {
while (true){
SystemClock.sleep(1000);
runOnUiThread(new Runnable() {
@Override
public void run() {
mShapeView.changeShape();
}
});
}
}
}).start();
}
}
这里重要的是按钮点击事件,让其不断循环,每隔1s就调用一次上述 View 的 changeShape 方法(还是注意,这里只是测试功能)。运行效果:
上面动画有点掉帧,实际运行起来效果不是这样的。
二、动画的实现
2.1、先实现下落和回弹效果
代码如下:
public class LoadingView extends LinearLayout {
private final int mTranslationDis;
private View mShadowView;//阴影
private ShapeView mShapeView;//图形View
// 动画执行的时间
private final long ANIMATOR_DURATION = 500;
public LoadingView(Context context) {
this(context,null);
}
public LoadingView(Context context, AttributeSet attrs) {
this(context, attrs,0);
}
public LoadingView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mTranslationDis = dip2px(80);
initLayout();
}
/**
* 初始化组合控件布局
*/
private void initLayout() {
// 第三个参数为this,表示布局解析完毕直接添加到LoadingView中(它是一个扩展的LinearLayout)
inflate(getContext(), R.layout.layout_loading_view, this);
mShadowView = findViewById(R.id.shadowView);
mShapeView = (ShapeView) findViewById(R.id.shapeView);
/**--------- 直接开启动画 ---------**/
post(new Runnable() {
@Override
public void run() {
//让开启动画逻辑在onResume()之后
startPullDownAnimation();
}
});
}
/**
* 开启下落动画
*/
private void startPullDownAnimation() {
ObjectAnimator shapeViewDownAnimator = ObjectAnimator.ofFloat(mShapeView,"TranslationY",0,mTranslationDis);
ObjectAnimator shadowViewDownAnimator = ObjectAnimator.ofFloat(mShadowView,"ScaleX",1.0f,0.3f);
AnimatorSet animatorSet = new AnimatorSet();
animatorSet.setDuration(ANIMATOR_DURATION);
// 下落的速度应该是越来越快,使用加速度插值器
animatorSet.setInterpolator(new AccelerateInterpolator());
animatorSet.playTogether(shapeViewDownAnimator,shadowViewDownAnimator);
animatorSet.start();
animatorSet.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
mShapeView.changeShape();
//开启回弹动画
startSpringBackAnimation();
}
});
}
/**
* 开启弹起动画
*/
private void startSpringBackAnimation() {
ObjectAnimator shapeViewDownAnimator = ObjectAnimator.ofFloat(mShapeView,"TranslationY",mTranslationDis,0);
ObjectAnimator shadowViewDownAnimator = ObjectAnimator.ofFloat(mShadowView,"ScaleX",0.3f,1.0f);
AnimatorSet animatorSet = new AnimatorSet();
animatorSet.setDuration(ANIMATOR_DURATION);
// 下落的速度应该是越来越快,使用加速度插值器
animatorSet.setInterpolator(new DecelerateInterpolator());
animatorSet.playTogether(shapeViewDownAnimator,shadowViewDownAnimator);
animatorSet.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
//开启回弹动画
startPullDownAnimation();
}
@Override
public void onAnimationStart(Animator animation) {
//动画开始,开启旋转动画
startRotateAnimation();
}
});
//开启动画要放在后面,否则onAnimationStart监听不到
animatorSet.start();
}
/**
* 旋转动画。
*/
private void startRotateAnimation() {
switch (mShapeView.getCurrentShape()) {
case Circular:
break;
case Square:
break;
case Triangle:
break;
}
}
private int dip2px(int dip) {
return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,dip,getResources().getDisplayMetrics());
}
}
在这里又重新做了一个 View——LoadingView,这 View 是一个组合控件形式,即加载布局的方式然后把加载的布局放入这个 LoadingView 控件里面。
要加载的布局如下:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:gravity="center"
android:background="#ffffffff"
android:layout_width="match_parent"
android:layout_height="match_parent">
<!--图形变换View-->
<com.itydl.property.view.ShapeView
android:layout_marginBottom="85dp"
android:id="@+id/shapeView"
android:layout_centerInParent="true"
android:layout_height="30dp"
android:layout_width="30dp">
</com.itydl.property.view.ShapeView>
<!--阴影-->
<View
android:id="@+id/shadowView"
android:background="@drawable/shadow_bg"
android:layout_width="40dp"
android:layout_height="3dp"/>
<!--文本-->
<TextView
android:layout_marginTop="10dp"
android:text="正在加载中..."
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
</LinearLayout>
布局还是很简单的,就不细说了。咱们看看自定义 LoadingView 的逻辑:
1、初始化布局和孩子控件
2、同时直接开启下落动画:
private void initLayout() {
// 第三个参数为this,表示布局解析完毕直接添加到LoadingView中(它是一个扩展的LinearLayout)
inflate(getContext(), R.layout.layout_loading_view, this);
mShadowView = findViewById(R.id.shadowView);
mShapeView = (ShapeView) findViewById(R.id.shapeView);
/**--------- 直接开启动画 ---------**/
post(new Runnable() {
@Override
public void run() {
//让开启动画逻辑在onResume()之后
startPullDownAnimation();
}
});
}
注意的是,使用 post 把动画开启在 Activity 的 onResume 之后执行。
3、具体下落动画:
/**
* 开启下落动画
*/
private void startPullDownAnimation() {
ObjectAnimator shapeViewDownAnimator = ObjectAnimator.ofFloat(mShapeView,"TranslationY",0,mTranslationDis);
ObjectAnimator shadowViewDownAnimator = ObjectAnimator.ofFloat(mShadowView,"ScaleX",1.0f,0.3f);
AnimatorSet animatorSet = new AnimatorSet();
animatorSet.setDuration(ANIMATOR_DURATION);
// 下落的速度应该是越来越快,使用加速度插值器
animatorSet.setInterpolator(new AccelerateInterpolator());
animatorSet.playTogether(shapeViewDownAnimator,shadowViewDownAnimator);
animatorSet.start();
animatorSet.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
mShapeView.changeShape();
//开启回弹动画
startSpringBackAnimation();
}
});
}
下落动画使用到了属性动画,这里都是最最进本的使用方式。看到是分别对 mShapeView 做Y轴的平移动画,对 mShadowView 做缩放动画。下落的时候,让 mShadowView 缩小。使用了 animatorSet 让动画同时播放。
需要监听动画状态,当下落动画结束,立即改变当前 ShapeView 的图形效果,然后开启回弹效果:
/**
* 开启弹起动画
*/
private void startSpringBackAnimation() {
ObjectAnimator shapeViewDownAnimator = ObjectAnimator.ofFloat(mShapeView,"TranslationY",mTranslationDis,0);
ObjectAnimator shadowViewDownAnimator = ObjectAnimator.ofFloat(mShadowView,"ScaleX",0.3f,1.0f);
AnimatorSet animatorSet = new AnimatorSet();
animatorSet.setDuration(ANIMATOR_DURATION);
// 下落的速度应该是越来越快,使用加速度插值器
animatorSet.setInterpolator(new DecelerateInterpolator());
animatorSet.playTogether(shapeViewDownAnimator,shadowViewDownAnimator);
animatorSet.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
//开启回弹动画
startPullDownAnimation();
}
@Override
public void onAnimationStart(Animator animation) {
//动画开始,开启旋转动画
startRotateAnimation();
}
});
//开启动画要放在后面,否则onAnimationStart监听不到
animatorSet.start();
}
这块代码跟下落基本很相似,注意点仍然是动画监听。当动画刚开启的时候开启旋转动画,看到旋转动画没有任何逻辑,我们会在下一节单独讲。然后动画结束的时候,在此开启下落动画。这里需要把 animatorSet.start(); 放在监听器的后面,否则动画开启监听拿不到。
此时运行程序看看效果吧:
看到基本效果都快实现了,最后就是完成旋转动画了。
2.2旋转动画
/**
* 旋转动画。
*/
private void startRotateAnimation() {
ObjectAnimator rotationAnimator = null;
switch (mShapeView.getCurrentShape()) {
case Circular:
case Square:
//圆形和方形旋转-180度
rotationAnimator = ofFloat(mShapeView,"rotation",0,180);
break;
case Triangle:
//三角形旋转-120°
rotationAnimator = ObjectAnimator.ofFloat(mShapeView,"rotation",0,-120);
break;
}
rotationAnimator.setDuration(ANIMATOR_DURATION);
rotationAnimator.setInterpolator(new DecelerateInterpolator());
rotationAnimator.start();
}
当处于圆形和方型的时候让 ShapeView 旋转180°,当为三角形的时候旋转-120°。
2.3添加让动画消失的功能
为了模拟更真实的开发环境,在加载网络结束或者失败都要让正在加载的 View 消失,这里同样提供一个消失的方法:
/**
* 清空动画,清空View
* @param visibility
*/
@Override
public void setVisibility(int visibility) {
super.setVisibility(View.INVISIBLE);
mShapeView.clearAnimation();
mShadowView.clearAnimation();
ViewGroup parent = (ViewGroup) getParent();
if(parent != null){
//因为自己装到了父View中了
parent.removeView(this);
//移除自己的Views
removeAllViews();
}
}
发现主要是清空动画和 View 视图。1、清空自己在父 View(也就是 LinearLayout )中;2、清空自己的孩子控件。
然后这个控件如果在 Activity 中使用的话:
mLoadingView = (LoadingView) findViewById(R.id.loadingView);
new Thread(new Runnable() {
@Override
public void run() {
SystemClock.sleep(5000);
runOnUiThread(new Runnable() {
@Override
public void run() {
mLoadingView.setVisibility(View.GONE);
}
});
}
}).start();
模拟数据加载5S钟后 gone 掉加载动画。
此时运行程序:
三、一点点优化
可以看到上面已经完成了开始的功能,但是呢。即使是移除了动画,此时的监听仍然在跑,不信你可以在启动动画里面加一行 log,发现即使 Activity 退出了,仍然在打印 log。那么就会导致 Activity 的实例无法被回收从而导致内存泄漏。只需要加一行代码即可:
加一个标志位:
然后在启动动画开始加上一个判断:
再运行程序,就不会随便打印 log 了。
到此为止,这个动效也就实现完毕了。