自定义view(1)-歌词列表

68 阅读7分钟

本文已参与「新人创作礼」活动, 一起开启掘金创作之路。

引言

本文是自定义view系列的第一篇,主要是个人用于记录和分享开发过程中遇到的一些问题什么的,分享给大家,也可以在后面我自己需要时进行查阅。希望可以坚持下去。
已经有太多的文章介绍自定义view了,包括但不限于绘制流程、加载流程、事件分发等等,这些前人很多文章都是很有价值的,我也学到过很多东西。目前自认为能力还是比较有限的,属于是会用,如果要讲也能讲一些自己的看法,但是自己实际的深入跟源码还没有进行过,所有的知识也只来源于“前人的智慧”。担心会给读者带来一些误导,这里目前就不献丑了,日后有机会真正梳理过了,还会拿出来分享的。
PS:有需要的可以直接去文章末尾,有直接的view全代码。代码里使用的baseview之前的文章里有。

lyrics view

来源

最近开发过程中,遇到了需求:需要“借鉴”某些音乐播放的APP,实现歌词滚动的效果。 image.png

  • 上面就是界面的ui了,分析一下需要达到的效果
  1. 可以上下手动的拖拽操作 ---> recycle view 自带的就可以
  2. 有一行文字高亮 ----> 这个很好做
  3. 第一行前面和最后一行后面,有单独的空行 ---> 使用ItemDecoration应该可以
  4. 高亮的一直是第二个,需要根据播放进度翻动;如果手动翻动了,停用根据进度翻动;如果刚刚手动翻动了,一定时间内不跟随进度翻动 ---> *****

这里我根据做的时候遇到的麻烦程度,一个个来安排上。

封装

这里我考虑到日后说不定别的地方改改就可以直接用,将其封装成了一个自定义的view,这样到时候那里需要,直接copy过去稍微改改就可以用了。
软件开发的原则是:有的抄先抄,没得抄想办法抄。
我选择让他继承BaseView(这货实际上就是一个framelayout),然后在其中加入一个recycleview即可。

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler"
        android:overScrollMode="never"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>

我之前的文章中有提到该baseview,个人觉得很方便使用,只需要写布局就行了。
既然是单列,竖着的,我们简单的设置layoutmanager即可:

layoutManager = new LinearLayoutManager(mContext, RecyclerView.VERTICAL, false);
mDataBinding.recycler.setLayoutManager(layoutManager);

再设置一点假数据(先写死):

lyricsAdapter = new LyricsAdapter(mContext, TestUtils.getTestLyrics());
mDataBinding.recycler.setAdapter(lyricsAdapter);

TestUtils.getTestLyrics()中只是返回了一个假的String的list。
这样一个竖向单列的recycle就跑起来了。

高亮

需求

未拖动时,高亮可见的第二个;拖动时,高亮对应进度应该显示的那个

分析

需求里高亮可见的第二个,相信读者有很多种方式来实现,下面是我想到的方式,之前别的项目的时候也使用过:

  • 使用layoutmanager获取屏幕内可见的第一个和最后一个的position来做对应的操作
  • 在adapter中获取所有item的view对象,保存起来,自己分析对应的显示逻辑 自己的实践结果来看,这些虽然都能达到效果,但过程中真的踩了很多坑。例如:layoutmanager获取的屏幕中可见的第一个有时候是不可信的!!!比如你上一个item已经离开屏幕了,肉眼已经不可见了;但实际上只要它还有一个px在屏幕里,就会被判定成第一个可见的。诸如此类的坑还有很多,说多了都是泪~

具体实现

下面来说说我是怎么实现的:
我使用livedatabus发送高亮的position,每个item都会接收到这个position,如果一样就更新成高亮。
livedatabus是一种基于观察者模式的总线类型的收发组件。 简单来说,你A处观察一个tag对应的值,B处C处可以发送一个值,A处就能接收到,做对应的处理。
个人感觉还是非常好用的,能节省很多代码。并且,它是生命周期相关的,会自动在lifecyclerowner销毁时一起销毁。

@SuppressLint("ResourceAsColor")
@Override
protected void bindItem(LayoutItemLyricsBinding dataBinding, String s, int position) {
    dataBinding.tvLyrics.setText(s);

    highlight(dataBinding, highlightPosition, position);

    LiveDataBus2.get().with(LiveDataBusConstants.highlight_line, Integer.class).observe((LifecycleOwner) mContext, integer -> {
        if (integer != null) {
            highlight(dataBinding, integer, position);
        }
    });

    dataBinding.getRoot().setOnClickListener(v -> {
        setHighlightPosition(position);
        if (listener != null) listener.onClick(position);
    });
}

