前言:你的問(wèn)題在于讀書(shū)不多而想得太多 。 -------楊絳
沒(méi)想到2019年的第一篇文章是在情人節(jié)這天更新了,回顧2018年,覺(jué)得自己花在健身房的時(shí)間太多了,反而在專(zhuān)業(yè)上面沒(méi)有那么用心,2019年還是保持初心,一步一個(gè)腳印按時(shí)更新專(zhuān)業(yè)方面的技能點(diǎn),做到勞逸結(jié)合,厚積薄發(fā),與你們共勉。
屬性動(dòng)畫(huà)的知識(shí)大家可以看看郭霖的三篇屬性動(dòng)畫(huà)理論知識(shí),已經(jīng)屬于非常全面的了。所以屬性動(dòng)畫(huà)這塊打算舉一些例子,并結(jié)合設(shè)計(jì)模式分析一下屬性動(dòng)畫(huà)的源碼。本文實(shí)現(xiàn)一個(gè)數(shù)據(jù)加載動(dòng)效,先看 gif 實(shí)現(xiàn)效果圖:

那么就一點(diǎn)點(diǎn)帶領(lǐng)大家實(shí)現(xiàn)這個(gè)效果吧。
一、實(shí)現(xiàn)“紅、黃、藍(lán)”三個(gè)圖形的切換效果
1、實(shí)現(xiàn)基本自定義View
由于很簡(jiǎn)單,就直接把代碼貼在下面了:
首先自定義 View 代碼:
public class ShapeView extends View {
private static String TAG = ShapeView.class.getSimpleName();
public enum ShapeType{
Circular,//圓形
Square,//正方形
Triangle//三角形
}
//默認(rèn)圖形
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);
//設(shè)置控件的大小就為手動(dòng)設(shè)置的大小
setMeasuredDimension(Math.min(width,height),Math.min(width,height));
}
/**
* 根據(jù)當(dāng)前枚舉類(lèi)型繪制對(duì)應(yīng)圖形
* @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);//直接構(gòu)造
break;
case Triangle:
//繪制三角形
mPaint.setColor(Color.YELLOW);
if (mPath == null) {
// 畫(huà)路徑
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,動(dòng)畫(huà)里面的圖片是繪制上去的。代碼很簡(jiǎn)單,首先,測(cè)量出控件的大小,這里僅僅支持布局寫(xiě)死的大小,并且設(shè)置為正方形大小。然后是 onDraw() 方法,在這里使用枚舉定義了三種狀態(tài)。分別是:圓形、矩形、方形狀態(tài)。且在對(duì)應(yīng)狀態(tài)繪制對(duì)應(yīng)的圖形就好了。我們看到有一個(gè)改變行狀的方法:
/**
* 改變形狀
*/
public void changeShape() {
switch (mCurrentShape) {
case Circular:
mCurrentShape = Square;
break;
case Square:
mCurrentShape = Triangle;
break;
case Triangle:
mCurrentShape = Circular;
break;
}
invalidate();
}
這個(gè)方法中沒(méi)有在 View 內(nèi)部調(diào)用,是一個(gè)公共的方法給外面調(diào)用的。然后判定當(dāng)前狀態(tài),而且修改為別的狀態(tài),比如:當(dāng)前圓形,下一個(gè)就是矩形;當(dāng)前矩形,下一個(gè)就是三角......最后調(diào)用重繪,系統(tǒng)就會(huì)去調(diào)用 onDraw 方法再走其中的邏輯。這樣就實(shí)現(xiàn)了圖形的改變。
可能一個(gè)地方稍微有一點(diǎn)點(diǎn)“卡殼”的地方就是繪制三角形,我們單獨(dú)拿出來(lái)分析一下:
//繪制三角形
mPaint.setColor(Color.YELLOW);
if (mPath == null) {
// 畫(huà)路徑
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 來(lái)進(jìn)行化畫(huà)路線操作,講一個(gè):
mPath.lineTo(0, (float) ((getWidth()/2)*Math.sqrt(3)));
x表示相對(duì)坐標(biāo)為0,y=((getWidth()/2)*Math.sqrt(3))
看圖解:

這里是需要畫(huà)一個(gè)正三角形,因此 x 邊和 y 邊夾角是60°。利用正比關(guān)系,容易得到計(jì)算 y 的公式。
2、測(cè)試View改變效果:
(為了測(cè)試效果,以下代碼不求規(guī)范)
在 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="測(cè)試"
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();
}
}
這里重要的是按鈕點(diǎn)擊事件,讓其不斷循環(huán),每隔1s就調(diào)用一次上述 View 的 changeShape 方法(還是注意,這里只是測(cè)試功能)。運(yùn)行效果:

