【造輪子系列】轉(zhuǎn)輪選擇工具——WheelView

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

實(shí)現(xiàn)轉(zhuǎn)輪的選擇功能,效果見下圖:

效果圖
效果圖

本項(xiàng)目是由這個(gè)項(xiàng)目修改而成,不過基本上除了原來(lái)的大體框架以外,內(nèi)部的實(shí)現(xiàn)邏輯全都做了大量修改,各位看官可以對(duì)比參考,在此必須感謝原作者給我的啟發(fā)。

先上源碼:WheelView

實(shí)現(xiàn)一個(gè)自定義View最基本步驟有:

  • 設(shè)計(jì)attribute屬性
  • 實(shí)現(xiàn)構(gòu)造函數(shù),在構(gòu)造函數(shù)中讀取attribute屬性并使用
  • 重寫onMeasure方法
  • 重寫onDraw方法

這些基礎(chǔ)的部分就不細(xì)說了,如果對(duì)這部分不了解的,可以看看我之前的一篇文章,也可以直接從源碼找答案。本文重點(diǎn)聊聊這個(gè)View中的滾動(dòng)的動(dòng)畫是如何設(shè)計(jì)、實(shí)現(xiàn)和調(diào)優(yōu)的,以及在源代碼中難以表現(xiàn)的一些思考,但是結(jié)合源碼能更好的理解本文。

構(gòu)思

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

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

這樣一個(gè)View應(yīng)該具有什么功能,響應(yīng)怎樣的操作呢?

  • 首先,起碼要能滾動(dòng)起來(lái),特別是在手指快速滑過時(shí),能繼續(xù)滾動(dòng)一段距離,這段距離應(yīng)該跟手指滑動(dòng)的力度有關(guān)
  • 滾動(dòng)的速度應(yīng)該要先快后慢,減速停止
  • 滾動(dòng)的時(shí)候要能夠判斷哪一項(xiàng)應(yīng)該被選中,也就是應(yīng)該停在哪里
  • 如果在滑動(dòng)的過程中再次滑動(dòng),應(yīng)該滑動(dòng)更遠(yuǎn)
  • 點(diǎn)擊轉(zhuǎn)輪的上部和下部的時(shí)候,應(yīng)該產(chǎn)生單步選擇的效果
  • 滾輪被微小的擾動(dòng)后應(yīng)該能恢復(fù)原狀

如何讓畫面動(dòng)起來(lái)

這個(gè)問題有經(jīng)驗(yàn)的童鞋都做過,簡(jiǎn)單的說就是:

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

設(shè)計(jì)函數(shù)功能

現(xiàn)在我們知道,為了讓畫面動(dòng)起來(lái),我們應(yīng)該在onTouchEvent函數(shù)中處理觸摸事件。

