基於 RecyclerView 實現的歌詞滾動自定義控制元件

恆夕發表於2018-03-21
      先來一張效果圖: 


基於 RecyclerView 實現的歌詞滾動自定義控制元件


       這幾天打算做一個控制元件,來讓自己複習一下自定義 view 的知識以及事件分發機制的原理與應用。對於這個控制元件,我已經封裝好了,只要呼叫就可以了。這是我的 gitHub 歡迎 star 和 fork,之前沒怎麼用過,請大家多多捧場,哈哈! github.com/Yeahlz/Word…

      該控制元件分為以下幾個部分: 

      1.歌詞自動滾動 2. 歌詞顏色字型變化 3.觸碰螢幕歌詞不滾動,高亮顯示,離開時自動移動到當前歌詞位置 4.觸碰螢幕中間線條出現以及顯示該歌詞的時間 5.點選歌詞跳轉到當前位置並輸出當時時間 6. 可設定跳轉時間跳到相應歌詞位置 

接下來我一個一個大概講述一下思路。

      1.對於滾動,我們可以呼叫 RecyclerView.smoothScrollBy() 方法, 相對於 ScrollBy() 方法,該方法能夠實現平滑滑動。 我設定了總共顯示九句歌詞。而且因為我想在歌詞前面和後面留一些空白,這些看起來會好看些。所以,在歌詞列表裡面我加多了一些空白。

List<String> wordList = new ArrayList<>(); //  新增歌詞列表中的一些空白
wordList.add(""); 
wordList.add(""); 
wordList.add(""); 
wordList.add(""); 
wordList.addAll(mWordList); 
wordList.add(""); 
wordList.add(""); 
wordList.add(""); 
wordList.add("");
複製程式碼

      所以我們需要使用 Runable 來執行滾動操作。而且為了避免記憶體洩漏。將 Runable 實現類修飾為 static 。由於歌詞的滾自動滾動是根據歌詞時間來進行移動的。前面已經看到歌詞列表索引位置跟時間列表位置有所變化,所以下面索引操作有些變化

private static class AutoPullWork implements Runnable {   //執行歌詞滾動的 Runable 類
    public AutoPullWork(AutoPullRecyclerView autoPullRecyclerView) { 
            weakReference = new WeakReference<AutoPullRecyclerView>(autoPullRecyclerView); 
     }
     @Override 
    public void run() { 
        autoPullRecyclerView.smoothScrollBy(0, autoPullRecyclerView.getMeasuredHeight() / 9); 
        autoPullRecyclerView.postDelayed(autoPullRecyclerView.autoPullWork, autoPullRecyclerView.timeList.get(autoPullRecyclerView.currentWord - 4) - autoPullRecyclerView.timeList.get(autoPullRecyclerView.currentWord - 5)); 
        // 由於歌詞列表前面新增了四個空白,所以 cuurrentWord 是從第 5 個開始。
        ......
    }
}複製程式碼

      2.對於歌詞的高亮顯示,我們可以呼叫 notifyItemChange(int position) 方法,這個方法呼叫會重新去繪製特定 position 上的 viewHolder 。hightLightItem() 在這個方法中設定我們想要改變 viewHolder 的位置,並呼叫 notifyItemChange(int position) 。然後在 onBindViewHolder() 中的設定可以判斷當前是否需要高亮顯示。

public void hightLightItem(int position){  // 外部呼叫 adapter 中這個辦法,用於設定要高亮顯示的位置,並呼叫重繪特定 position
        mHighLightPosition = position; 
        notifyItemChanged(position-1); 
        notifyItemChanged(position); 
}
複製程式碼

private boolean isHighLight(int position){ // 在 onBindViewHolder 中呼叫 用於判斷當前是否需要高亮顯示
        return mHighLightPosition == position; 
} 
複製程式碼

@Override public void onBindViewHolder(ViewHolder holder, int position) {  //設定高亮的變化
        String word = mWordList.get(position); 
        holder.textView.setText(word); 
        try { 
            if (!isHighLight(position)) {                 
                holder.textView.setTextSize(mOrdinarySize); 
                holder.textView.setTextColor(Color.parseColor(mOrdinaryColor)); 
        } else if (isHighLight(position)) { 
                holder.textView.setTextSize(mHighLightSize); 
                holder.textView.setTextColor(Color.parseColor(mHighLightColor)); 
        } 
        }catch ( Exception e){ 
            e.printStackTrace(); 
        } 
} 
複製程式碼

      3.對於歌詞自動移動到當前語句: 本身我的想法就是多設定一個變數還是在這個 Runable() 裡面進行操作。但是一個很嚴重的問題,導致我連續幾天一直想不到對策方法。由於手指離開螢幕的時候我使用 postDelayed() 方法有可能跟裡面 Runable 裡面使用的 postDelayed() 時間上可能會相互衝突,事件的執行情況就很有可能變得跟你想不一樣。所以我們應該重新寫一個 Runable() 來控制它的自動移動到當前位置。這樣子的話各做各的事情,在寫邏輯的時候會比較容易理順。(當時沒想好害我調了好久,一直都不對,哈哈). 

private static class AutoBackWork implements Runnable{  //開啟另一個任務來控制歌詞自動移動到當前位置
    @Override public void run() { 
    }
 }
複製程式碼

       對於點選螢幕時就重寫 onTouchEvent() 方法, 在 down 事件中 ,設定變數讓 Runable () 事件中不滾動。 而對於歌詞在離開螢幕後的一段時間後自動回到該位置。同樣的,還是需要使用 smoothScrollBy() 方法移動。而移動多少呢?

這是個問題。這個要分為四種情況: 

第一種: 當前歌詞在螢幕之外:由於我是打算將歌詞移動到螢幕中的第四個位置。 那麼我就需要找到螢幕中的第一個位置,還有當前顯示的是哪一句歌詞。 由於我是想要讓他顯示在螢幕的第四行,所以是相差 currentWord + 5 - firstPosition 個位置 。 

第二種: 當歌詞在第四行之前但是在第一行之後。 

第三種: 當歌詞在第四行之後但是在最後一行之前。 

第四種: 當歌詞在最後一行之後。 其實我們就根據自己想要在顯示在第幾行來判斷需要移動多少個位置。 我就不詳說啦,具體看程式碼:

AutoPullRecyclerView autoPullRecyclerView = weakReference.get(); 
LinearLayoutManager linearLayoutManager = (LinearLayoutManager) autoPullRecyclerView.getLayoutManager(); 
 int firtPosition = linearLayoutManager.findFirstVisibleItemPosition(); // 視覺化第一個位置
 int lastPosition = linearLayoutManager.findLastVisibleItemPosition();  // 視覺化最後一個位置
if (firtPosition>autoPullRecyclerView.currentWord){ // 第一種 
    autoPullRecyclerView.smoothScrollBy(0, -(firtPosition - autoPullRecyclerView.currentWord + 5) * height); 
}else if(firtPosition+9>autoPullRecyclerView.currentWord){ if (firtPosition+3>autoPullRecyclerView.currentWord){ // 第二種 
    int top = autoPullRecyclerView.getChildAt(autoPullRecyclerView.currentWord-firtPosition).getTop(); // 獲取當前歌詞距離開頭的位置
    autoPullRecyclerView.smoothScrollBy(0, -(4*height-top)); //-- 
}else{ // 第三種 
    int top = autoPullRecyclerView.getChildAt(autoPullRecyclerView.currentWord-firtPosition).getTop(); 
    autoPullRecyclerView.smoothScrollBy(0,top-(4*height)); //++ 
 }else { // 第四種 
    autoPullRecyclerView.smoothScrollBy(0, (autoPullRecyclerView.currentWord - lastPosition + 5) * height); 
    } 
}複製程式碼

      4.顯示中間線條以及顯示該歌詞時間 中間的 view 不可能鑲嵌在 RecyclerView 中。所以我們要自定義一個佈局來放自定義 RecyclerView 和中間的 view。

基於 RecyclerView 實現的歌詞滾動自定義控制元件

      中間線的邏輯是當點選螢幕的時候顯示出中間的線,離開螢幕的時候過一小段時間消失。也就是需要處理 down 事件和 up 事件 。但是我們在 RecyclerView 中是處理了點選事件的,而且本身 RecyclerView 就已經重寫了攔截了該事件的。而且一般是父 View 是不攔截事件的。那我們要怎麼在裡面設定 down 時間和 up 事件呢?我們怎麼能讓父 View 接收到事件處理了一下同時最後又是子 view 處理事件呢? 在此,我推薦一篇部落格,裡面很詳細地介紹了事件分發處理機制的流程。 

www.jianshu.com/p/e99b5e8bd… 

      我先說一下結論吧。就是重寫 dispatchTouchEvent() 。因為假如我們重寫 onTouchEvent 的話,由於 RecyclerView 處理了事件。是不會處理這個方法的。 而對於 dispatchTouchEvent() 方法 ,如果你是在子 view 中處理事件。那麼每次事件都會從 dispatchTouchEvent() 往下傳遞。具體原理可以看一下原始碼。

@Override public boolean dispatchTouchEvent(MotionEvent ev) {  // 父 view 在這個方法中處理 down 和 up 事件
        switch (ev.getAction()){ 
            case MotionEvent.ACTION_DOWN: 
                performClick(); 
                    view.setVisibility(VISIBLE); 
                    show = true; 
                    view.setOnClickListener(new OnClickListener() { 
                            @Override public void onClick(View view) { 
                                    autoPullRecyclerView.setComeToPlay();  // 呼叫方法跳轉到當前歌詞
                                    onClickListener.onClickListener(mCurrentTime); //回撥當前歌詞時間
                                        } }); 
                                    break; 
            case MotionEvent.ACTION_UP: 
                    view.removeCallbacks(runnable); //除去原先所有事件,因為有可能有多個 up 操作,我們只需要保留最後一個。
                    view.postDelayed(runnable,4000); // 呼叫攔截器
                    break; 
                    default: 
                    break; 
            } 
        return super.dispatchTouchEvent(ev); 
} 
複製程式碼

       對於顯示歌詞的時間,由於線條是在最中間的部分,我想要的是中間的線在哪一個 item 裡面顯示該 item 對應時間。對於最原先的做法,我是通過 firstPosition 第一個看到的 item 變化時便變化時間。但是如果只是靠第一個視覺化位置的話,由於中間線的位置,這樣會導致恰好在中間的位置往上移動一點和往下移動一點是兩個不同的時間變化。但是此時都是在同一 item 中 。所以我做的是去第二個視覺化位置,判斷該位置離 top 與 item/2 的距離的比較。從而解決問題。 最開始只是根據第一個視覺化位置而顯示的時間,但是顯示時間變化的位置不對。

基於 RecyclerView 實現的歌詞滾動自定義控制元件


     改了思路根據第二個視覺化位置之後根據位移來判斷。

