自定義View-波浪動效

栗子醬油餅發表於2018-07-10

1. 概述

最近開始學習自定義View,看到現在公司專案上的一個動畫效果,頓時想到其實可以自己畫,於是就開始著手優(zhuang)化(bi)這個動畫。

動畫如下:

自定義View-波浪動效

其實很簡單對不對,但初學者的我還是要思考一下。

2. 動畫分解

動畫有兩部分,

  • 第一個是背景,這個直接畫bitmap就可以了。
  • 第二個就是這個波浪,我們仔細觀察這個波浪其實是一個有規律的,基於每一個點的原點Y軸方向的不斷拉伸。 但是,每一個點,其拉伸的量不一致,而且時間也有差錯。

根據UI提供的詳細動畫細節,可以知道:

  • 動效總共時間為2S,之後反覆迴圈,每秒幀數為24幀,其中圓形元素控制元件大小為6px,拉伸都是以圓心為拉伸中心點進行拉伸。 0-1s為從左到右的拉伸動畫,1s-2s為從右到左的拉伸動畫,之後為迴圈。

  • 每一個點,都有5種不同的拉伸量,這裡我們把對應的拉伸後的Y的高度命名為:

public float maxHeight;
public float threeHeight;
public float halfHeight;
public float oneHeight;
複製程式碼

其中還有一種拉伸量為0。maxHeight是最大的高度,threeHeight為次最高高度。

列出幾個關鍵幀(約定,從左到右將元素命名為元素1、元素2、元素3、元素4、元素5)
  • 第1幀: 初始化,每一個的點均為 6 px * 6 px

  • 第2幀: 元素1 高7.3px 對應 oneHeight,其他元素保持初始狀態。

  • 第3幀: 元素1 高11px 對應 halfHeight,其他元素保持初始狀態

  • 第4幀: 元素1 高14.7px 對應 threeHeight,元素2 高8.3px 對應 oneHeight,其他元素保持初始狀態。

  • 第5幀: 元素1 高16px 對應 maxHeight,元素2 高15px 對應 halfHeight,其他元素保持初始狀態。

  • 第6幀: 元素1 高14.7px 對應 threeHeight,元素2 高21.7px 對應 threeHeight,元素3 高10.1px 對應 oneHeight,其他元素保持初始狀態。

... 通過觀察,我...先定義一個類,來儲存這些點的四種拉伸量的高度(我們也稱之為狀態)和中心位置的座標:

public class VoiceAnimPoint {
    public int centerX, centerY;
    public float maxHeight;
    public float threeHeight;
    public float halfHeight;
    public float oneHeight;
    public VoiceAnimPoint(int centerX, int centerY, float maxHeight, float threeHeight, float halfHeight, float oneHeight) {
        this.centerX = centerX;
        this.centerY = centerY;
        this.maxHeight = maxHeight;
        this.threeHeight = threeHeight;
        this.halfHeight = halfHeight;
        this.oneHeight = oneHeight;
    }
}
複製程式碼

然後在 onSizeChanged 中初始化這五個點:

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, oldh);
    points = new VoiceAnimPoint[5];
    points[0] = new VoiceAnimPoint(getWidth()/2-24,getHeight()/2,16f,14.7f,11f,7.3f);
    points[1] = new VoiceAnimPoint(getWidth()/2-12,getHeight()/2,24f,21.7f,15f, 8.3f);
    points[2] = new VoiceAnimPoint(getWidth()/2,getHeight()/2,38f,33.9f,22f,10.1f);
    points[3] = new VoiceAnimPoint(getWidth()/2+12,getHeight()/2,24f,21.7f,15f,8.3f);
    points[4] = new VoiceAnimPoint(getWidth()/2+24,getHeight()/2,16f,14.7f,11f,7.3f);
}
複製程式碼

3. 波浪動效實現

對於元素1,我們看到有這樣的一個變化過程:

自定義View-波浪動效
那麼我們可以構造這樣的一個函式:

自定義View-波浪動效
x暫時可以看成是第幾幀,x=0,第1幀時候,y=0,代表原始狀態,x=1,第2幀的時候,y=1,代表元素1 高7.3px 對應 oneHeight,y=2,代表元素1 高11px 對應 halfHeight,y=3,代表元素1 高11px 對應 threeHeight,y=4,代表元素1 高16px 對應 maxHeight,,,

那麼其他的元素呢,我們把他們的變化過程放在一起:

自定義View-波浪動效
發現,每一個元素的變化都是一樣的,只不過是x軸的位移,而相鄰的元素的x相差2,也就是假如 元素1在 oneHeight,x=8 狀態的時候,對應的 相鄰元素2 就是元素1在x-2=6的時候的狀態,也就是 元素2的 threeHeight 狀態,而,元素2相鄰的元素3就是在元素1的x-2-2=4的時候的狀態,為 元素3的 threeHeight,,,如此類推。所以我們只用元素1當前的位置和上面的函式圖,就可以推斷出其他元素的情況。

