【造輪子系列】仿谷歌語音搜索動畫——VoiceAnimation

轉載注明出處:簡書-十個雨點

谷歌App的語音搜索功能估計很多人都沒用過,沒用過的也沒必要去用它了,因為實際上就類似手機百度,360手機搜索,是一款類瀏覽器產品,沒有太多實用價值。

不過不得不說的是,它的動畫做得相當精致,如果要用一個詞來形容,就是——靈動。先給大家看看效果:

錄音效果,每100ms用setValue()傳一個值
錄音效果,每100ms用setValue()傳一個值
startLoading()效果
startLoading()效果

動圖無法完全展現這個動畫的細微精妙之處,想仔細研究的同學可以自行下載,不過接著往下看,我們會來模擬實現這個效果的。

背景

首先介紹一下語音動畫的一些背景,使用過訊飛語音識別sdk(或者其他語音識別)的人都應該有相關經驗,
在開始錄音以后,訊飛會通過回調函數返回一小段時間內聲音的平均大小。我們使用這個代表聲音大小的值,就可以繪制出各種各樣的動畫,給用戶清晰的反饋。

仔細觀察不難發現,這個動畫中包含了幾個特點:

  1. 波浪的效果,前面的點比后面的點先漲先落
  2. 每個點本身都具有一定的延滯性,在到達一定的高度以后,不會立刻回落,而是停頓一小段時間以后才收縮。
  3. 對變化比較敏感,如果持續大聲說話,動畫會在最高點處不斷震顫,而不會死板的不動

我們先預想一下如何實現這些功能(以下稱為VoiceAnimator):

首先共通的部分是,按一定的時間間隔,將代表聲音大小的值設置給VoiceAnimator,VoiceAnimator則根據這些值來繪制動畫。

而動畫的實現方式有:

  1. 自定義View,通過一個線程來計算每個點的高度,然后統一繪制
  2. 自定義View,通過多個線程分別計算每個點的高度,然后統一繪制
  3. 自定義ViewGroup,每個點都用一個View來表示,通過屬性動畫來實現動畫
  4. 自定義ViewGroup,每個點都用一個View來表示,使用多個線程來手動繪制動畫

其中1和3應該是最容易實現的,又是消耗資源比較少的方法,但是為了得到更好更可控的動畫效果,我采用了第4種方法。下面我就結合源碼介紹一下我是如何實現的。

源碼

VoiceAnimator

自定義ViewGroup取名為VoiceAnimator,其子View叫VoiceAnimationUnit。

外部通過每隔一段時間調用VoiceAnimator.setValue()函數來啟動動畫效果。

實現VoiceAnimator

VoiceAnimator比較簡單,主要是作為ViewGroup包裹住VoiceAnimationUnit,然后對作為單獨點的VoiceAnimationUnit進行統一啟動操作。

實現自定義ViewGroup比View需要多實現一個函數:

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int childCount=getChildCount();
        float totalWidth= (dotsCount*dotsWidth+dotsMargin*(dotsCount +1));
        float backgroundWitdh= (int) backgroundRect.width();
        for (int i=0;i<childCount;i++){
            View childView=getChildAt(i);
            int cl,ct,cr,cb;
            cl= (int) ((backgroundWitdh-totalWidth)/2+dotsMargin*(i+1)+dotsWidth*(i));
            cr= (int) (cl+dotsWidth);
            ct=0;
            cb= (int) Math.max(backgroundRect.height(),totalHeight);
            childView.layout(cl,ct,cr,cb);
        }
    }

onLayout函數的作用是計算出每一個點的位置,然后通過childView.layout(cl,ct,cr,cb)將VoiceAnimationUnit設置到這個位置上。

至于構造函數、attribute屬性、onMeasure等基礎的函數,可以參考我以前寫的
【造輪子系列】一個選擇星期的工具——SweepSelect View

