本文已参与「新人创作礼」活动, 一起开启掘金创作之路。
引言
本文是自定义view系列的第一篇,主要是个人用于记录和分享开发过程中遇到的一些问题什么的,分享给大家,也可以在后面我自己需要时进行查阅。希望可以坚持下去。
已经有太多的文章介绍自定义view了,包括但不限于绘制流程、加载流程、事件分发等等,这些前人很多文章都是很有价值的,我也学到过很多东西。目前自认为能力还是比较有限的,属于是会用,如果要讲也能讲一些自己的看法,但是自己实际的深入跟源码还没有进行过,所有的知识也只来源于“前人的智慧”。担心会给读者带来一些误导,这里目前就不献丑了,日后有机会真正梳理过了,还会拿出来分享的。
PS:有需要的可以直接去文章末尾,有直接的view全代码。代码里使用的baseview之前的文章里有。
lyrics view
来源
最近开发过程中,遇到了需求:需要“借鉴”某些音乐播放的APP,实现歌词滚动的效果。
- 上面就是界面的ui了,分析一下需要达到的效果
- 可以上下手动的拖拽操作 ---> recycle view 自带的就可以
- 有一行文字高亮 ----> 这个很好做
- 第一行前面和最后一行后面,有单独的空行 ---> 使用ItemDecoration应该可以
- 高亮的一直是第二个,需要根据播放进度翻动;如果手动翻动了,停用根据进度翻动;如果刚刚手动翻动了,一定时间内不跟随进度翻动 ---> *****
这里我根据做的时候遇到的麻烦程度,一个个来安排上。
封装
这里我考虑到日后说不定别的地方改改就可以直接用,将其封装成了一个自定义的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);
}
}
留言
希望可以给读者带来一些帮助~