美文网首页自定义控件Android开发Android开发
基于 RecyclerView 实现的歌词滚动自定义控件

基于 RecyclerView 实现的歌词滚动自定义控件

作者: 恒夕 | 来源:发表于2018-03-21 23:19 被阅读873次

    先来几张效果图:


    GIF.gif
    GIF1.gif

    这几天打算做一个控件,来让自己复习一下自定义 view 的知识以及事件分发机制的原理与应用。对于这个控件,我已经封装好了,只要调用就可以了。
    这是我的 gitHub 欢迎 star 和 fork,之前没怎么用过,请大家多多捧场,哈哈!
    https://github.com/Yeahlz/WordView#wordview
    接下来说一下实现原理:
    该控件分为以下几个部分:

    • 歌词自动滚动
    • 歌词颜色字体变化
    • 触碰屏幕歌词不滚动,高亮显示,离开时自动移动到当前歌词位置
    • 触碰屏幕中间线条出现以及显示该歌词的时间
    • 点击歌词跳转到当前位置并输出当时时间
    • 可设置跳转时间跳到相应歌词位置

    接下来我一个一个大概讲述一下思路。
    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。
    这个是整个的 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 就已经重写了拦截了该事件的。而且一般是父 View 是不拦截事件的。那我们要怎么在里面设置 down 时间和 up 事件呢?我们怎么能让父 View 接收到事件处理了一下同时最后又是子 view 处理事件呢?
    在此,我推荐一篇博客,里面很详细地介绍了事件分发处理机制的流程。
    https://www.jianshu.com/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);     //出现
                    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 的距离的比较。从而解决问题。

    最开始只是根据第一个可视化位置而显示的时间,但是显示时间变化的位置不对。

    GIF3.gif

    改了思路根据第二个可视化位置之后根据位移来判断。

    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) {  // 根据距离来判断当前应该显示哪个时间
                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;
    

    6.点击进度条跳转到相应位置
    先调用 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 控件,让我有好几点感触,我记录一下,一方面是希望告诫自己,一方面也算是分享给他人吧。

    • 当你要做某个控件或项目的时候,不要着急着动笔。要先想好整个流程和框架。这方面先考虑清楚在动笔写。你的逻辑一定要现在白纸上实现一遍后才开始敲代码。就像我之前做的项目还有这次这个控件,我都比较着急写。等到开始运行的时候,出现了跟我想的不太一样。那我又根据结果去改代码,但是这可能只是代表着某一个方面而已,下次有可能其他方面出问题了。这样你就会被问题牵着走,而不能从整体上去看问题。
    • 事情总是一点一点一点地解决。在写代码的过程中,总有我们当时不知道的,不会的,不知道怎么做的。但是也正是因为这些东西我们才会扩展了更多,丰富了许多,从另一个方面讲,这也是在跳出舒适区吧,所以不要慌张,作为工程师,或者说作为生活的人,我们都需要有耐心和热情。

    共勉

    相关文章

      网友评论

      本文标题:基于 RecyclerView 实现的歌词滚动自定义控件

      本文链接:https://www.haomeiwen.com/subject/xafgqftx.html