轉(zhuǎn)載注明出處:簡書-十個(gè)雨點(diǎn)
谷歌App的語音搜索功能估計(jì)很多人都沒用過,沒用過的也沒必要去用它了,因?yàn)閷?shí)際上就類似手機(jī)百度,360手機(jī)搜索,是一款類瀏覽器產(chǎn)品,沒有太多實(shí)用價(jià)值。
不過不得不說的是,它的動(dòng)畫做得相當(dāng)精致,如果要用一個(gè)詞來形容,就是——靈動(dòng)。先給大家看看效果:


動(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):
- 波浪的效果,前面的點(diǎn)比后面的點(diǎn)先漲先落
- 每個(gè)點(diǎn)本身都具有一定的延滯性,在到達(dá)一定的高度以后,不會(huì)立刻回落,而是停頓一小段時(shí)間以后才收縮。
- 對(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)方式有:
- 自定義View,通過一個(gè)線程來計(jì)算每個(gè)點(diǎn)的高度,然后統(tǒng)一繪制
- 自定義View,通過多個(gè)線程分別計(jì)算每個(gè)點(diǎn)的高度,然后統(tǒng)一繪制
- 自定義ViewGroup,每個(gè)點(diǎn)都用一個(gè)View來表示,通過屬性動(dòng)畫來實(shí)現(xiàn)動(dòng)畫
- 自定義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)的。
源碼
自定義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ì)有疑問:
- 為什么上漲的過程是在整個(gè)Runnable中執(zhí)行,而回落的過程則是通過sendEmptyMessage()實(shí)現(xiàn)的。
- 上漲的過程在整個(gè)Runnable中執(zhí)行,會(huì)不會(huì)導(dǎo)致多次調(diào)用setValue()以后,設(shè)置了更大的幅度值,但是Runnable上漲的幅度過小。
很簡單
- 因?yàn)榛芈浔仨毮鼙淮驍啵诨芈涞倪^程中setValue()被調(diào)用都要立刻停止回落,并重新上漲。
- 沒錯(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)一繪制。