一、效果展示
動畫分爲三種狀態:Loading、Success、Fail,可以點擊按鈕切換狀態。
加載後成功的效果如下所示。
加載後失敗的效果如下所示。
二、前置知識
1. ValueAnimator
ValueAnimator是屬性動畫的一種,它不直接改變View的屬性,而是不斷生成一個代表動畫進度的值,用戶通過該值改變View的某些屬性達到動畫的效果。ValueAnimator基本的用法如下。
ValueAnimator anim = ValueAnimator.ofFloat(0, 1); // 動畫的進度爲[0, 1]中某個值
anim.setDuration(1000); // 動畫的時長爲 1s
anim.setRepeatMode(ValueAnimator.RESTART); // 動畫重複時重新開始
anim.setRepeatCount(ValueAnimator.INFINITE); // 動畫重複次數爲無限次
anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
progress = (float) animation.getAnimatedValue(); // 獲取到動畫的當前進度
invalidate(); // 立即重繪當前 View
}
});
anim.start();
程序通過爲ValueAnimator設置監聽來獲取當前的進度progress,這裏的進度爲[0, 1]中的某個float值。如果有一個這樣的需求,要求某個View從透明慢慢變爲顯示,就可以通過view.setAlpha(progress * 255)
來實現這種淡出的效果。
除了AnimatorUpdateListener,ValueAnimator還有個監聽器AnimatorListener,主要用於對動畫的狀態進行監聽,4個方法如下。
public void start() { } // 動畫開始時調用
public void end() { } // 動畫結束時調用
public void cancel() { } // 動畫取消時調用
public void repeat() { } // 動畫重複時調用
2. PathMeasure
PathMeasure用於實現路徑動畫,它能夠截取Path中的一段內容進行顯示。構造方法如下。
參數中的path就是PathMeasure 之後截取的對象,forceClosed只對測量PathMeasure長度的結果有影響,一般設置爲false。
PathMeasure pm = new PathMeasure(Path path, boolean forceClosed);
PathMeasure的常用函數如下。
float getLength(); // 獲取當前段的長度(注意是當前)
boolean nextContour(); // 跳轉到Path的下一條曲線,成功返回true
/**
* 通過startD和stopD來截取Path中的某個片段
* 結果保存至dst
* startWithMoveTo爲true時,截取的path保存原樣
*/
boolean getSegment(float startD, float stopD, Path dst, boolean startWithMoveTo);
如果你還不理解也不要緊,下面讓我們在實戰中去理解ValueAnimator和PathMeasure。
三、Loading動畫
首先來完成Loading動畫,該動畫的主體是一個圓,在動畫的過程中不斷對圓切割,展示其中的一部分。實現加載框的有種做法是在View每次調用onDraw(Canvas)
時調整圓弧的起始角度(startAngle)和掃過的角度(sweepAngle),再根據角度繪製Arc圓弧,但是這種做法有3個問題。第一是不方便控制動畫的時間,也就是duration; 第二是不方便設置插值器,導致動畫一直爲勻速;第三是無法對動畫的各種狀態(開始、結束等)進行監聽。
而ValueAnimator正好能解決上述問題,我們通過ValueAnimator計算動畫的進度,再通過PathMeasure切割圓弧。我們一步一步來,先嚐試把簡單的圓畫出來。
自定義一個PayTestView ,在其中新建一個ValueAnimator,該動畫的進度從0到1,並設置爲無限循環,在監聽器的onAnimationUpdate()
方法中將動畫的進度賦值給mProgress。隨後新建一個PathMeasure,因爲所要截取的動畫爲一個圓,所以新建PathMeasure時傳入一個圓形Path。
public class PayTestView extends View {
private float mProgress; // 代表動畫當前進度
private Paint mBluePaint; // 藍色畫筆
private ValueAnimator mLoadingAnimator;
private PathMeasure mLoadingPathMeasure;
private Path mDstPath; // 保存PathMeasure切割後的內容
public PayTestView(Context context) {
super(context);
init();
}
public PayTestView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}
public PayTestView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
setLayerType(LAYER_TYPE_SOFTWARE, null); // 取消硬件加速
// 畫筆設置
mBluePaint = new Paint(Paint.ANTI_ALIAS_FLAG); // 畫筆抗鋸齒
mBluePaint.setColor(Color.BLUE);
mBluePaint.setStyle(Paint.Style.STROKE);
mBluePaint.setStrokeWidth(10);
mBluePaint.setStrokeCap(Paint.Cap.ROUND);
// 新建 PathMeasure
Path loadingPath = new Path();
loadingPath.addCircle(100, 100, 60, Path.Direction.CW); // CW代表順時針
mLoadingPathMeasure = new PathMeasure(loadingPath, false);
mDstPath = new Path();
// 動畫
mLoadingAnimator = ValueAnimator.ofFloat(0, 1);
mLoadingAnimator.setDuration(1500);
mLoadingAnimator.setRepeatMode(ValueAnimator.RESTART);
mLoadingAnimator.setRepeatCount(ValueAnimator.INFINITE);
mLoadingAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mProgress = (float) animation.getAnimatedValue();
invalidate();
}
});
mLoadingAnimator.start();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
mDstPath.reset();
float stop = mLoadingPathMeasure.getLength() * mProgress;
mLoadingPathMeasure.getSegment(0, stop, mDstPath, true);
canvas.drawPath(mDstPath, mBluePaint);
}
}
可以發現在onDraw()
中使用getSegment(start, stop, ...)
切割圓弧時,起點start永遠是0,而終點是整個圓弧的長度乘當前進度。每次調用onDraw()
時就將從0到當前進度的圓弧切割,因此得到的是圓弧從0度增長到到360度的動畫,並且循環播放。效果如下。
當前動畫中圓弧的起點一直是0,最後的效果比較僵硬,我們嘗試修改動畫的起點。這裏使用啓艦《Anroid自定義控件開發入門與實戰》中的方法,在mProgress <= 0.5
時,start爲0,在mProgress > 0.5
時,start爲mProgress * 2 - 1
。修改onDraw()中的代碼如下:
mDstPath.reset();
float length = mPathMeasure.getLength();
float stop = mProgress * length;
float start = (float) (stop - (0.5 - Math.abs(mProgress - 0.5)) * length);
mPathMeasure.getSegment(start, stop, mDstPath, true);
這裏將start的計算放在了一句代碼中,當然用if-else的方法來做可讀性會更高。最終的效果如下。
這個效果離之前的展示的Loading動畫已經比較接近了,仔細觀察可以發現,最終的Loading動畫只是在當前的動畫上加了一個整體旋轉的效果。我們可以通過旋轉View的畫布(Canvas)來實現,要注意的是,旋轉時必須按照Loading動畫的圓心進行旋轉。
不過對畫布的旋轉也會影響到之後成功/失敗狀態下的動畫,因此在旋轉之前需要將當前的畫布保存,然後在Loading動畫結束之後恢復畫布。
首先定義一個變量標記畫布是否被保存了,因爲畫布只需要保存一次;隨後在進入onDraw()
時判斷畫布是否已經被保存,如果未保存,則保存當前畫布,否則跳過。
public class PayTestView extends View {
// ......
private boolean hasCanvasSaved = false; // 畫布是否已被保存
private int mCurRotate = 0; // 當前畫布旋轉的角度
// ......
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 在 Loading 狀態下 Canvas 會被旋轉, 需要在第一次進入時保存
if (!hasCanvasSaved) {
canvas.save();
hasCanvasSaved = true;
}
// Loading 動畫
// ......
mCurRotate = (mCurRotate + 2) % 360;
canvas.rotate(mCurRotate, 100, 100);
canvas.drawPath(mDstPath, mBluePaint);
}
}
效果如下所示。
此時的Loading動畫已經完成,只不過圓的座標(100, 100)和半徑(60)是固定的。其實我們可以在onSizeChanged()
中獲取當前View的高寬,再去設置圓的座標和半徑。這裏不細說,後面會在整體代碼中貼出。
三、狀態切換
整個支付的動畫包含3種狀態:加載、成功、失敗。那麼繪製時怎麼識別當前的狀態?他們之間的狀態又是怎麼切換的呢?
對於第一個問題,我們可以在onDraw(Canvas)
中根據動畫當前的狀態來繪製不同的圖形。但是要注意在繪製Loading動畫之前保存畫布;在繪製成功或失敗動畫之前將原始的畫布恢復。onDraw()
中的邏輯如下所示。
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 在 Loading 狀態下 Canvas 會被旋轉, 需要在第一次進入時保存
if (!hasCanvasSaved) {
canvas.save();
hasCanvasSaved = true;
}
// 判斷當前動畫的狀態並繪製相應動畫
if (curStatus == STATUS_LOADING) {
// 繪製 Loading 動畫
} else if (curStatus == STATUS_SUCCESS) {
// 如果畫布還未恢復則將其恢復
if (!hasCanvasRestored) {
canvas.restore();
hasCanvasRestored = true;
}
// 繪製 success 動畫
} else if (curStatus == STATUS_FAIL) {
if (!hasCanvasRestored) {
canvas.restore();
hasCanvasRestored = true;
}
// 繪製 fail 動畫
}
}
對於第二個問題,狀態之間的切換需要外部調用,且只能由loading向success或fail切換。因此View中需要一個供外部修改狀態的方法,同時修改狀態後需要停止Loading動畫。
// 將動畫的狀態從 Loading 變爲 success 或 fail
public void setStatus(int status) {
if (curStatus == STATUS_LOADING && status != STATUS_LOADING) {
curStatus = status;
mLoadingAnimator.end();
}
}
Loading動畫結束之後需要開始success動畫或fail動畫,你可以在上述的setStatus()
方法中開始success/fail動畫,也可以爲Loading動畫設置監聽,監聽到其結束時開啓新動畫。這裏使用監聽的方式,如下所示。
mLoadingAnimator.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) { }
@Override
public void onAnimationEnd(Animator animation) {
if (curStatus == STATUS_SUCCESS) {
mSuccessAnimator.start();
} else if (curStatus == STATUS_FAIL) {
mFailAnimator.start();
}
}
@Override
public void onAnimationCancel(Animator animation) { }
@Override
public void onAnimationRepeat(Animator animation) { }
});
瞭解了3種動畫的切換邏輯之後,再來看看成功/失敗動畫的實現。
四、成功動畫
之前的Loading動畫只有一段路徑,就是一個圓。而成功的動畫包含兩段:外部的圓和內部的勾。之前介紹過PathMeasure是可以通過nextContour()
方法從Path中的一段路徑切換到下一段的,因此我們可以構造一個由兩段路徑構成的PathMeasure。
Path successPath = new Path();
successPath.addCircle(100, 100, 60, Path.Direction.CW);
successPath.moveTo(100- 60 * 0.5f, 100- 60 * 0.2f);
successPath.lineTo(100 - 60 * 0.1f, 100 + 60 * 0.4f);
successPath.lineTo(100 + 60 * 0.6f, 100 - 60 * 0.5f);
mSuccessPathMeasure = new PathMeasure(successPath, false);
代碼首先在successPath中添加了外圈的圓,隨後moveTo到勾的起點,lineTo到勾的下方,最後lineTo到勾的終點。很顯然moveTo之前的是第一段路徑,moveTo之後的是第二段路徑。
爲了在ValueAnimator的進度中將兩段路徑分開,新建時進度的範圍設置爲[0, 2]。
mSuccessAnimator = ValueAnimator.ofFloat(0, 2);
// ......
在繪製時,mProgress∈[0, 1]代表外部的圓,mProgress∈[1, 2]代表內部的勾,我們通過一個變量來表示當前在繪製第幾段,初始化時爲1。
private int mSuccessIndex = 1;
繪製代碼如下,當mProgress < 1
時繪製外部的圓,mProgress >= 1
時切換到下一條路徑。代碼在切換路徑之前通過mSuccessPathMeasure.getSegment(0, mSuccessPathMeasure.getLength(), mSuccessDstPath, true)
將第一段路徑完整地繪製了一下。如果不使用這句代碼,第一段的圓繪製出來不是完整的。
if (mProgress < 1) {
float stop = mSuccessPathMeasure.getLength() * mProgress;
mSuccessPathMeasure.getSegment(0, stop, mSuccessDstPath, true);
} else {
if (mSuccessIndex == 1) {
mSuccessIndex = 2;
mSuccessPathMeasure.getSegment(0, mSuccessPathMeasure.getLength(), mSuccessDstPath, true);
mSuccessPathMeasure.nextContour();
}
float stop = mSuccessPathMeasure.getLength() * (mProgress - 1);
mSuccessPathMeasure.getSegment(0, stop, mSuccessDstPath, true);
}
canvas.drawPath(mSuccessDstPath, mBluePaint);
PS:千萬不要在切換路徑時使用if (mProgress == 1)
這種寫法,首先動畫的進度值是float類型的,要判斷float值是否“相等”只能用if (Math.abs(mProgress - 1) < 0.01)
這種方式;其次如果把動畫執行中的所有進度值打印出來,會是這個樣子的:
2019-06-13 00:00:24.842 2224-2224/com.lister.myviews E/TAG: progress = 0.0
2019-06-13 00:00:24.853 2224-2224/com.lister.myviews E/TAG: progress = 5.57065E-4
2019-06-13 00:00:24.869 2224-2224/com.lister.myviews E/TAG: progress = 0.0022275448
......
2019-06-13 00:00:25.604 2224-2224/com.lister.myviews E/TAG: progress = 0.9411293
2019-06-13 00:00:25.621 2224-2224/com.lister.myviews E/TAG: progress = 0.9725146
2019-06-13 00:00:25.637 2224-2224/com.lister.myviews E/TAG: progress = 1.0058903
2019-06-13 00:00:25.656 2224-2224/com.lister.myviews E/TAG: progress = 1.0392599
2019-06-13 00:00:25.675 2224-2224/com.lister.myviews E/TAG: progress = 1.0706271
2019-06-13 00:00:25.693 2224-2224/com.lister.myviews E/TAG: progress = 1.1038773
......
2019-06-13 00:00:26.407 2224-2224/com.lister.myviews E/TAG: progress = 1.9984891
2019-06-13 00:00:26.423 2224-2224/com.lister.myviews E/TAG: progress = 1.9997668
2019-06-13 00:00:26.440 2224-2224/com.lister.myviews E/TAG: progress = 2.0
進度值progress會在多大的範圍內逼近1.0是無法確定的,因此直接判斷進度值是不是小於1比較妥當。
五、完整代碼
fail狀態下的動畫比較簡單,是一個三段的路徑,不再贅述。這裏貼出完整代碼,註釋也比較齊全,如果有不完善的地方還望批評指正。
public class PayAnimatorView extends View {
/**
* 動畫狀態:加載中、成功、失敗
*/
public static final int STATUS_LOADING = 1;
public static final int STATUS_SUCCESS = 2;
public static final int STATUS_FAIL = 3;
/**
* 當前動畫的狀態
*/
private int curStatus;
/**
* loading 動畫變量
*/
private PathMeasure mPathMeasure;
private Path mDstPath;
private int mCurRotate = 0;
private float mProgress;
private boolean hasCanvasSaved = false;
private boolean hasCanvasRestored = false;
/**
* success / Fail 動畫變量
*/
private PathMeasure mSuccessPathMeasure;
private Path mSuccessDstPath;
private PathMeasure mFailPathMeasure;
private Path mFailDstPath;
/**
* 動畫
*/
private ValueAnimator mLoadingAnimator;
private ValueAnimator mSuccessAnimator;
private ValueAnimator mFailAnimator;
private Paint mBluePaint;
private Paint mRedPaint;
private int mCenterX, mCenterY;
public PayAnimatorView(Context context) {
super(context);
init();
}
public PayAnimatorView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}
public PayAnimatorView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mCenterX = w / 2;
mCenterY = h / 2;
int radius = (int) (Math.min(w, h) * 0.3);
// 在獲取寬高之後設置加載框的位置和大小
Path circlePath = new Path();
circlePath.addCircle(mCenterX, mCenterY, radius, Path.Direction.CW);
mPathMeasure = new PathMeasure(circlePath, true);
mDstPath = new Path();
// 設置 success 動畫的 path
Path successPath = new Path();
successPath.addCircle(mCenterX, mCenterY, radius, Path.Direction.CW);
successPath.moveTo(mCenterX - radius * 0.5f, mCenterY - radius * 0.2f);
successPath.lineTo(mCenterX - radius * 0.1f, mCenterY + radius * 0.4f);
successPath.lineTo(mCenterX + radius * 0.6f, mCenterY - radius * 0.5f);
mSuccessPathMeasure = new PathMeasure(successPath, false);
mSuccessDstPath = new Path();
// 設置 fail 動畫的 path
Path failPath = new Path();
failPath.addCircle(mCenterX, mCenterY, radius, Path.Direction.CW);
failPath.moveTo(mCenterX - radius / 3, mCenterY - radius / 3);
failPath.lineTo(mCenterX + radius / 3, mCenterY + radius / 3);
failPath.moveTo(mCenterX + radius / 3, mCenterY - radius / 3);
failPath.lineTo(mCenterX - radius / 3, mCenterY + radius / 3);
mFailPathMeasure = new PathMeasure(failPath, false);
mFailDstPath = new Path();
}
private void init() {
// 取消硬件加速
setLayerType(LAYER_TYPE_SOFTWARE, null);
// 初始化畫筆
mBluePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mBluePaint.setColor(Color.BLUE);
mBluePaint.setStyle(Paint.Style.STROKE);
mBluePaint.setStrokeCap(Paint.Cap.ROUND);
mBluePaint.setStrokeWidth(10);
mRedPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mRedPaint.setColor(Color.RED);
mRedPaint.setStyle(Paint.Style.STROKE);
mRedPaint.setStrokeCap(Paint.Cap.ROUND);
mRedPaint.setStrokeWidth(10);
// 初始化時, 動畫爲加載狀態
curStatus = STATUS_LOADING;
// 新建 Loading 動畫並 start
mLoadingAnimator = ValueAnimator.ofFloat(0, 1);
mLoadingAnimator.setDuration(2000);
mLoadingAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mProgress = (float) animation.getAnimatedValue();
invalidate();
}
});
mLoadingAnimator.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) { }
@Override
public void onAnimationEnd(Animator animation) {
if (curStatus == STATUS_SUCCESS) {
mSuccessAnimator.start();
} else if (curStatus == STATUS_FAIL) {
mFailAnimator.start();
}
}
@Override
public void onAnimationCancel(Animator animation) { }
@Override
public void onAnimationRepeat(Animator animation) { }
});
mLoadingAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
mLoadingAnimator.setRepeatCount(ValueAnimator.INFINITE);
mLoadingAnimator.setRepeatMode(ValueAnimator.RESTART);
mLoadingAnimator.start();
// 新建 success 動畫
mSuccessAnimator = ValueAnimator.ofFloat(0, 2);
mSuccessAnimator.setDuration(1600);
mSuccessAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mProgress = (float) animation.getAnimatedValue();
invalidate();
}
});
// 新建 fail 動畫
mFailAnimator = ValueAnimator.ofFloat(0, 3);
mFailAnimator.setDuration(2100);
mFailAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mProgress = (float) animation.getAnimatedValue();
invalidate();
}
});
}
/**
* 將動畫的狀態從 Loading 變爲 success 或 fail
*/
public void setStatus(int status) {
if (curStatus == STATUS_LOADING && status != STATUS_LOADING) {
curStatus = status;
mLoadingAnimator.end();
}
}
private int mSuccessIndex = 1;
private int mFailIndex = 1;
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 在 Loading 狀態下 Canvas 會被旋轉, 需要在第一次進入時保存
if (!hasCanvasSaved) {
canvas.save();
hasCanvasSaved = true;
}
// 判斷當前動畫的狀態並繪製相應動畫
if (curStatus == STATUS_LOADING) {
mDstPath.reset();
float length = mPathMeasure.getLength();
float stop = mProgress * length;
float start = (float) (stop - (0.5 - Math.abs(mProgress - 0.5)) * length);
mPathMeasure.getSegment(start, stop, mDstPath, true);
// 旋轉畫布
mCurRotate = (mCurRotate + 2) % 360;
canvas.rotate(mCurRotate, mCenterX, mCenterY);
canvas.drawPath(mDstPath, mBluePaint);
} else if (curStatus == STATUS_SUCCESS) {
if (!hasCanvasRestored) {
canvas.restore();
hasCanvasRestored = true;
}
if (mProgress < 1) {
float stop = mSuccessPathMeasure.getLength() * mProgress;
mSuccessPathMeasure.getSegment(0, stop, mSuccessDstPath, true);
} else {
if (mSuccessIndex == 1) {
mSuccessIndex = 2;
mSuccessPathMeasure.getSegment(0, mSuccessPathMeasure.getLength(),
mSuccessDstPath, true);
mSuccessPathMeasure.nextContour();
}
float stop = mSuccessPathMeasure.getLength() * (mProgress - 1);
mSuccessPathMeasure.getSegment(0, stop, mSuccessDstPath, true);
}
canvas.drawPath(mSuccessDstPath, mBluePaint);
} else if (curStatus == STATUS_FAIL) {
if (!hasCanvasRestored) {
canvas.restore();
hasCanvasRestored = true;
}
if (mProgress < 1) {
float stop = mFailPathMeasure.getLength() * mProgress;
mFailPathMeasure.getSegment(0, stop, mFailDstPath, true);
} else if (mProgress < 2) {
if (mFailIndex == 1) {
mFailIndex = 2;
mFailPathMeasure.getSegment(0, mFailPathMeasure.getLength(),
mFailDstPath, true);
mFailPathMeasure.nextContour();
}
float stop = mFailPathMeasure.getLength() * (mProgress - 1);
mFailPathMeasure.getSegment(0, stop, mFailDstPath, true);
} else {
if (mFailIndex == 2) {
mFailIndex = 3;
mFailPathMeasure.getSegment(0, mFailPathMeasure.getLength(),
mFailDstPath, true);
mFailPathMeasure.nextContour();
}
float stop = mFailPathMeasure.getLength() * (mProgress - 2);
mFailPathMeasure.getSegment(0, stop, mFailDstPath, true);
}
canvas.drawPath(mFailDstPath, mRedPaint);
}
}
}