一、源代碼
二、背景
先看看Win10的加載動畫(找了很久才找到):
每次打開電腦都會有這個加載動畫,看上挺cool的,就想着自己能否實現它。
要實現這個動畫?
首先想能否通過自定義SurfaceView控件(界面刷新是通過子線程來完成)來實現。這需要知道某一刻時間,那些小圓點在什么位置。小圓點都在做圓周運動,可以看出除了左上角,可以通過勢能和動能的相互轉化來計算速度。但速度是變化的,如何計算某一個時刻的位置?網上一查,暈,都是微積分,算了吧。
后來,還是使用動畫吧,最后的效果:
動起來:

要玩動畫,自然就得理解動畫的核心部分。如果理解了,就坐電梯直達“動畫分析”。
三、動畫的兩個核心
一般復雜動畫需要自定義TimeInterpolator和TypeEvaluator,前者控制運動的速度,后者控制運動的軌跡。
TypeEvaluator不僅控制運動軌跡,只要有權限且能體現效果的setter屬性,都可以控制,如旋轉、縮放、顏色等;TimeInterpolator能干的事情,它也能干,只是為了方便,把它抽出來了。
這是他倆的關系(詳見參考5):
3.1 TimeInterpolator
TimeInterpolator,名為時間插值器(或時間校正器),用於校正動畫播放的時間。默認是加速減速插值器AccelerateDecelerateInterpolator。
正常情況下,動畫在執行時間duration內,從起點到終點,中間是勻速運動,每一時刻都對應着固定的位置。為了統一,把時間[0, duration]轉換成時間百分比[0, 1],如duration=100ms時,在50ms,應該對應着時間比0.5,且位置在正中間。如下圖紅色線:
說明:
橫軸是實際運行的時間百分比軸(X軸),縱軸是校正后的時間百分比軸(Y軸); 圖中紫色線是經過校正后的時間百分比,在x=0.5時,正常情況是y=0.35,但校正后y不到0.3,也就是說這個時刻所在的位置還不到總路線的1/3。說白了,導數就是速度,紫色線的導數在逐漸變大,表示速度也在逐漸變快,這就是一個加速過程。 X軸是實際運行的時間軸,與Y軸,只有一對一、或一對多的關系,不能出現多對一的情況。一對多的關系就是來回運動的具體體現。
再看TimeInterpolator,是個接口,只有一個方法getInterpolation(float input),方法中的參數對應着X軸,返回值對應着Y軸。
1
2
3
|
<code>
public
interface
TimeInterpolator {
float
getInterpolation(
float
input);
}</code>
|
3.2 TypeEvaluator
TypeEvaluator就是一個估值器。拿代碼說來,明白一點。
1
2
3
|
<code>
public
interface
TypeEvaluator<t> {
public
T evaluate(
float
fraction, T startValue, T endValue);
}</t></code>
|
也是一個接口,有一個方法
evaluate(float fraction, T startValue, T endValue),參數說明:
fraction:時間插值器的校正值 startValue:開始值 endValue:結束值 T:可以是float類的單值,也可以是坐標、顏色等
fraction:時間插值器的校正值 startValue:開始值 endValue:結束值 T:可以是float類的單值,也可以是坐標、顏色等
一般與AnimatorUpdateListener的onAnimationUpdate()方法結合。
3.3 貝塞爾曲線
以下用到了很多二階貝塞爾曲線,具體計算公式如下(詳見參考2):
B(t)=(1?t)2P0+2t(1?t)P1+t2P2,t∈[0,1]
原理:由 P0 至 P1 的連續點 Q0,描述一條線段。
由 P1 至 P2 的連續點 Q1,描述一條線段。
由 Q0 至 Q1 的連續點 B(t),描述一條二次貝塞爾曲線。
經驗:P1-P0為曲線在P0處的切線
另外加兩條經驗:
為了更自然,P1-P2一般情況下也是曲線在P2處的切線,這樣就能算出P1的具體位置 曲線上每個點的坐標x和y,x和y分別套用此公式
為了更自然,P1-P2一般情況下也是曲線在P2處的切線,這樣就能算出P1的具體位置 曲線上每個點的坐標x和y,x和y分別套用此公式
3.4 效果比較
無圖無真相。。。圖來了
在起點、終點、運行時間都一樣的情況下,三種效果比較:
普通動畫——默認插值器是加速減速插值器AccelerateDecelerateInterpolator 自定義插值器動畫——速度效果是先勻速,再做貝塞爾曲線運動(先反向減速后,再正向加速)。如下圖紅線(其他畫圖軟件都沒不好辦,這時還是PS的鋼筆工具好用)
自定義估值器動畫——插值器是默認的(水平方向與普通動畫是一致的),做正弦曲線運動
普通動畫——默認插值器是加速減速插值器AccelerateDecelerateInterpolator 自定義插值器動畫——速度效果是先勻速,再做貝塞爾曲線運動(先反向減速后,再正向加速)。如下圖紅線(其他畫圖軟件都沒不好辦,這時還是PS的鋼筆工具好用)
自定義估值器動畫——插值器是默認的(水平方向與普通動畫是一致的),做正弦曲線運動
三種動畫的代碼:
1
2
3
4
5
6
7
8
9
10
11
|
<code><code>
private
static
final
long
ANIM_DURATION =
5000
;
/**
* 普通動畫
* 差值器默認為AccelerateDecelerateInterpolator
*/
private
void
normalAnim() {
ObjectAnimator oa = ObjectAnimator.ofFloat(v_normal,
"translationX"
,
0
,
300
);
oa.setDuration(ANIM_DURATION);
oa.setRepeatCount(ValueAnimator.INFINITE);
oa.start();
}</code></code>
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
<code><code>
/**
* 自定義插值器的動畫
*/
private
void
interpolatorAnim() {
ObjectAnimator oa = ObjectAnimator.ofFloat(v_interpolator,
"translationX"
,
0
,
300
);
oa.setInterpolator(
new
TimeInterpolator() {
/**
* 獲取插值器的值
* @param input 原生時間比值[0, 1]
* @return 校正后的值
*/
@Override
public
float
getInterpolation(
float
input) {
// 前半段時間為直線(勻速運動),后半段貝塞爾曲線(先反向)
if
(input <
0.5
) {
return
input;
}
// 把貝塞爾曲線范圍[0.5, 1]轉換成[0, 1]范圍
input = (input -
0
.5f) * (
1
-
0
) / (
1
-
0
.5f);
float
tmp =
1
- input;
return
tmp * tmp *
0
.5f +
2
* input * tmp *
0
+ input * input *
1
;
}
});
oa.setDuration(ANIM_DURATION);
oa.setRepeatCount(ValueAnimator.INFINITE);
oa.start();
}</code></code>
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
<code><code>
/**
* 自定義估值器的動畫
*/
private
void
evaluatorAnim() {
ValueAnimator va = ValueAnimator.ofObject(
new
TypeEvaluator<pointf>() {
/**
* 估算結果
*
* @param fraction 由插值器提供的值,∈[0, 1]
* @param startValue 開始值
* @param endValue 結束值
* @return
*/
@Override
public
PointF evaluate(
float
fraction, PointF startValue, PointF endValue) {
PointF p =
new
PointF();
float
distance = endValue.x - startValue.x;
p.x = fraction * distance;
float
halfDistance = distance /
2
;
float
sinX = (
float
) Math.sin(fraction * Math.PI /
0.5
);
p.y = -halfDistance * sinX;
return
p;
}
},
new
PointF(
0
,
0
),
new
PointF(
300
,
0
)
);
va.addUpdateListener(
new
ValueAnimator.AnimatorUpdateListener() {
@Override
public
void
onAnimationUpdate(ValueAnimator animation) {
PointF pointF = (PointF) animation.getAnimatedValue();
v_evaluator.setTranslationX(pointF.x);
v_evaluator.setTranslationY(pointF.y);
}
});
va.setDuration(ANIM_DURATION);
va.setRepeatCount(ValueAnimator.INFINITE);
va.start();
}
</pointf></code></code>
|
再看看原生動畫:
4.1 組成與始末
毫無疑問,是由5個圓點組成。
從哪開始的呢?一般都是從無到有,即從最底部一個一個彈出開始的。經過查看Win10的動畫,每次顯示時確實從此處開始的。
結束,從開始的前一刻,也就是其它點都消失了,最后一個點到達底部的時刻。
4.2 動畫剖析
從上面的動畫核心部分可知,要分析動畫,就要分析其速度變化與運行時間。
先從第一個圓點開始分析。底部起始點為0°,順時針為正,分析每個區間段的速度與時間:
0° ~ 160°:速度變慢,時間0.5s 160° ~ 180°:勻速,時間2s 180° ~ 360°:速度變快,時間1s 360° ~ 520°:速度變慢,時間0.5s 520° ~ 540°:勻速,時間2s 540° ~ 720°:速度變快,時間1s
其中:步驟4-6重復1-3。
第二個以后的圓點,一開始以為在使用延時執行就可以,但發現有問題:動畫需無限次重復執行,延時只在第一次執行時延時。
所以,用時間來模擬延時執行的效果:首先隱藏在起始點,速度為0,然后延時時間(偏移時間offsetMs)到達后就顯示,開始運動了。
最后第一個圓點等待最后一個圓點到達底部的功能也是一樣:到達后隱藏在終點,速度不變且為0,一直等待最后一個圓點到達終點。
這樣,才算一個完整的運動軌跡(這里范圍為實際運行時間):
步驟 |
時間段 |
時間差 |
運動范圍 |
速度變化 |
備注 |
---|---|---|---|---|---|
1 |
0 ~ offsetMs |
offsetMs |
0° ~ 0° |
0 |
隱藏 |
2 |
offsetMs ~ offsetMs + 0.5 |
0.5 |
0° ~ 160° |
減速 |
顯示 |
3 |
offsetMs + 0.5 ~ offsetMs + 2.5 |
2 |
160° ~ 180° |
勻速 |
|
4 |
offsetMs + 2.5 ~ offsetMs + 3.5 |
1 |
180° ~ 360° |
加速 |
|
5 |
offsetMs + 3.5 ~ offsetMs + 4.0 |
0.5 |
360° ~ 520° |
減速 |
|
6 |
offsetMs + 4.0 ~ offsetMs + 6.0 |
2 |
520° ~ 540° |
勻速 |
|
7 |
offsetMs + 6.0 ~ offsetMs + 7.0 |
1 |
540° ~ 720° |
加速 |
|
8 |
offsetMs + 7.0 ~ 8.0 |
1-offsetMs |
720° ~ 720° |
0 |
隱藏 |
其中,步驟5-7運動與步驟2-3重復,也就是實際看到運動效果的幾個步驟。
之后又經過了如下優化:
經過多次卡表發現,一次運行的總時間為7s,但比例還是按上面的來 測試中發現會出現擠壓重疊現象,仔細查看原生動畫,發現在勻速開始和結束的位置,並不都是160°和180°,五個圓點到達勻速的角度在逐漸變小,離開的角度也在逐漸變小。因此需要給每個圓點一個偏移角度 為了進一步模仿動畫在左上角慢慢靠近的追逐效果,上面所述的到達偏移角度要比離開的偏移角度大
這樣,就有了下面軌跡參數的確定(這些參數是我測試出來比較理想的,可以自己去設置更精確的參數):
/**
* 創建動畫
*
* @param view 需執行的控件
* @param index 該控件執行的順序
* @return 該控件的動畫
*/
private Animator createViewAnim(final View view, final int index) {
long duration = 7000; // 一個周期(2圈)一共運行7000ms,固定值
int comeStepAngle = 22; // 到達的間隔角度
int goStepAngle = 16; // 離開的間隔角度
// 最小執行單位時間
final float minRunUnit = duration / 16;
// 最小執行單位時間所占總時間的比例
double minRunPer = minRunUnit / duration;
// 在差值器中實際值(Y坐標值),共8組
final double[] trueRunInOne = new double[]{
0,
0,
160 / 720d - index * comeStepAngle / 720d,
180 / 720d - index * goStepAngle / 720d,
360 / 720d,
520 / 720d - index * comeStepAngle / 720d,
540 / 720d - index * goStepAngle / 720d,
1
};
// 動畫開始的時間比偏移量。剩下的時間均攤到每個圓點上(本應該是length-1,但length效果更好)
final float offset = (float) (index * (16 - 14) * minRunPer / mDotViews.length);
// 在差值器中理論值(X坐標值),與realRunInOne對應
final double[] rawRunInOne = new double[]{
0,
offset + 0,
offset + 1 * minRunPer,
offset + 5 * minRunPer,
offset + 7 * minRunPer,
offset + 8 * minRunPer,
offset + 12 * minRunPer,
offset + 14 * minRunPer
};
}
整個動畫放在了一個自定義RelativeLayout里,圓點是通過代碼動態添加的,圓點背景使用的是shape:
// 2、 添加新控件
for (int i = 0; i < mDotViews.length; i++) {
mDotViews[i] = new View(getContext());
View view = mDotViews[i];
LayoutParams lp = new LayoutParams(dotD, dotD);
// 添加規則:底部 + 水平居中
lp.addRule(ALIGN_PARENT_BOTTOM);
lp.addRule(CENTER_HORIZONTAL);
// 調整位置
if (mHeight > mWidth) {
lp.bottomMargin = (mHeight - mWidth)/2;
}
// 設置旋轉中心點
view.setPivotX(dotR);
view.setPivotY(-(halfSize - dotD));
// 背景
view.setBackgroundResource(R.drawable.shape_dot);
// 修改點的背景顏色
GradientDrawable gradientDrawable = (GradientDrawable) view.getBackground();
gradientDrawable.setColor(dotColor);
view.setVisibility(INVISIBLE);
addView(view, lp);
}
五個圓點的時間百分比軌跡圖如下(這時PS畫圖也不好使了,還是要Android自己來,demo里有源代碼):
五、核心代碼
private Animator createViewAnim(final View view, final int index) {
...
// 各貝塞爾曲線控制點的Y坐標
final float p1_2 = calculateLineY(rawRunInOne[2], trueRunInOne[2], rawRunInOne[3], trueRunInOne[3], rawRunInOne[1]);
final float p1_4 = calculateLineY(rawRunInOne[2], trueRunInOne[2], rawRunInOne[3], trueRunInOne[3], rawRunInOne[4]);
final float p1_5 = calculateLineY(rawRunInOne[5], trueRunInOne[5], rawRunInOne[6], trueRunInOne[6], rawRunInOne[4]);
final float p1_7 = calculateLineY(rawRunInOne[5], trueRunInOne[5], rawRunInOne[6], trueRunInOne[6], rawRunInOne[7]);
// A 創建屬性動畫:繞着中心點旋轉2圈
ObjectAnimator objAnim = ObjectAnimator.ofFloat(view, "rotation", 0, 720);
// B 設置一個周期執行的時間
objAnim.setDuration(duration);
// C 設置重復執行的次數:無限次重復執行下去
objAnim.setRepeatCount(ValueAnimator.INFINITE);
// D 設置差值器
objAnim.setInterpolator(new TimeInterpolator() {
@Override
public float getInterpolation(float input) {
if (input < rawRunInOne[1]) {
// 1 等待開始
return 0;
} else if (input < rawRunInOne[2]) {
if (view.getVisibility() != VISIBLE) {
view.setVisibility(VISIBLE);
}
// 2 底部 → 左上角:貝賽爾曲線1
// 先轉換成[0, 1]范圍
input = calculateNewPercent(rawRunInOne[1], rawRunInOne[2], 0, 1, input);
return calculateBezierQuadratic(trueRunInOne[1], p1_2, trueRunInOne[2], input);
} else if (input < rawRunInOne[3]) {
// 3 左上角 → 頂部:直線
return calculateLineY(rawRunInOne[2], trueRunInOne[2], rawRunInOne[3], trueRunInOne[3], input);
} else if (input < rawRunInOne[4]) {
// 4 頂部 → 底部:貝賽爾曲線2
input = calculateNewPercent(rawRunInOne[3], rawRunInOne[4], 0, 1, input);
return calculateBezierQuadratic(trueRunInOne[3], p1_4, trueRunInOne[4], input);
} else if (input < rawRunInOne[5]) {
// 5 底部 → 左上角:貝賽爾曲線3
input = calculateNewPercent(rawRunInOne[4], rawRunInOne[5], 0, 1, input);
return calculateBezierQuadratic(trueRunInOne[4], p1_5, trueRunInOne[5], input);
} else if (input < rawRunInOne[6]) {
// 6 左上角 → 頂部:直線
return calculateLineY(rawRunInOne[5], trueRunInOne[5], rawRunInOne[6], trueRunInOne[6], input);
} else if (input < rawRunInOne[7]) {
// 7 頂部 → 底部:貝賽爾曲線4
input = calculateNewPercent(rawRunInOne[6], rawRunInOne[7], 0, 1, input);
return calculateBezierQuadratic(trueRunInOne[6], p1_7, trueRunInOne[7], input);
} else {
// 8 消失
if (view.getVisibility() != INVISIBLE) {
view.setVisibility(INVISIBLE);
}
return 1;
}
}
});
return objAnim;
}
/**
* 根據舊范圍,給定舊值,計算在新范圍中的值
*
* @param oldStart 舊范圍的開始值
* @param oldEnd 舊范圍的結束值
* @param newStart 新范圍的開始值
* @param newEnd 新范圍的結束之
* @param value 給定舊值
* @return 新范圍的值
*/
private float calculateNewPercent(double oldStart, double oldEnd, double newStart, double newEnd, double value) {
if ((value < oldStart && value < oldEnd) || (value > oldStart && value > oldEnd)) {
throw new IllegalArgumentException(String.format("參數輸入錯誤,value必須在[%f, %f]范圍中", oldStart, oldEnd));
}
return (float) ((value - oldStart) * (newEnd - newStart) / (oldEnd - oldStart));
}
/**
* 根據兩點坐標形成的直線,計算給定X坐標在直線上對應的Y坐標值
*
* @param x1 起點X坐標
* @param y1 起點Y坐標
* @param x2 終點X坐標
* @param y2 終點Y坐標
* @param x 給定的X坐標
* @return 給定X坐標對應的Y坐標
*/
private float calculateLineY(double x1, double y1, double x2, double y2, double x) {
if (x1 == x2) {
return (float) y1;
}
return (float) ((x - x1) * (y2 - y1) / (x2 - x1) + y1);
}
/**
* 計算貝塞爾二階曲線的X(或Y)坐標值
* 給定起點、控制點、終點的X(或Y)坐標值,和給定時間t(∈[0, 1]),算出此時貝塞爾曲線的X(或Y)坐標值
*
* @param p0 起點值
* @param p1 控制點值
* @param p2 終點值
* @param t 給定的時間
* @return 曲線的位置值
*/
private float calculateBezierQuadratic(double p0, double p1, double p2, @FloatRange(from = 0, to = 1) double t) {
double tmp = 1 - t;
return (float) (tmp * tmp * p0 + 2 * tmp * t * p1 + t * t * p2);
}
public synchronized void setDotColor(int dotColor) {
this.dotColor = dotColor;
for (View view : mDotViews) {
GradientDrawable gradientDrawable = (GradientDrawable) view.getBackground();
gradientDrawable.setColor(dotColor);
}
}
http://www.2cto.com/kf/201610/553402_2.html