先漲先落的關鍵函數setValue

    private static final int SET_VALUE_ANIMATION_FRAMES_INTERVAL=40;//ms
    private static final int SET_VALUE_ANIMATION_FRAMES_INTERVAL_STEP=5;//ms
    /**
     * 設置當前動畫的幅度值
     * @param targetValue 動畫的幅度,范圍(0,1)
     */
    public void setValue(final float targetValue){
        if (animationMode!=AnimationMode.ANIMATION){
            return;
        }
        if(valueHandler==null){
            return;
        }
        valueHandler.removeCallbacksAndMessages(null);
        valueHandler.post(new Runnable() {
            @Override
            public void run() {
                int changeStep=0;
                while(changeStep<dotsCount){
                    setCurrentValue(targetValue,changeStep);
                    drawHandler.sendEmptyMessage(VALUE_SETED);
                    try {
                        Thread.sleep(SET_VALUE_ANIMATION_FRAMES_INTERVAL-SET_VALUE_ANIMATION_FRAMES_INTERVAL_STEP*changeStep);//先漲先落的間隔越來越短
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    changeStep++;
                }
            }
        });
    }
    
    private void setCurrentValue(float value,int changeStep){
        if (voiceAnimationUnits ==null){
            return;
        }
        if (voiceAnimationUnits.length>changeStep) {
            if (voiceAnimationUnits[changeStep]!=null) {
                try {
                    voiceAnimationUnits[changeStep].setValue(value);//先漲先落的關鍵,voiceAnimationUnit隨著changeStep遞增依次啟動
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }

其中SET_VALUE_ANIMATION_FRAMES_INTERVAL和SET_VALUE_ANIMATION_FRAMES_INTERVAL_STEP的值是通過反復實驗得到的,可以得到比較理想的動畫效果。

從源碼中就可以看出,其實只是通過調用VoiceAnimationUnit.setVaule()方法的時間間隔變化來調整動效中每個點的先漲先落,
而每個Unit自己來控制自身的動畫效果。

實現VoiceAnimationUnit

這部分復雜一些,不但要包括快速上漲的動畫,還包括緩慢回落的動畫,用到了兩個Handler進行計算。

1. 加速增加的setValue函數

    private float targetValue;          // 上漲時的目標高度,范圍(0,1)
    private float currentValue;         // 當前幀計算出的高度值,用于onDraw繪制,范圍(0,1)
    private float lastValue;            // 回落時記錄上一幀的高度值,范圍(0,1)

    private HandlerThread valueHandlerThread=new HandlerThread(TAG);
    private Handler valueHandler=new Handler(valueHandlerThread.getLooper());//用于計算上漲的動畫

    private static final int SET_VALUE_ANIMATION_MAX_FRAMES=10;
    private static final int SET_VALUE_ANIMATION_FRAMES_INTERVAL=10;

    private static final int STAY_INTERVAL=50;

    private static final int RESET_VALUE_ANIMATION_MAX_FRAMES=10;
    private static final int RESET_VALUE_ANIMATION_FRAMES_INTERVAL=10;

    private void removeResetMessages() {
        VoiceAnimationUnit.this.changeStep=0;
        drawHandler.removeMessages(VALUE_RESET_START);
        drawHandler.removeMessages(VALUE_RESETTING);
    }


    private void setCurrentValue(float value){
        Log.d(TAG,"setCurrentValue currentValue="+value);
        this.currentValue =value;
    }

    /**
     * 設置當前動畫的幅度值
     * @param targetValue 動畫的幅度,范圍(0,1)
     */
    public void setValue(float targetValue){
        if (isLoading){
            return;
        }
        if (lastSetValueTime==0){
            long now=System.currentTimeMillis();
            setValueInterval=SET_VALUE_ANIMATION_FRAMES_INTERVAL*SET_VALUE_ANIMATION_MAX_FRAMES;
            lastSetValueTime=now;
        }else {
            long now=System.currentTimeMillis();
            setValueInterval= (int) (now-lastSetValueTime);
            lastSetValueTime=now;
        }
        if(valueHandler==null){
            return;
        }
        Log.d(TAG,"setValueInterval="+setValueInterval);
        if (targetValue<currentValue){
            Log.d(TAG,"Runnable targetValue<this.targetValue");
        }else {
            removeResetMessages();
        }
        this.targetValue=targetValue;
        valueHandler.post(new Runnable(){
            @Override
            public void run() {
                if (isLoading){
                    return;
                }
                final float lastValue=(Float.isInfinite(currentValue)||Float.isNaN(currentValue))?0:currentValue;
                final float targetValue= VoiceAnimationUnit.this.targetValue;

                Log.d(TAG,"Runnable start currentValue="+lastValue);
                Log.d(TAG,"Runnable start targetValue="+targetValue);
                removeResetMessages();
                float currentValue;
                int changeStep=0;
                while (changeStep <= SET_VALUE_ANIMATION_MAX_FRAMES&&!isLoading) {
                    if (targetValue<lastValue){
                        Log.d(TAG,"Runnable targetValue<this.targetValue");
                    }else {
                        removeResetMessages();
                    }
                    currentValue = lastValue + (targetValue - lastValue) * valueAddingInterpolator.
                            getInterpolation((float) changeStep / (float) SET_VALUE_ANIMATION_MAX_FRAMES);
                    Log.d(TAG,"Runnable currentValue=");
                    setCurrentValue(currentValue);
                    drawHandler.sendEmptyMessage(VALUE_CHANGING);
                    try {
                        Thread.sleep(Math.min(SET_VALUE_ANIMATION_FRAMES_INTERVAL,
                                (setValueInterval==0?SET_VALUE_ANIMATION_FRAMES_INTERVAL:(setValueInterval/SET_VALUE_ANIMATION_MAX_FRAMES))));
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    changeStep++;
                }
                if (targetValue<lastValue){
                    Log.d(TAG,"Runnable targetValue<this.targetValue");
                }else {
                    removeResetMessages();
                }
                drawHandler.sendEmptyMessageDelayed(VALUE_RESET_START,
                        setValueInterval==0?STAY_INTERVAL: (long) ((setValueInterval * 0.4 + STAY_INTERVAL * 0.6) / 2));
            }
        });
    }

從源碼中可以看出setValue()中將工作添加到valueHandler中進行,而valueHandler中則會進行SET_VALUE_ANIMATION_MAX_FRAMES次計算,
計算出每一幀的位置,然后進行繪制,每幀間隔SET_VALUE_ANIMATION_FRAMES_INTERVAL。

等SET_VALUE_ANIMATION_MAX_FRAMES次計算完成以后,將啟動回落的過程。

2. 減速減小的handler

private Handler drawHandler=new Handler(){
        @Override
        public void handleMessage(Message msg) {
            switch (msg.what){
                case VALUE_CHANGING:
                    invalidate();
                    break;
                case VALUE_RESET_START:
                    lastValue=currentValue;
                    sendEmptyMessage(VALUE_RESETTING);
                case VALUE_RESETTING:
                    currentValue=lastValue-lastValue* valueDecreasingInterpolator.getInterpolation((float) changeStep/(float) RESET_VALUE_ANIMATION_MAX_FRAMES);
//                    Log.d(TAG,"handleMessage currentValue=");
                    setCurrentValue(currentValue);
                    invalidate();
                    changeStep++;
                    if (changeStep<=RESET_VALUE_ANIMATION_MAX_FRAMES){
                        sendEmptyMessageDelayed(VALUE_RESETTING,(Math.min(RESET_VALUE_ANIMATION_FRAMES_INTERVAL,
                                (setValueInterval==0?RESET_VALUE_ANIMATION_FRAMES_INTERVAL:(setValueInterval/RESET_VALUE_ANIMATION_MAX_FRAMES)))));
                    }else {
                        lastValue=0;
                        targetValue=0;
                    }
                    break;
                case HEIGHT_CHANGING:

                    break;
            }
        }
    };

這部分可以結合上面的部分看,其實drawHandler的功能主要就是間隔RESET_VALUE_ANIMATION_FRAMES_INTERVAL時間,就繪制一幀,形成回落的動畫。

可能有人會有疑問:

  1. 為什么上漲的過程是在整個Runnable中執行,而回落的過程則是通過sendEmptyMessage()實現的。
  2. 上漲的過程在整個Runnable中執行,會不會導致多次調用setValue()以后,設置了更大的幅度值,但是Runnable上漲的幅度過小。

很簡單

  1. 因為回落必須能被打斷,在回落的過程中setValue()被調用都要立刻停止回落,并重新上漲。
  2. 沒錯,就是這樣,但是這樣做是為了實現在最高點處不斷震顫的效果。不信的話可以嘗試只取targetValue的最大值做動畫,最終效果可能像一條死魚一樣在最高點不動。

小結

光看代碼可能無法體會調整動畫的痛苦,這其中的實現方式我做了好幾次修改,才最終穩定到現在的版本。

其實目前這種實現方式肯定不是最優的,因為計算4個點的動畫,就開啟了4個子線程,再加上UI線程,一共用到了5個線程,動畫過程中的cpu使用率達到8%左右。

而計算的東西其實是差不多的,只是由于延遲啟動造成的時間差導致不能直接使用同一個線程的計算結果,做一些轉換可能就能使用了。

所以想嘗試的朋友可以試試前面說的方法——自定義View,通過一個線程來計算每個點的高度,然后統一繪制。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容