【造輪子系列】轉輪選擇工具——WheelView

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

實現轉輪的選擇功能,效果見下圖:

效果圖
效果圖

本項目是由這個項目修改而成,不過基本上除了原來的大體框架以外,內部的實現邏輯全都做了大量修改,各位看官可以對比參考,在此必須感謝原作者給我的啟發。

先上源碼:WheelView

實現一個自定義View最基本步驟有:

  • 設計attribute屬性
  • 實現構造函數,在構造函數中讀取attribute屬性并使用
  • 重寫onMeasure方法
  • 重寫onDraw方法

這些基礎的部分就不細說了,如果對這部分不了解的,可以看看我之前的一篇文章,也可以直接從源碼找答案。本文重點聊聊這個View中的滾動的動畫是如何設計、實現和調優的,以及在源代碼中難以表現的一些思考,但是結合源碼能更好的理解本文。

構思

參考前面的效果圖,先讓我們想想,我們應該能自定義這個View的哪些屬性:

attr 屬性 描述
lineColor 分割線顏色
lineHeight 分割線高度
itemNumber 此wheelView顯示item的個數
noEmpty 設置true則選中不能為空,否則可以是空
normalTextColor 未選中文本顏色
normalTextSize 未選中文本字體大小
selectedTextColor 選中文本顏色
selectedTextSize 選中文本字體大小
unitHeight 每個item單元的高度

這樣一個View應該具有什么功能,響應怎樣的操作呢?

  • 首先,起碼要能滾動起來,特別是在手指快速滑過時,能繼續滾動一段距離,這段距離應該跟手指滑動的力度有關
  • 滾動的速度應該要先快后慢,減速停止
  • 滾動的時候要能夠判斷哪一項應該被選中,也就是應該停在哪里
  • 如果在滑動的過程中再次滑動,應該滑動更遠
  • 點擊轉輪的上部和下部的時候,應該產生單步選擇的效果
  • 滾輪被微小的擾動后應該能恢復原狀

如何讓畫面動起來

這個問題有經驗的童鞋都做過,簡單的說就是:

  1. 根據現有狀態A<small>0</small>和輸入的信息(從onTouchEvent中獲得),計算出動畫的終點狀態A<small>n</small>;
  2. 在終點狀態和當前狀態之間,得出A<small>m</small>=f(A<small>m-1</small>),或者A<small>m</small>=g(A<small>m</small>),用于計算即將插入的有限個點A<small>1</small>,A<small>2</small>...A<small>n-1</small>,先設i=1;
  3. 計算A<small>i</small>;
  4. 調用invalidate()函數,使畫面重繪;
  5. 等待一段時間t,使i=i+1;
  6. 重復3、 4、 5,直到i=n為止。

設計函數功能

現在我們知道,為了讓畫面動起來,我們應該在onTouchEvent函數中處理觸摸事件。

@Override
public boolean onTouchEvent(MotionEvent event) {
    if (!isEnable)
        return true;
    int y = (int) event.getY();
    int move = Math.abs(y - downY);
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            //防止被其他可滑動View搶占焦點,比如嵌套到ListView中使用時
            getParent().requestDisallowInterceptTouchEvent(true);
            if (isScrolling){
                isGoOnMove=false;
                if (moveHandler !=null) {
                    //清除當前快速滑動的動畫,進入下一次滑動動作
                    moveHandler.removeMessages(GO_ON_MOVE_REFRESH);
                    moveHandler.sendEmptyMessage(GO_ON_MOVE_INTERRUPTED);
                }
            }
            isScrolling = true;
            downY = (int) event.getY();
            downTime = System.currentTimeMillis();
            break;
        case MotionEvent.ACTION_MOVE:
            isGoOnMove=false;
            isScrolling = true;
            actionMove(y - downY);
            onSelectListener();
            break;
        case MotionEvent.ACTION_UP:
            long time= System.currentTimeMillis()-downTime;
            // 判斷這段時間移動的距離
            if (time < goonTime && move > goOnMinDistance) {
                goonMove(time,y - downY);
            } else { 
                //如果移動距離較小,則認為是點擊事件,否則認為是小距離滑動
                if (move<clickDistance){
                    if (downY<unitHeight*(itemNumber/2)&&downY>0){
                        //如果不先move再up,而是直接up,則無法產生點擊時的滑動效果
                        //通過調整move和up的距離,可以調整點擊的效果
                        actionMove((int) (unitHeight/2));
                        slowMove((int) unitHeight/4);
                    }else if (downY>controlHeight-unitHeight*(itemNumber/2)&&downY<controlHeight){
                        actionMove(-(int) (unitHeight/2));
                        slowMove(-(int) unitHeight/4);
                    }
                }else {
                    slowMove(y - downY);
                }
                isScrolling = false;
            }
            break;
        default:
            break;
    }
    return true;
}
/** 
* 處理MotionEvent.ACTION_MOVE中的移動  
* @param move 移動的距離 
*/
private void actionMove(int move) 

/** 
* 繼續快速移動一段距離,連續滾動動畫,滾動速度遞減,速度減到SLOW_MOVE_SPEED之下后調用slowMove
* @param time 滑動的時間間隔 
* @param move 滑動的距離 
*/
void goonMove(long time, final long move)

/**
* 緩慢移動一段距離,移動速度為SLOW_MOVE_SPEED,
* 注意這個距離不是move參數,而是先將選項坐標移動move的距離以后,再判斷當前應該選中的項目,然后將改項目移動到中間
* 移動完成后調用noEmpty
* @param move 立即設置的新坐標移動距離,不是緩慢移動的距離
*/
private void slowMove(final int move) 

/** 
* 不能為空,必須有選項 ,滑動動畫結束時調用
* 判斷當前應該被選中的項目,如果其不在屏幕中間,則將其移動到屏幕中間
* @param moveSymbol 移動的距離,實際上只需要其符號,用于判斷當前滑動方向 
*/
private void noEmpty(int moveSymbol) 

為了防止本文淹沒在代碼中,actionMove、goonMove、slowMove、noEmpty函數只介紹了功能,具體實現可以移步源碼查看。

需要注意的是,為了保證畫面的流暢,應該將計算的部分放在其他線程中執行,計算完以后再進行繪制,常用方法就是在計算完成后發送消息給Handler,然后在Handler中調用invalidate(),或者也可以直接調用postInvalidate()方法來重繪。本項目中計算的部分在goonMove、slowMove和noEmpty三個函數中,這三個函數都是在子線程(moveHandler)中執行的,采用postInvalidate()方式刷新界面。

如何產生減速停止的效果

說到繪制動畫時減速停止,很多人立刻就會想到Android提供給我們的插值器Interpolator。它有個實現類就是DecelerateInterpolator,從名字就可以看出是減速插值器。

結合到本項目的時候,有一個小trick,就是在goonMove中使用DecelerateInterpolator,來進行減速插值,當速度減慢到一定程度后(SLOW_MOVE_SPEED=3px),就改為調用slowMove來進行勻速滑動。結合slowMove的注釋可以看出,如果在計算滑動的距離時,按照整數倍的unitHeight來滑動,則緩慢滑動的距離為0,沒有效果,因此要多出一段距離,slowMove的滑動動畫距離就會較長,可以得到一個更加平穩的緩慢停止效果。

如何候判斷哪個備選項應該被選中

判斷是否可以被選中,以及是否已經被選中是本項目最重要的功能。先看代碼:

 /**
 * 判斷是否在可以選擇區域內,用于在沒有剛好被選中項的時候判斷備選項
 * 考慮到文字的baseLine是其底部,而y+m的高度是文字的頂部的高度
 * 因此判斷為可選區域的標準是需要減去文字的部分的
 * 也就是y+m在正中間和正中間上面一格的范圍內,則判斷為可選
 */
public  synchronized boolean couldSelected() {
    boolean isSelect=true;
    if (y+move<=itemNumber/2*unitHeight-unitHeight||y+move>=itemNumber/2*unitHeight+unitHeight){
        isSelect=false;
    }
    return isSelect;
}

/**
 * 判斷是否剛好在正中間的選擇區域內,也就是選中狀態
 */
public  synchronized boolean selected() {
    boolean  isSelect=false;
    if (textRect==null){
        return false;
    }
    if ((y+move>=itemNumber/2*unitHeight-unitHeight/2+(float) textRect.height()/2)&&
            (y+move<=itemNumber/2*unitHeight+unitHeight/2-(float)textRect.height()/2))
        isSelect=true;
    return isSelect;
}

這兩個函數是每個item判斷自己是否被選中的,其中y是這個item當前的坐標,move是這個item移動的距離,y+move就是這個item在畫面中所處的位置的上頂邊的值。上面的表達式經過簡化,很難看出到底是怎么推倒出來的,下面的示意圖能幫你更好理解。

普通繪制示意圖

上圖所示是一個3格的滾輪,其中標示了幾個重要的高度,從圖中可以看出每一個待選項繪制位置是如何計算的。需要注意的是,y+m的起點并不是畫面中的頂點,而是從第一個待選項的頂點算起的(也就是可能超出了繪制區域)。其中tH是根據normalTextSize和selectedTextSize和文字的內容計算出來的,具體計算步驟請看源碼

couldSelected示意圖

上圖標示了如何計算couldSelected的結果,需要注意的是,N是int型的,因此N/2的結果其實是下取整的,故N/2*uH!=N*uH/2。如果不明白,去看看java的運算符優先級和隱式的類型轉換吧。