在每个item生成的时候,会bindItem方法。我在其中监听LiveDataBusConstants.highlight_line这个tag对应的值。这样,每个item都会接收到:如果一样就设置不一样的颜色:

/**
 * 不处理滑动 仅修改高亮的item
 */
private void highlight(LayoutItemLyricsBinding dataBinding, int highlightPos, int position) {
    dataBinding.tvLyrics.setTextColor(highlightPos == position ? Color.parseColor("#FFFFFF") : Color.parseColor("#8CFFFFFF"));
    dataBinding.tvLyrics.setTag(highlightPos == position);
    highlightPosition = highlightPos;
}

接下来是发送:

public void setHighlightPosition(int highlightPosition) {
    if (highlightPosition < 0 || highlightPosition > lyricsAdapter.getItemCount() - 1) return;

    smoothScrollToPosition(highlightPosition);

    this.highlightPosition = highlightPosition;

    LiveDataBus2.get().with(LiveDataBusConstants.highlight_line).postValue(highlightPosition);
}

实际上和前文相关的是最后一行,发送了这个position值,每个item都能接收到。

到了这里,我们就完成了高亮的操作,实际上没用几行代码。

间隔

需求

第一行前面和最后一行后面,有单独的空行

分析

recycleview提供了设置间距相关的类,直接使用就行。

实现

判断如果是第一个,就加top的偏移量;如果是最后一个,就加bottom偏移量。这里还是很好理解的,直接上代码:

// 设置第一个前方的边距 及最后一个后方的边距
mDataBinding.recycler.addItemDecoration(new RecyclerView.ItemDecoration() {
    @Override
    public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);
        int position = parent.getChildAdapterPosition(view);
        if (position == 0) {
            lyricTopMargin = DensityUtils.dp2Px(mContext, 40);
            outRect.top = lyricTopMargin;
        } else if (position == parent.getAdapter().getItemCount() - 1) {
            lyricBottomMargin = DensityUtils.dp2Px(mContext, 320);
            outRect.bottom = lyricBottomMargin;
        }
    }
});

交互

这里就恶心了,其实前面的和别的简单的列表展示没什么区别;这里才是这个做这个自定义view遇到的一些坑。

需求

  • 高亮的一直是第二个,需要根据播放进度翻动
  • 如果手动翻动了,停用根据进度翻动
  • 如果刚刚手动翻动了,一定时间内不跟随进度翻动

分析

一开始想岔了,做了很多别的工作:判断界面中显示的是第几个,距离目标的position差了几个,应该如何位移等等。其实有更简单的做法:
无论歌词怎么滑动,我们都可以想办法计算出目标位置和初始位置的纵向位移差;再和当前与初始位置纵向的位移差作差,就可以获取当前应该位移的距离。
记录位移差:

mDataBinding.recycler.addOnScrollListener(new RecyclerView.OnScrollListener() {
    @Override
    public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
        super.onScrolled(recyclerView, dx, dy);
        totalScroll += dy;
    }
});

滚动方法:

/**
 * 将position高亮并显示在屏幕中的第二个位置
 */
private void smoothScrollToPosition(int position) {
    int toY = lyricTopMargin + (position - 1) * getItemHeight();  // 相对于初始状态时,目标偏移量
    int dY = toY - totalScroll;  // 相对于初始位置 当前偏移量
    LogUtils.d("to y : " + toY + " totalScroll : " + totalScroll + " dy : " + dY);
    mDataBinding.recycler.smoothScrollBy(0, dY);
}

这样,嘎~的一下,就解决了问题。没想到的时候,却思考了一个下午,真是气煞我也!

全部代码

import java.util.List;

public class LyricsView extends BaseView<LayoutViewLyricsBinding> {
    private static final long AFTER_ENDING_SCROLL_DURATION = 1000;
    private LyricsAdapter lyricsAdapter;
    // 是否正在通过点击歌词来切换歌词
    private boolean isClickMoving = false;
    private int lyricTopMargin;
    private int lyricBottomMargin;

    private int highlightPosition;

    private LinearLayoutManager layoutManager;

    // 当前的滑动总量
    private int totalScroll;
    private long endScrollTime;