那麼onDraw中可以這樣來:pointIndex 代表著元素1繪製的第 N 幀,然後依次按照如上所分析的去得到其他元素的對應幀。

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    for (int i = 0; i < points.length; i++) {
        VoiceAnimPoint point = points[i];
        int y = indexChangeFunc(pointIndex - i*2);
        switch (y) {
            case 0:
                canvas.drawLine(point.centerX,point.centerY, point.centerX,point.centerY+0.01f,paint);
                break;
            case 2:
                canvas.drawLine(point.centerX,point.centerY-point.halfHeight/2+pointWidth/2,
                        point.centerX,point.centerY+point.halfHeight/2-pointWidth/2,paint);
                break;
            case 4:
                canvas.drawLine(point.centerX,point.centerY-point.maxHeight/2+pointWidth/2,
                        point.centerX,point.centerY+point.maxHeight/2-pointWidth/2,paint);
                break;
            case 1:
                canvas.drawLine(point.centerX,point.centerY-point.oneHeight/2+pointWidth/2,
                        point.centerX,point.centerY+point.oneHeight/2-pointWidth/2,paint);
                break;
            case 3:
                canvas.drawLine(point.centerX,point.centerY-point.threeHeight/2+pointWidth/2,
                        point.centerX,point.centerY+point.threeHeight/2-pointWidth/2,paint);
                break;
        }
    }
}
複製程式碼

其中的0-4狀態就是上面的函式的Y值,通過X得到相應的Y,而Y則對應則元素的5中狀態,onDraw中就是根據5中狀態去繪製相應的高度:

/**
 * 動畫軌跡其實符合一個函式
 * 這裡傳入對應的x,返回函式的y
 * @param x 位置
 * @return y 4 : 最大, 3:threeHeight, 2: 一半, 1:oneHeight, 0 :0 。
 */
private int indexChangeFunc(int x) {
    if (x<0)
        return 0;
    else if (x<4)
        return x;
    else if (x<8)
        return -x + 8;
    else
        return 0;
}
複製程式碼

4. 幾個關鍵的地方

  • 這裡的波浪條是用drawLine畫,而且上下端是圓角的,所以我們要設定Paint的線帽為圓形,
paint = new Paint();
paint.setAntiAlias(true);
paint.setColor(0xffC2E379);
paint.setStyle(Paint.Style.FILL);
paint.setStrokeWidth(pointWidth);
paint.setStrokeCap(Paint.Cap.ROUND);
複製程式碼

注意的一點,假如你的Paint寬10px,而同時你又設定了線帽為圓形,畫了20px的line,那其實你畫的如下:

自定義View-波浪動效

所以才有上面的onDraw中的,高度要減去線帽:

canvas.drawLine(point.centerX,point.centerY-point.maxHeight/2+pointWidth/2,point.centerX,point.centerY+point.maxHeight/2-pointWidth/2,paint);
複製程式碼
  • 最後還有這個動效要倒敘播放,那麼我們可以讓 pointIndex 自增代表正序播放,pointIndex 自減代表倒敘播放。
@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    canvas.drawBitmap(bgBitmap, getWidth() / 2 - bgBitmap.getWidth() / 2, getHeight() / 2 - bgBitmap.getHeight() / 2, paint);
    for (int i = 0; i < points.length; i++) {
        VoiceAnimPoint point = points[i];
        int y = indexChangeFunc(pointIndex - i*2);
        switch (y) {
            case 0:
                canvas.drawLine(point.centerX,point.centerY, point.centerX,point.centerY+0.01f,paint);
                break;
            case 2:
                canvas.drawLine(point.centerX,point.centerY-point.halfHeight/2+pointWidth/2,
                        point.centerX,point.centerY+point.halfHeight/2-pointWidth/2,paint);
                break;
            case 4:
                canvas.drawLine(point.centerX,point.centerY-point.maxHeight/2+pointWidth/2,
                        point.centerX,point.centerY+point.maxHeight/2-pointWidth/2,paint);
                break;
            case 1:
                canvas.drawLine(point.centerX,point.centerY-point.oneHeight/2+pointWidth/2,
                        point.centerX,point.centerY+point.oneHeight/2-pointWidth/2,paint);
                break;
            case 3:
                canvas.drawLine(point.centerX,point.centerY-point.threeHeight/2+pointWidth/2,
                        point.centerX,point.centerY+point.threeHeight/2-pointWidth/2,paint);
                break;
        }
    }
    if (!isRevert) {
        pointIndex++;
    }
    else {
        pointIndex--;
    }
    if (pointIndex == 23) {
        isRevert = true;
        pointIndex = 17;
    }
    else if (pointIndex == -6) {
        pointIndex = 0;
        isRevert = false;
    }
}
複製程式碼
  • 暴露開啟動畫,暫停動畫的介面,根據動效的時間描述,我們應該每1000/24=42毫秒去重新繪製,也就是呼叫:invalidate()方法。
private Runnable r = new Runnable() {
    @Override
    public void run() {
        VoiceAnimView.this.invalidate();
        VoiceAnimView.this.postDelayed(r, 42);
    }
};
public void startAnim() {
    if (!isStart) {
        isStart = true;
        this.post(r);
    }
}
public void stopAnim() {
    if (isStart) {
        isStart = false;
        this.removeCallbacks(r);
    }
}
複製程式碼
  • 對比原來的動畫 原來的是用lottie直接去實現,可謂方便快捷:

自定義View-波浪動效
載入動畫後,大概用了1M多,每一論的波浪,都吃15%cpu。

用了自定義動畫後:

自定義View-波浪動效
載入動畫後,大概用了不到1M,每一論的波浪,都吃不到5%cpu。

5. 最後

這算是自己的第一個自定義View,可能實現思路上有問題,或者大家有更好的思路,歡迎一起討論! 還有幾個問題:

  • 一般測試動畫效能是怎麼去測試的?上面的測試我覺得有些粗糙
  • 關於優化自定義的動畫效能,也就是那幾點,其中有一點,說的是減少呼叫invalidate()的無引數方法,使用有引數的方法,但我查了一下官方文件,在API21後,有引數的方法就不管用了。還有什麼別的方法優化自己的自定義動畫?

原始碼在此

相關文章