上面動(dòng)畫(huà)有點(diǎn)掉幀,實(shí)際運(yùn)行起來(lái)效果不是這樣的。
二、動(dòng)畫(huà)的實(shí)現(xiàn)
2.1、先實(shí)現(xiàn)下落和回彈效果
代碼如下:
public class LoadingView extends LinearLayout {
private final int mTranslationDis;
private View mShadowView;//陰影
private ShapeView mShapeView;//圖形View
// 動(dòng)畫(huà)執(zhí)行的時(shí)間
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() {
// 第三個(gè)參數(shù)為this,表示布局解析完畢直接添加到LoadingView中(它是一個(gè)擴(kuò)展的LinearLayout)
inflate(getContext(), R.layout.layout_loading_view, this);
mShadowView = findViewById(R.id.shadowView);
mShapeView = (ShapeView) findViewById(R.id.shapeView);
/**--------- 直接開(kāi)啟動(dòng)畫(huà) ---------**/
post(new Runnable() {
@Override
public void run() {
//讓開(kāi)啟動(dòng)畫(huà)邏輯在onResume()之后
startPullDownAnimation();
}
});
}
/**
* 開(kāi)啟下落動(dòng)畫(huà)
*/
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);
// 下落的速度應(yīng)該是越來(lái)越快,使用加速度插值器
animatorSet.setInterpolator(new AccelerateInterpolator());
animatorSet.playTogether(shapeViewDownAnimator,shadowViewDownAnimator);
animatorSet.start();
animatorSet.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
mShapeView.changeShape();
//開(kāi)啟回彈動(dòng)畫(huà)
startSpringBackAnimation();
}
});
}
/**
* 開(kāi)啟彈起動(dòng)畫(huà)
*/
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);
// 下落的速度應(yīng)該是越來(lái)越快,使用加速度插值器
animatorSet.setInterpolator(new DecelerateInterpolator());
animatorSet.playTogether(shapeViewDownAnimator,shadowViewDownAnimator);
animatorSet.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
//開(kāi)啟回彈動(dòng)畫(huà)
startPullDownAnimation();
}
@Override
public void onAnimationStart(Animator animation) {
//動(dòng)畫(huà)開(kāi)始,開(kāi)啟旋轉(zhuǎn)動(dòng)畫(huà)
startRotateAnimation();
}
});
//開(kāi)啟動(dòng)畫(huà)要放在后面,否則onAnimationStart監(jiān)聽(tīng)不到
animatorSet.start();
}
/**
* 旋轉(zhuǎn)動(dòng)畫(huà)。
*/
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());
}
}
在這里又重新做了一個(gè) View——LoadingView,這 View 是一個(gè)組合控件形式,即加載布局的方式然后把加載的布局放入這個(gè) 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>
布局還是很簡(jiǎn)單的,就不細(xì)說(shuō)了。咱們看看自定義 LoadingView 的邏輯:
1、初始化布局和孩子控件
2、同時(shí)直接開(kāi)啟下落動(dòng)畫(huà):
private void initLayout() {
// 第三個(gè)參數(shù)為this,表示布局解析完畢直接添加到LoadingView中(它是一個(gè)擴(kuò)展的LinearLayout)
inflate(getContext(), R.layout.layout_loading_view, this);
mShadowView = findViewById(R.id.shadowView);
mShapeView = (ShapeView) findViewById(R.id.shapeView);
/**--------- 直接開(kāi)啟動(dòng)畫(huà) ---------**/
post(new Runnable() {
@Override
public void run() {
//讓開(kāi)啟動(dòng)畫(huà)邏輯在onResume()之后
startPullDownAnimation();
}
});
}
注意的是,使用 post 把動(dòng)畫(huà)開(kāi)啟在 Activity 的 onResume 之后執(zhí)行。
3、具體下落動(dòng)畫(huà):
/**
* 開(kāi)啟下落動(dòng)畫(huà)
*/
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);
// 下落的速度應(yīng)該是越來(lái)越快,使用加速度插值器
animatorSet.setInterpolator(new AccelerateInterpolator());
animatorSet.playTogether(shapeViewDownAnimator,shadowViewDownAnimator);
animatorSet.start();
animatorSet.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
mShapeView.changeShape();
//開(kāi)啟回彈動(dòng)畫(huà)
startSpringBackAnimation();
}
});
}
下落動(dòng)畫(huà)使用到了屬性動(dòng)畫(huà),這里都是最最進(jìn)本的使用方式。看到是分別對(duì) mShapeView 做Y軸的平移動(dòng)畫(huà),對(duì) mShadowView 做縮放動(dòng)畫(huà)。下落的時(shí)候,讓 mShadowView 縮小。使用了 animatorSet 讓動(dòng)畫(huà)同時(shí)播放。
需要監(jiān)聽(tīng)動(dòng)畫(huà)狀態(tài),當(dāng)下落動(dòng)畫(huà)結(jié)束,立即改變當(dāng)前 ShapeView 的圖形效果,然后開(kāi)啟回彈效果:
/**
* 開(kāi)啟彈起動(dòng)畫(huà)
*/
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);
// 下落的速度應(yīng)該是越來(lái)越快,使用加速度插值器
animatorSet.setInterpolator(new DecelerateInterpolator());
animatorSet.playTogether(shapeViewDownAnimator,shadowViewDownAnimator);
animatorSet.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
//開(kāi)啟回彈動(dòng)畫(huà)
startPullDownAnimation();
}
@Override
public void onAnimationStart(Animator animation) {
//動(dòng)畫(huà)開(kāi)始,開(kāi)啟旋轉(zhuǎn)動(dòng)畫(huà)
startRotateAnimation();
}
});
//開(kāi)啟動(dòng)畫(huà)要放在后面,否則onAnimationStart監(jiān)聽(tīng)不到
animatorSet.start();
}
這塊代碼跟下落基本很相似,注意點(diǎn)仍然是動(dòng)畫(huà)監(jiān)聽(tīng)。當(dāng)動(dòng)畫(huà)剛開(kāi)啟的時(shí)候開(kāi)啟旋轉(zhuǎn)動(dòng)畫(huà),看到旋轉(zhuǎn)動(dòng)畫(huà)沒(méi)有任何邏輯,我們會(huì)在下一節(jié)單獨(dú)講。然后動(dòng)畫(huà)結(jié)束的時(shí)候,在此開(kāi)啟下落動(dòng)畫(huà)。這里需要把 animatorSet.start(); 放在監(jiān)聽(tīng)器的后面,否則動(dòng)畫(huà)開(kāi)啟監(jiān)聽(tīng)拿不到。
此時(shí)運(yùn)行程序看看效果吧:

看到基本效果都快實(shí)現(xiàn)了,最后就是完成旋轉(zhuǎn)動(dòng)畫(huà)了。
2.2旋轉(zhuǎn)動(dòng)畫(huà)
/**
* 旋轉(zhuǎn)動(dòng)畫(huà)。
*/
private void startRotateAnimation() {
ObjectAnimator rotationAnimator = null;
switch (mShapeView.getCurrentShape()) {
case Circular:
case Square:
//圓形和方形旋轉(zhuǎn)-180度
rotationAnimator = ofFloat(mShapeView,"rotation",0,180);
break;
case Triangle:
//三角形旋轉(zhuǎn)-120°
rotationAnimator = ObjectAnimator.ofFloat(mShapeView,"rotation",0,-120);
break;
}
rotationAnimator.setDuration(ANIMATOR_DURATION);
rotationAnimator.setInterpolator(new DecelerateInterpolator());
rotationAnimator.start();
}
當(dāng)處于圓形和方型的時(shí)候讓 ShapeView 旋轉(zhuǎn)180°,當(dāng)為三角形的時(shí)候旋轉(zhuǎn)-120°。
2.3添加讓動(dòng)畫(huà)消失的功能
為了模擬更真實(shí)的開(kāi)發(fā)環(huán)境,在加載網(wǎng)絡(luò)結(jié)束或者失敗都要讓正在加載的 View 消失,這里同樣提供一個(gè)消失的方法:
/**
* 清空動(dòng)畫(huà),清空View
* @param visibility
*/
@Override
public void setVisibility(int visibility) {
super.setVisibility(View.INVISIBLE);
mShapeView.clearAnimation();
mShadowView.clearAnimation();
ViewGroup parent = (ViewGroup) getParent();
if(parent != null){
//因?yàn)樽约貉b到了父View中了
parent.removeView(this);
//移除自己的Views
removeAllViews();
}
}
發(fā)現(xiàn)主要是清空動(dòng)畫(huà)和 View 視圖。1、清空自己在父 View(也就是 LinearLayout )中;2、清空自己的孩子控件。
然后這個(gè)控件如果在 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();
模擬數(shù)據(jù)加載5S鐘后 gone 掉加載動(dòng)畫(huà)。
此時(shí)運(yùn)行程序:

三、一點(diǎn)點(diǎn)優(yōu)化
可以看到上面已經(jīng)完成了開(kāi)始的功能,但是呢。即使是移除了動(dòng)畫(huà),此時(shí)的監(jiān)聽(tīng)仍然在跑,不信你可以在啟動(dòng)動(dòng)畫(huà)里面加一行 log,發(fā)現(xiàn)即使 Activity 退出了,仍然在打印 log。那么就會(huì)導(dǎo)致 Activity 的實(shí)例無(wú)法被回收從而導(dǎo)致內(nèi)存泄漏。只需要加一行代碼即可:
加一個(gè)標(biāo)志位:

然后在啟動(dòng)動(dòng)畫(huà)開(kāi)始加上一個(gè)判斷:

再運(yùn)行程序,就不會(huì)隨便打印 log 了。
到此為止,這個(gè)動(dòng)效也就實(shí)現(xiàn)完畢了。