@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:
            //防止被其他可滑動(dòng)View搶占焦點(diǎn),比如嵌套到ListView中使用時(shí)
            getParent().requestDisallowInterceptTouchEvent(true);
            if (isScrolling){
                isGoOnMove=false;
                if (moveHandler !=null) {
                    //清除當(dāng)前快速滑動(dòng)的動(dòng)畫,進(jìn)入下一次滑動(dòng)動(dòng)作
                    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;
            // 判斷這段時(shí)間移動(dòng)的距離
            if (time < goonTime && move > goOnMinDistance) {
                goonMove(time,y - downY);
            } else { 
                //如果移動(dòng)距離較小,則認(rèn)為是點(diǎn)擊事件,否則認(rèn)為是小距離滑動(dòng)
                if (move<clickDistance){
                    if (downY<unitHeight*(itemNumber/2)&&downY>0){
                        //如果不先move再up,而是直接up,則無(wú)法產(chǎn)生點(diǎn)擊時(shí)的滑動(dòng)效果
                        //通過調(diào)整move和up的距離,可以調(diào)整點(diǎn)擊的效果
                        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中的移動(dòng)  
* @param move 移動(dòng)的距離 
*/
private void actionMove(int move) 

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

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

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

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

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

如何產(chǎn)生減速停止的效果

說到繪制動(dòng)畫時(shí)減速停止,很多人立刻就會(huì)想到Android提供給我們的插值器Interpolator。它有個(gè)實(shí)現(xiàn)類就是DecelerateInterpolator,從名字就可以看出是減速插值器。

結(jié)合到本項(xiàng)目的時(shí)候,有一個(gè)小trick,就是在goonMove中使用DecelerateInterpolator,來(lái)進(jìn)行減速插值,當(dāng)速度減慢到一定程度后(SLOW_MOVE_SPEED=3px),就改為調(diào)用slowMove來(lái)進(jìn)行勻速滑動(dòng)。結(jié)合slowMove的注釋可以看出,如果在計(jì)算滑動(dòng)的距離時(shí),按照整數(shù)倍的unitHeight來(lái)滑動(dòng),則緩慢滑動(dòng)的距離為0,沒有效果,因此要多出一段距離,slowMove的滑動(dòng)動(dòng)畫距離就會(huì)較長(zhǎng),可以得到一個(gè)更加平穩(wěn)的緩慢停止效果。

如何候判斷哪個(gè)備選項(xiàng)應(yīng)該被選中

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

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

/**
 * 判斷是否剛好在正中間的選擇區(qū)域內(nèi),也就是選中狀態(tài)
 */
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;
}

這兩個(gè)函數(shù)是每個(gè)item判斷自己是否被選中的,其中y是這個(gè)item當(dāng)前的坐標(biāo),move是這個(gè)item移動(dòng)的距離,y+move就是這個(gè)item在畫面中所處的位置的上頂邊的值。上面的表達(dá)式經(jīng)過簡(jiǎn)化,很難看出到底是怎么推倒出來(lái)的,下面的示意圖能幫你更好理解。

普通繪制示意圖

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

couldSelected示意圖

上圖標(biāo)示了如何計(jì)算couldSelected的結(jié)果,需要注意的是,N是int型的,因此N/2的結(jié)果其實(shí)是下取整的,故N/2*uH!=N*uH/2。如果不明白,去看看java的運(yùn)算符優(yōu)先級(jí)和隱式的類型轉(zhuǎn)換吧。

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

selected示意圖

上圖標(biāo)示了如何計(jì)算selected的結(jié)果,可以看出,selected的范圍剛好是正中間那格的范圍,文字的任何一部分進(jìn)入這一格內(nèi)的時(shí)候,這一項(xiàng)就被選中了。

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

如何處理滑動(dòng)的過程中的點(diǎn)擊操作

系統(tǒng)的NumberPicker和一些其他的開源項(xiàng)目對(duì)滑動(dòng)時(shí)的點(diǎn)擊處理得不夠理想。在滑動(dòng)的過程中快速點(diǎn)擊,很大的幾率出現(xiàn)最終結(jié)果不居中的情況:

現(xiàn)存滾輪工具的問題

其實(shí)這就是我自己造輪子的原因。這種情況主要是以下兩點(diǎn)設(shè)計(jì)上的缺陷導(dǎo)致的:

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

于是我針對(duì)這兩點(diǎn)做了對(duì)應(yīng)的處理。

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

  • 通過使用HandlerThread,能保證在每次滾動(dòng)的結(jié)束都調(diào)用slowMove函數(shù)和noEmpty函數(shù)(而且不會(huì)有同步問題),在這兩個(gè)函數(shù)中,會(huì)再次計(jì)算當(dāng)前滾輪的狀態(tài),從而確保在動(dòng)畫停止的時(shí)候肯定有一項(xiàng)被選中,且被選中項(xiàng)處于滾輪正中間的位置。說白了,就是通過重復(fù)計(jì)算的方式,確保最終效果。

如何調(diào)優(yōu)性能

說實(shí)話,我對(duì)性能調(diào)優(yōu)方面并沒有深入研究,所以本項(xiàng)目的性能可能并不算好,但是性能優(yōu)化的基本邏輯還是有的,也就是減少不必要的計(jì)算,本項(xiàng)目中有兩處:

  • 在繪制每個(gè)item的時(shí)候,需要先根據(jù)normalTextSize、selectedTextSize、文字內(nèi)容和item的位置計(jì)算tH,但是如果normalTextSize和selectedTextSize相等的情況下,則每次計(jì)算的tH都一樣,所以我設(shè)置了一個(gè)boolean來(lái)標(biāo)示是否以及計(jì)算過了,計(jì)算過就無(wú)需反復(fù)計(jì)算了。
  • 在繪制每個(gè)item之前,先調(diào)用isInView函數(shù),判斷當(dāng)前item是否在顯示區(qū)域內(nèi),如果不在,則直接跳過該item的計(jì)算和繪制,可以大幅提高動(dòng)畫的流暢度。注意下面代碼中注釋行和非注釋行的區(qū)別。
/**
 * 是否在可視界面內(nèi)
 * @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的開頭執(zhí)行,而要到計(jì)算完tH以后才能判斷了。
        return false;
    return true;
}

更多性能調(diào)優(yōu)請(qǐng)移步這篇:WheelView的改進(jìn)

源碼

WheelView
源碼會(huì)繼續(xù)更新,博客可能會(huì)跟不上源碼的進(jìn)度,以源碼為準(zhǔn)。

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

最后編輯于
?著作權(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),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

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

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