自定義動畫(仿Win10加載動畫)


一、源代碼

源代碼及demo

二、背景

先看看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類的單值,也可以是坐標、顏色等

一般與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分別套用此公式

3.4 效果比較

無圖無真相。。。圖來了
這里寫圖片描述
在起點、終點、運行時間都一樣的情況下,三種效果比較:

普通動畫——默認插值器是加速減速插值器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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM