基于 RecyclerView 實現(xiàn)的歌詞滾動自定義控件

先來幾張效果圖:


GIF.gif

GIF1.gif

這幾天打算做一個控件,來讓自己復習一下自定義 view 的知識以及事件分發(fā)機制的原理與應用。對于這個控件,我已經(jīng)封裝好了,只要調(diào)用就可以了。
這是我的 gitHub 歡迎 star 和 fork,之前沒怎么用過,請大家多多捧場,哈哈!
https://github.com/Yeahlz/WordView#wordview
接下來說一下實現(xiàn)原理:
該控件分為以下幾個部分:

  • 歌詞自動滾動
  • 歌詞顏色字體變化
  • 觸碰屏幕歌詞不滾動,高亮顯示,離開時自動移動到當前歌詞位置
  • 觸碰屏幕中間線條出現(xiàn)以及顯示該歌詞的時間
  • 點擊歌詞跳轉到當前位置并輸出當時時間
  • 可設置跳轉時間跳到相應歌詞位置

接下來我一個一個大概講述一下思路。
1.對于滾動,我們可以調(diào)用 RecyclerView.smoothScrollBy() 方法,
相對于 ScrollBy() 方法,該方法能夠實現(xiàn)平滑滑動。
我設置了總共顯示九句歌詞。而且因為我想在歌詞前面和后面留一些空白,這些看起來會好看些。所以,在歌詞列表里面我加多了一些空白。

 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 來執(zhí)行滾動操作。而且為了避免內(nèi)存泄漏。將 Runable 實現(xiàn)類修飾為 static 。由于歌詞的滾自動滾動是根據(jù)歌詞時間來進行移動的。前面已經(jīng)看到歌詞列表索引位置跟時間列表位置有所變化,所以下面索引操作有些變化。

 private static class AutoPullWork implements Runnable {   //執(zhí)行歌詞滾動的 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.對于歌詞的高亮顯示,我們可以調(diào)用 notifyItemChange(int position) 方法,這個方法調(diào)用會重新去繪制特定 position 上的 viewHolder 。hightLightItem() 在這個方法中設置我們想要改變 viewHolder 的位置,并調(diào)用 notifyItemChange(int position) 。然后在 onBindViewHolder() 中的設置可以判斷當前是否需要高亮顯示。

 public void hightLightItem(int position){   // 外部調(diào)用 adapter 中這個辦法,用于設置要高亮顯示的位置,并調(diào)用重繪特定 position
         mHighLightPosition = position;
         notifyItemChanged(position-1);
         notifyItemChanged(position);
    }
 private boolean isHighLight(int position){  // 在 onBindViewHolder 中調(diào)用 用于判斷當前是否需要高亮顯示
        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() 里面進行操作。但是一個很嚴重的問題,導致我連續(xù)幾天一直想不到對策方法。由于手指離開屏幕的時候我使用 postDelayed() 方法有可能跟里面 Runable 里面使用的 postDelayed() 時間上可能會相互沖突,事件的執(zhí)行情況就很有可能變得跟你想不一樣。所以我們應該重新寫一個 Runable() 來控制它的自動移動到當前位置。這樣子的話各做各的事情,在寫邏輯的時候會比較容易理順。(當時沒想好害我調(diào)了好久,一直都不對,哈哈).

 /**
     *  歌詞自動滑動到特定位置任務
     */
    private static class AutoBackWork implements Runnable{  //開啟另一個任務來控制歌詞自動移動到當前位置

        @Override
        public void run() {
        }  
    }

對于點擊屏幕時就重寫 onTouchEvent() 方法,
在 down 事件中 ,設置變量讓 Runable () 事件中不滾動。
而對于歌詞在離開屏幕后的一段時間后自動回到該位置。同樣的,還是需要使用 smoothScrollBy() 方法移動。而移動多少呢?這是個問題。這個要分為四種情況:
第一種:
當前歌詞在屏幕之外:由于我是打算將歌詞移動到屏幕中的第四個位置。
那么我就需要找到屏幕中的第一個位置,還有當前顯示的是哪一句歌詞。
由于我是想要讓他顯示在屏幕的第四行,所以是相差 currentWord + 5 - firstPosition 個位置 。
第二種:
當歌詞在第四行之前但是在第一行之后。
第三種:
當歌詞在第四行之后但是在最后一行之前。
第四種:
當歌詞在最后一行之后。
其實我們就根據(jù)自己想要在顯示在第幾行來判斷需要移動多少個位置。
我就不詳說啦,具體看代碼:

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。
這個是整個的 xml 文件。

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:clickable="true"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <com.example.administrator.animationview.AutoPullRecyclerView
        android:id="@+id/auto_word"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>
    <RelativeLayout
        android:layout_centerVertical="true"
        android:id="@+id/divide_line"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
    <ImageView
        android:id="@+id/item_play_here"
        android:layout_marginStart="8dp"
        android:layout_centerVertical="true"
        android:src="@drawable/play"
        android:layout_width="20dp"
        android:layout_height="20dp" />
    <View
        android:id="@+id/divide_line1"
        android:layout_marginEnd="48dp"
        android:layout_marginStart="4dp"
        android:layout_toEndOf="@+id/item_play_here"
        android:layout_centerVertical="true"
        android:background="#E6E6FA"
        android:layout_width="match_parent"
        android:layout_height="1px"/>
    <TextView
        android:id="@+id/time1"
        android:layout_marginEnd="4dp"
        android:layout_alignParentEnd="true"
        android:layout_centerVertical="true"
        android:textSize="12sp"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />
    </RelativeLayout>

</RelativeLayout>
image.png

中間線的邏輯是當點擊屏幕的時候顯示出中間的線,離開屏幕的時候過一小段時間消失。也就是需要處理 down 事件和 up 事件 。但是我們在 RecyclerView 中是處理了點擊事件的,而且本身 RecyclerView 就已經(jīng)重寫了攔截了該事件的。而且一般是父 View 是不攔截事件的。那我們要怎么在里面設置 down 時間和 up 事件呢?我們怎么能讓父 View 接收到事件處理了一下同時最后又是子 view 處理事件呢?
在此,我推薦一篇博客,里面很詳細地介紹了事件分發(fā)處理機制的流程。
http://www.itdecent.cn/p/e99b5e8bd67b?utm_campaign=haruki&utm_content=note&utm_medium=reader_share&utm_source=weixin

我先說一下結論吧。就是重寫 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);     //出現(xiàn)
                show = true;
                view.setOnClickListener(new OnClickListener() {
                    @Override
                    public void onClick(View view) {
                        autoPullRecyclerView.setComeToPlay(); // 調(diào)用方法跳轉到當前歌詞
                        onClickListener.onClickListener(mCurrentTime); //回調(diào)當前歌詞時間
                    }
                });
                break;
            case MotionEvent.ACTION_UP:
                view.removeCallbacks(runnable); //除去原先所有事件,因為有可能有多個 up 操作,我們只需要保留最后一個。
                view.postDelayed(runnable,4000);
                break;
            default:
                break;
        }
        return super.dispatchTouchEvent(ev); // 調(diào)用攔截器
    }

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

最開始只是根據(jù)第一個可視化位置而顯示的時間,但是顯示時間變化的位置不對。

GIF3.gif

改了思路根據(jù)第二個可視化位置之后根據(jù)位移來判斷。

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

5.點擊歌詞跳轉并且返回時間
點擊歌詞的時候改變高亮的位置和恢復原先的高亮的位置,并且通過回調(diào)返回時間。

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); // 回調(diào)
                    }
                });
                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;

6.點擊進度條跳轉到相應位置
先調(diào)用 seekBar 的 onSeekBarChangeListener() 中監(jiān)聽方法,獲取當前時間,根據(jù)時間獲得當前應該所處的索引。然后調(diào)用自動移動滾動方法和高亮方法。

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 控件,讓我有好幾點感觸,我記錄一下,一方面是希望告誡自己,一方面也算是分享給他人吧。

  • 當你要做某個控件或項目的時候,不要著急著動筆。要先想好整個流程和框架。這方面先考慮清楚在動筆寫。你的邏輯一定要現(xiàn)在白紙上實現(xiàn)一遍后才開始敲代碼。就像我之前做的項目還有這次這個控件,我都比較著急寫。等到開始運行的時候,出現(xiàn)了跟我想的不太一樣。那我又根據(jù)結果去改代碼,但是這可能只是代表著某一個方面而已,下次有可能其他方面出問題了。這樣你就會被問題牽著走,而不能從整體上去看問題。
  • 事情總是一點一點一點地解決。在寫代碼的過程中,總有我們當時不知道的,不會的,不知道怎么做的。但是也正是因為這些東西我們才會擴展了更多,豐富了許多,從另一個方面講,這也是在跳出舒適區(qū)吧,所以不要慌張,作為工程師,或者說作為生活的人,我們都需要有耐心和熱情。

共勉

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

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

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