從圖中可以看出,couldSelected的范圍其實剛好就是第一個待選項(含)和第三個待選項(含)之間的范圍。而如果滾輪中不止3格,而是5格、7格,則couldSelected的范圍 就是正中間那項的上下各一項的文字之間的范圍。

selected示意圖

上圖標示了如何計算selected的結果,可以看出,selected的范圍剛好是正中間那格的范圍,文字的任何一部分進入這一格內的時候,這一項就被選中了。

現在你應該理解了這些數值的判斷依據了,但你可能會問,如果有兩個待選項都在這個范圍內,selected怎么判斷?那么使用時會使上方的那個item被選中,而事實上本項目在計算過程中已經基本排除了這種可能性了,結合前面介紹的slowMove和noEmpty函數的源碼可以更好的理解couldSelected和selected的作用,以及整個選擇和滾動的邏輯,具體實現還是請移步源碼

如何處理滑動的過程中的點擊操作

系統的NumberPicker和一些其他的開源項目對滑動時的點擊處理得不夠理想。在滑動的過程中快速點擊,很大的幾率出現最終結果不居中的情況:

現存滾輪工具的問題

其實這就是我自己造輪子的原因。這種情況主要是以下兩點設計上的缺陷導致的:

  • 滾動動畫本身的實現方式上有問題。在每次快速滑動的時候(goonMove的實現)新建一個Thread來進行計算,這樣做有個好處在于,多次快速滾動的時候,可以通過多個線程同步計算,產生加速滾動的感覺。
  • 沒有在每一次滾動結束的時候,都進行一次讓滾輪歸位的操作。這些項目中,動畫的實現方式,往往是在動畫開始的時候就計算好了最終要滾動的距離,而由于滾動動畫是在線程中迭代計算的,所以在計算的過程中再次進行微小的擾動,就會導致整個滾動產生偏差,形成上圖中錯位的結果。

于是我針對這兩點做了對應的處理。

  • 首先使用了HandlerThread和Handler來進行動畫的計算,這樣就使得同時只有一個線程進行滾動計算,也減少了頻繁創建線程的開銷。然后在onTouchEvent函數中做了打斷當前滾動的判斷,打斷滾動很簡單,就只是把當前動畫的位置設置為新的動畫的起點。這樣在滾輪快速滾動過程中再次點擊的時候,就相當于一次新的滾動,與上一次滾動就沒有關系了。但是這就需要使用其他方法來產生加速滾動的效果,詳見goonMove函數源碼 。

  • 通過使用HandlerThread,能保證在每次滾動的結束都調用slowMove函數和noEmpty函數(而且不會有同步問題),在這兩個函數中,會再次計算當前滾輪的狀態,從而確保在動畫停止的時候肯定有一項被選中,且被選中項處于滾輪正中間的位置。說白了,就是通過重復計算的方式,確保最終效果。

如何調優性能

說實話,我對性能調優方面并沒有深入研究,所以本項目的性能可能并不算好,但是性能優化的基本邏輯還是有的,也就是減少不必要的計算,本項目中有兩處:

  • 在繪制每個item的時候,需要先根據normalTextSize、selectedTextSize、文字內容和item的位置計算tH,但是如果normalTextSize和selectedTextSize相等的情況下,則每次計算的tH都一樣,所以我設置了一個boolean來標示是否以及計算過了,計算過就無需反復計算了。
  • 在繪制每個item之前,先調用isInView函數,判斷當前item是否在顯示區域內,如果不在,則直接跳過該item的計算和繪制,可以大幅提高動畫的流暢度。注意下面代碼中注釋行和非注釋行的區別。
/**
 * 是否在可視界面內
 * @return
 */
public  synchronized boolean isInView() {
//    if (y + move > controlHeight || ((float)y + (float)move + (float)unitHeight / 2 + (float)textRect.height() / 2f) < 0)
    if (y + move > controlHeight || ((float)y + (float)move + (float)unitHeight  ) < 0)//放寬判斷的條件,否則就不能在onDraw的開頭執行,而要到計算完tH以后才能判斷了。
        return false;
    return true;
}

更多性能調優請移步這篇:WheelView的改進

源碼

WheelView
源碼會繼續更新,博客可能會跟不上源碼的進度,以源碼為準。

tips:源碼中比較核心的函數就是前面介紹過的onTouchEvent,goonMove,slowMove,noEmpty,couldSelected和selected,結合本文,基本上一看就明白了。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,963評論 6 542
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,348評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,083評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,706評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,442評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,802評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,795評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,983評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,542評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,287評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,486評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,030評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,710評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,116評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,412評論 1 294
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,224評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,462評論 2 378

推薦閱讀更多精彩內容