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

先来几张效果图:


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 控件,让我有好几点感触,我记录一下,一方面是希望告诫自己,一方面也算是分享给他人吧。

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

共勉

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,716评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,558评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,431评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,127评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,511评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,692评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,915评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,664评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,412评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,616评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,105评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,424评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,098评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,096评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,869评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,748评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,641评论 2 271

推荐阅读更多精彩内容