【造輪子系列】仿谷歌語音搜索動(dòng)畫——VoiceAnimation

轉(zhuǎn)載注明出處:簡書-十個(gè)雨點(diǎn)

谷歌App的語音搜索功能估計(jì)很多人都沒用過,沒用過的也沒必要去用它了,因?yàn)閷?shí)際上就類似手機(jī)百度,360手機(jī)搜索,是一款類瀏覽器產(chǎn)品,沒有太多實(shí)用價(jià)值。

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

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

動(dòng)圖無法完全展現(xiàn)這個(gè)動(dòng)畫的細(xì)微精妙之處,想仔細(xì)研究的同學(xué)可以自行下載,不過接著往下看,我們會(huì)來模擬實(shí)現(xiàn)這個(gè)效果的。

背景

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

仔細(xì)觀察不難發(fā)現(xiàn),這個(gè)動(dòng)畫中包含了幾個(gè)特點(diǎn):

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

我們先預(yù)想一下如何實(shí)現(xiàn)這些功能(以下稱為VoiceAnimator):

首先共通的部分是,按一定的時(shí)間間隔,將代表聲音大小的值設(shè)置給VoiceAnimator,VoiceAnimator則根據(jù)這些值來繪制動(dòng)畫。

而動(dòng)畫的實(shí)現(xiàn)方式有:

  1. 自定義View,通過一個(gè)線程來計(jì)算每個(gè)點(diǎn)的高度,然后統(tǒng)一繪制
  2. 自定義View,通過多個(gè)線程分別計(jì)算每個(gè)點(diǎn)的高度,然后統(tǒng)一繪制
  3. 自定義ViewGroup,每個(gè)點(diǎn)都用一個(gè)View來表示,通過屬性動(dòng)畫來實(shí)現(xiàn)動(dòng)畫
  4. 自定義ViewGroup,每個(gè)點(diǎn)都用一個(gè)View來表示,使用多個(gè)線程來手動(dòng)繪制動(dòng)畫

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

源碼

VoiceAnimator

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

外部通過每隔一段時(shí)間調(diào)用VoiceAnimator.setValue()函數(shù)來啟動(dòng)動(dòng)畫效果。

實(shí)現(xiàn)VoiceAnimator

VoiceAnimator比較簡單,主要是作為ViewGroup包裹住VoiceAnimationUnit,然后對(duì)作為單獨(dú)點(diǎn)的VoiceAnimationUnit進(jìn)行統(tǒng)一啟動(dòng)操作。

實(shí)現(xiàn)自定義ViewGroup比View需要多實(shí)現(xiàn)一個(gè)函數(shù):

    @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函數(shù)的作用是計(jì)算出每一個(gè)點(diǎn)的位置,然后通過childView.layout(cl,ct,cr,cb)將VoiceAnimationUnit設(shè)置到這個(gè)位置上。

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

先漲先落的關(guān)鍵函數(shù)setValue

    private static final int SET_VALUE_ANIMATION_FRAMES_INTERVAL=40;//ms
    private static final int SET_VALUE_ANIMATION_FRAMES_INTERVAL_STEP=5;//ms
    /**
     * 設(shè)置當(dāng)前動(dòng)畫的幅度值
     * @param targetValue 動(dòng)畫的幅度,范圍(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);//先漲先落的關(guān)鍵,voiceAnimationUnit隨著changeStep遞增依次啟動(dòng)
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }

其中SET_VALUE_ANIMATION_FRAMES_INTERVAL和SET_VALUE_ANIMATION_FRAMES_INTERVAL_STEP的值是通過反復(fù)實(shí)驗(yàn)得到的,可以得到比較理想的動(dòng)畫效果。

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

實(shí)現(xiàn)VoiceAnimationUnit

這部分復(fù)雜一些,不但要包括快速上漲的動(dòng)畫,還包括緩慢回落的動(dòng)畫,用到了兩個(gè)Handler進(jìn)行計(jì)算。

1. 加速增加的setValue函數(shù)

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

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

    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;
    }

    /**
     * 設(shè)置當(dāng)前動(dòng)畫的幅度值
     * @param targetValue 動(dòng)畫的幅度,范圍(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中進(jìn)行,而valueHandler中則會(huì)進(jìn)行SET_VALUE_ANIMATION_MAX_FRAMES次計(jì)算,
計(jì)算出每一幀的位置,然后進(jìn)行繪制,每幀間隔SET_VALUE_ANIMATION_FRAMES_INTERVAL。

等SET_VALUE_ANIMATION_MAX_FRAMES次計(jì)算完成以后,將啟動(dòng)回落的過程。

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;
            }
        }
    };

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

可能有人會(huì)有疑問:

  1. 為什么上漲的過程是在整個(gè)Runnable中執(zhí)行,而回落的過程則是通過sendEmptyMessage()實(shí)現(xiàn)的。
  2. 上漲的過程在整個(gè)Runnable中執(zhí)行,會(huì)不會(huì)導(dǎo)致多次調(diào)用setValue()以后,設(shè)置了更大的幅度值,但是Runnable上漲的幅度過小。

很簡單

  1. 因?yàn)榛芈浔仨毮鼙淮驍啵诨芈涞倪^程中setValue()被調(diào)用都要立刻停止回落,并重新上漲。
  2. 沒錯(cuò),就是這樣,但是這樣做是為了實(shí)現(xiàn)在最高點(diǎn)處不斷震顫的效果。不信的話可以嘗試只取targetValue的最大值做動(dòng)畫,最終效果可能像一條死魚一樣在最高點(diǎn)不動(dòng)。

小結(jié)

光看代碼可能無法體會(huì)調(diào)整動(dòng)畫的痛苦,這其中的實(shí)現(xiàn)方式我做了好幾次修改,才最終穩(wěn)定到現(xiàn)在的版本。

其實(shí)目前這種實(shí)現(xiàn)方式肯定不是最優(yōu)的,因?yàn)橛?jì)算4個(gè)點(diǎn)的動(dòng)畫,就開啟了4個(gè)子線程,再加上UI線程,一共用到了5個(gè)線程,動(dòng)畫過程中的cpu使用率達(dá)到8%左右。

而計(jì)算的東西其實(shí)是差不多的,只是由于延遲啟動(dòng)造成的時(shí)間差導(dǎo)致不能直接使用同一個(gè)線程的計(jì)算結(jié)果,做一些轉(zhuǎn)換可能就能使用了。

所以想嘗試的朋友可以試試前面說的方法——自定義View,通過一個(gè)線程來計(jì)算每個(gè)點(diǎn)的高度,然后統(tǒng)一繪制。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容