基於 RecyclerView 實現的歌詞滾動自定義控制元件 

 private void showTime(){ 
        int height = autoPullRecyclerView.getMeasuredHeight() / 9; // 單行歌詞的距離
        int top = autoPullRecyclerView.getChildAt(1).getTop(); // 第二個視覺化位置距離頂部的距離
        int currentPosition = linearLayoutManager.findFirstVisibleItemPosition(); 
        int position; 
        if (top > height / 2) { // 根據距離來判斷當前應該顯示哪個時間
                position = currentPosition; 
        } else { 
            position = currentPosition + 1; 
        }
複製程式碼

         5.點選歌詞跳轉並且返回時間 點選歌詞的時候改變高亮的位置和恢復原先的高亮的位置,並且通過回撥返回時間。 

case MotionEvent.ACTION_DOWN: performClick(); 
    view.setVisibility(VISIBLE); 
    show = true; view.setOnClickListener(new OnClickListener() { 
        @Override public void onClick(View view) { 
        autoPullRecyclerView.setComeToPlay(); 
        onClickListener.onClickListener(mCurrentTime);  // 回撥
       } 
    }); 
break; 複製程式碼

 /**
     *  點選歌詞滑動
     */
    public void setComeToPlay(){ //這是子 view 中的方法
        type =3;  //點選歌詞跳轉型別
        comeToPlay = true;
        lastWord = currentWord-1;
        removeCallbacks(autoPullWork);
        post(autoPullWork);
    }複製程式碼

if (type==3&&autoPullRecyclerView.comeToPlay){
                            type = 1;  // 自動滾動型別
                            if (-top>height/2){   //理由跟上面的一樣
                                autoPullAdapter.changeToHighLight(autoPullRecyclerView.lastWord,firtPosition+5);
                                autoPullRecyclerView.currentWord = firtPosition+5; //當前歌詞重新設定
                            }else {
                                autoPullAdapter.changeToHighLight(autoPullRecyclerView.lastWord,firtPosition+4);
                                autoPullRecyclerView.currentWord = firtPosition+4;
                            }
                            autoPullRecyclerView.comeToPlay = false;
複製程式碼

5.點選進度條跳轉到相應位置 先呼叫 seekBar 的 onSeekBarChangeListener() 中監聽方法,獲取當前時間,根據時間獲得當前應該所處的索引。然後呼叫自動移動滾動方法和高亮方法。

seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { 
           @Override public void onProgressChanged(SeekBar seekBar, int i, boolean b) { }
           @Override public void onStartTrackingTouch(SeekBar seekBar) { } 
           @Override public void onStopTrackingTouch(SeekBar seekBar) { 
                int progress = seekBar.getProgress(); // 獲取當前進度 
                worldRelativeLayout.setChangeTime(progress); 
            } 
});   複製程式碼

 /** 設定歌詞時間相應歌詞滑動
     * @param time
     */
    public void setChangeTime(int time){
        type =2; 
        if (time<=timeList.get(0)){  //時間小於第一句時間
            removeCallbacks(autoPullWork);  //清除之前的任務
            removeCallbacks(autoBackWork);
            lastWord = currentWord;   // 上一次高亮的位置
            currentWord = 3;
            post(autoBackWork); //重新移動位置
            postDelayed(autoPullWork,timeList.get(0)-time); 
        }else if (time>=timeList.get(timeList.size()-1)){  //時間大於最後一句位置
            removeCallbacks(autoPullWork);
            removeCallbacks(autoBackWork);  //清除之前的任務
            lastWord = currentWord; 
            currentWord = wordLength+3; 當前應該顯示的歌詞位置
            post(autoPullWork);
            postDelayed(autoBackWork,2000);
        }else {  
            removeCallbacks(autoPullWork);
            removeCallbacks(autoBackWork);
            int position = 0;
            for (int i=0;i<timeList.size()-1;i++){   //找出比這個時間快一點的歌詞
                if (time>timeList.get(i)&&time<timeList.get(i+1)){
                    position =i;
                    break;
                }
            }
            int a = timeList.get(currentWord-3)-time;
            lastWord = currentWord-1;
            currentWord = position+4;
            post(autoBackWork);
            postDelayed(autoPullWork,timeList.get(currentWord-3)-time); 與下一句單詞間隔
        }
    }
複製程式碼

   這次做一個自定義 View 控制元件,讓我有好幾點感觸,我記錄一下,一方面是希望告誡自己,一方面也算是分享給他人吧。 

        1.當你要做某個控制元件或專案的時候,不要著急著動筆。要先想好整個流程和框架。這方面先考慮清楚在動筆寫。你的邏輯一定要現在白紙上實現一遍後才開始敲程式碼。就像我之前做的專案還有這次這個控制元件,我都比較著急寫。等到開始執行的時候,出現了跟我想的不太一樣。那我又根據結果去改程式碼,但是這可能只是代表著某一個方面而已,下次有可能其他方面出問題了。這樣你就會被問題牽著走,而不能從整體上去看問題。 -

        2.事情總是一點一點一點地解決。在寫程式碼的過程中,總有我們當時不知道的,不會的,不知道怎麼做的。但是也正是因為這些東西我們才會擴充套件了更多,豐富了許多,從另一個方面講,這也是在跳出舒適區吧,所以不要慌張,作為工程師,或者說作為生活的人,我們都需要有耐心和熱情。 

         共勉 



相關文章