    public LyricsView(@NonNull Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    protected void init(AttributeSet attrs) {
        layoutManager = new LinearLayoutManager(mContext, RecyclerView.VERTICAL, false);
        mDataBinding.recycler.setLayoutManager(layoutManager);
        lyricsAdapter = new LyricsAdapter(mContext, TestUtils.getTestLyrics());
        mDataBinding.recycler.setAdapter(lyricsAdapter);

        // 设置第一个前方的边距 及最后一个后方的边距
        mDataBinding.recycler.addItemDecoration(new RecyclerView.ItemDecoration() {
            @Override
            public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
                super.getItemOffsets(outRect, view, parent, state);
                int position = parent.getChildAdapterPosition(view);
                if (position == 0) {
                    lyricTopMargin = DensityUtils.dp2Px(mContext, 40);
                    outRect.top = lyricTopMargin;
                } else if (position == parent.getAdapter().getItemCount() - 1) {
                    lyricBottomMargin = DensityUtils.dp2Px(mContext, 320);
                    outRect.bottom = lyricBottomMargin;
                }
            }
        });

        mDataBinding.recycler.addOnScrollListener(new RecyclerView.OnScrollListener() {
            boolean shouldRecord = false;

            @Override
            public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
                super.onScrollStateChanged(recyclerView, newState);
                switch (newState) {
                    case SCROLL_STATE_DRAGGING:
                        shouldRecord = true;
                        break;
                    case SCROLL_STATE_IDLE:
                        if (shouldRecord) {
                            // 滚动停止的时候,记录当前时间
                            endScrollTime = System.currentTimeMillis();
                            shouldRecord = false;
                        }
                        break;
                }
            }

            @Override
            public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
                super.onScrolled(recyclerView, dx, dy);

                totalScroll += dy;
            }
        });
    }

    @Override
    protected int getLayoutId() {
        return R.layout.layout_view_lyrics;
    }

    public class LyricsAdapter extends BaseRecyclerViewAdapter<String, LayoutItemLyricsBinding> {

        public LyricsAdapter(Context mContext, List<String> mData) {
            super(mContext, mData);
        }

        @Override
        protected int getLayoutId() {
            return R.layout.layout_item_lyrics;
        }

        @SuppressLint("ResourceAsColor")
        @Override
        protected void bindItem(LayoutItemLyricsBinding dataBinding, String s, int position) {
            dataBinding.tvLyrics.setText(s);

            highlight(dataBinding, highlightPosition, position);

            LiveDataBus2.get().with(LiveDataBusConstants.highlight_line, Integer.class).observe((LifecycleOwner) mContext, integer -> {
                if (integer != null) {
                    highlight(dataBinding, integer, position);
                }
            });

            dataBinding.getRoot().setOnClickListener(v -> {
                setHighlightPosition(position);
                if (listener != null) listener.onClick(position);
            });
        }

        /**
         * 不处理滑动 仅修改高亮的item
         */
        private void highlight(LayoutItemLyricsBinding dataBinding, int highlightPos, int position) {
            dataBinding.tvLyrics.setTextColor(highlightPos == position ? Color.parseColor("#FFFFFF") : Color.parseColor("#8CFFFFFF"));
            dataBinding.tvLyrics.setTag(highlightPos == position);
            highlightPosition = highlightPos;
        }
    }

    /**
     * 将position高亮并显示在屏幕中的第二个位置
     */
    private void smoothScrollToPosition(int position) {
        if (mDataBinding.recycler.getScrollState() == SCROLL_STATE_DRAGGING
                || System.currentTimeMillis() - endScrollTime < AFTER_ENDING_SCROLL_DURATION)
            return;

        int toY = lyricTopMargin + (position - 1) * getItemHeight();  // 相对于初始状态时,目标偏移量
        int dY = toY - totalScroll;  // 相对于初始位置 当前偏移量
        LogUtils.d("to y : " + toY + " totalScroll : " + totalScroll + " dy : " + dY);
        mDataBinding.recycler.smoothScrollBy(0, dY);
    }

    public int getHighlightPosition() {
        return highlightPosition;
    }

    public void setHighlightPosition(int highlightPosition) {
        if (highlightPosition < 0 || highlightPosition > lyricsAdapter.getItemCount() - 1) return;

        smoothScrollToPosition(highlightPosition);

        this.highlightPosition = highlightPosition;

        LiveDataBus2.get().with(LiveDataBusConstants.highlight_line).postValue(highlightPosition);
    }

    /**
     * 获取一个item的高度
     */
    private int getItemHeight() {
        return DensityUtils.dp2Px(mContext, 40);
    }

    private OnItemClickListener listener;

    public void setListener(OnItemClickListener listener) {
        this.listener = listener;
    }

    public interface OnItemClickListener {
        void onClick(int position);
    }
}

留言

希望可以给读者带来一些帮助~