在之前使用RecyclerView的时候,实现对RecyclerView中每一个item的点击事件一直是在Adapter的onBindViewHolder中为其itemView添加onClickListener来实现的,这种实现方式有以下问题:
- 不够优雅:这种方式需要使用adapter来set点击事件,但是理应是由RecyclerView来添加监听器才符合认知,因此这种方式太难看;
- 性能问题:最大的问题处在于这是为每一个item都添加了一个OnClickListener,虽然RecyclerView中有缓存机制,但还是会产生多个OnClickListener。
为了解决这个问题,阅读ListView的实现发现,在ListView中,通过以下方式确定点击的位置:
点击区域 -> 定位child -> 对应在Adapter中的position
在View的TouchEvent过程中,第一个处理的总是DOWN事件,因此首先来看看ListView是如何处理DOWN事件的:
// AbsListView.java
private void onTouchDown(MotionEvent ev) {
..
if (mTouchMode == TOUCH_MODE_OVERFLING) {
...
} else {
final int x = (int) ev.getX();
final int y = (int) ev.getY();
// 正是通过该关键方法来定位到点击区域所对应哪一个Child 的
int motionPosition = pointToPosition(x, y);
...
}
...
}
上面注释的方法正式根据MotionEvent来定位child 的关键方法:
// AbsListView.java
public int pointToPosition(int x, int y) {
Rect frame = mTouchFrame;
if (frame == null) {
mTouchFrame = new Rect();
frame = mTouchFrame;
}
final int count = getChildCount();
for (int i = count - 1; i >= 0; i--) {
final View child = getChildAt(i);
if (child.getVisibility() == View.VISIBLE) {
child.getHitRect(frame);
if (frame.contains(x, y)) {
// AbsListView的mFirstPosition很关键
return mFirstPosition + i;
}
}
}
return INVALID_POSITION;
}
我去,原来这么简单就能找到对应的child,值得注意的是,这里该方法直接返回的是该child在Adapter中对应的position,得益于AbsListView维护的mFirstPosition属性,改属性表示当前屏幕所展示的item中第一个item对应在Adapter中的position。即在第一屏数据加载好时该属性为0,当向上滑动手指,列表下滚导致滚出屏幕3个(对应0,1,2),那么此时mFirstPosition就为3。
紧接着,处理UP事件的方法如下:
//AbsListView.java
private void onTouchUp(MotionEvent ev) {
switch (mTouchMode) {
case TOUCH_MODE_DOWN:
case TOUCH_MODE_TAP:
case TOUCH_MODE_DONE_WAITING:
// 获取在DOWN事件时得到的mMotionPosition
final int motionPosition = mMotionPosition;
...
if (inList && !child.hasExplicitFocusable()) {
if (mPerformClick == null) {
mPerformClick = new PerformClick();
}
final AbsListView.PerformClick performClick = mPerformClick;
// 将motionPosition保存了下来
performClick.mClickMotionPosition = motionPosition;
...
else if (!mDataChanged && mAdapter.isEnabled(motionPosition)) {
// 执行
performClick.run();
}
}
}
...
...
}
该方法对UP事件的处理也很清晰明了,就是分两步:
- PerformClick记录一下motionPosition;
- 调用performClick.run执行点击事件
接下来就看看PerformClick:
// AbsListView.java
private class PerformClick extends WindowRunnnable implements Runnable {
int mClickMotionPosition;
@Override
public void run() {
// The data has changed since we posted this action in the event queue,
// bail out before bad things happen
if (mDataChanged) return;
final ListAdapter adapter = mAdapter;
final int motionPosition = mClickMotionPosition;
if (adapter != null && mItemCount > 0 &&
motionPosition != INVALID_POSITION &&
motionPosition < adapter.getCount() && sameWindow() &&
adapter.isEnabled(motionPosition)) {
// 根据position得到定位到的child
final View view = getChildAt(motionPosition - mFirstPosition);
if (view != null) {
// 该方法内部会回调mOnItemClickListener的方法
performItemClick(view, motionPosition, adapter.getItemId(motionPosition));
}
}
}
}
可以发先,其原理就是根据position得到child,然后内部调用OnItemClickListener完成。
小结
可以发现ListView实现Item Click监听分如下几步:
- 使用for循环对children进行遍历,找到MotionEvent点击区域所属的child;
- 得到child所对应Adapter的position;
- 调用OnItemClickListener监听。
为此,我们在RecyclerView也可以如法炮制。
Q1:如何定位child?
在ListView中,定位child的前提是能够捕捉到MotionEvent,根据点击事件的x和y确定child。这...难道要重写onTouchEvent?虽然只需要进行一点逻辑的添加,然后直接super.onTouchEvent就行了,但是再怎么省事儿终归要定义一个RecyclerView的子类来干这些啊?为了实现ItemClickListener这未必有些麻烦了。
唉,我们可以使用onTouchListener啊,这里也可以获取到MotionEvent,并且onTouchListener返回false的话,RecyclerView就会自动调用其完备的onTouchEvent处理事件。嗯,可行。
Q2:如何获取position?
在ListView中,position得益于AbsListView维护了mFirstPosition这个变量,那么结合for循环的index就可以得到child对应的position。同样类似,在RecyclerView.ViewHolder中也提供了相应的api -- getAdapterPosition()。同时,RecyclerView提供了将View转化为ViewHolder的api -- getChildViewHolder(View)。因此,也能够获取position了。
有了上述两个支持,开干!!
fun RecyclerView.setOnItemClickListener(onItemClickListener: (View, Int) -> Unit) {
this.setOnTouchListener { v, event ->
if (event.action == MotionEvent.ACTION_UP) {
var frame = Rect()
for (child in (v as RecyclerView).children) {
if (child.visibility != View.VISIBLE)
continue
// 模仿AbsListView定位hild
child.getHitRect(frame)
if(!frame.contains(event.x.toInt(), event.y.toInt()))
continue
// 获取position
val pos = this.getChildViewHolder(child).adapterPosition
// 调用点击事件
onItemClickListener(this, pos)
break
}
}
// 返回false很关键,我们只是处理item点击,滚动等其他逻辑还要委托回onTouchEvent方法处理
false
}
}
得益于Kotlin对扩展的支持,上述函数定义使用起来就优雅多了:
recycler = findViewById(R.id.recycler)
recycler.adapter = TestAdapter()
recycler.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
recycler.setOnItemClickListener { v, pos ->
Log.d("MainActivity", "========结果是:${position}")
}
经过使用,上述代码确实可以完成效果,但是又产生了一个问题,就是当发生滚动、长按事件情况下,手指抬起也会出发,这就不应该了,为此,只能舍弃上面那种优雅的实现方式,专门定义一个Listener类来实现对时间类型的过滤,修改后的代码如下:
abstract class OnItemClickListener {
var downX: Float = -1f
var downY: Float = -1f
var downTime: Long = 0L
private fun reset() {
downTime = 0L
downX = -1f
downY = -1f
}
// 用来记录down事件装填
fun record(event: MotionEvent) {
downX = event.x
downY = event.y
downTime = event.downTime
}
// 用来过滤掉长按事件与move可能产生的滚动事件
fun performClick(event: MotionEvent, recyclerView: View, position: Int) {
if (Math.abs(downX - event.x) < 8 && Math.abs(downY - event.y) < 8 && event.eventTime - downTime < 500)
onItemClick(recyclerView, position)
reset()
}
abstract fun onItemClick(recyclerView: View, position: Int)
}
// 实现类
class OnItemClickListenerImpl: OnItemClickListener() {
override fun onItemClick(recyclerView: View, position: Int) {
Log.d("MainActivity", "========结果是:${position}")
}
}
扩展方法定义与使用如下:
fun RecyclerView.setOnItemClickListener(onItemClickListener: OnItemClickListener) {
this.setOnTouchListener { v, event ->
if (event.action == MotionEvent.ACTION_DOWN)
onItemClickListener.record(event)
else if (event.action == MotionEvent.ACTION_UP) {
var frame = Rect()
for (child in (v as RecyclerView).children) {
if (child.visibility != View.VISIBLE)
continue
child.getHitRect(frame)
if(!frame.contains(event.x.toInt(), event.y.toInt()))
continue
val pos = this.getChildViewHolder(child).adapterPosition
onItemClickListener.performClick(event, this, pos)
break
}
}
false
}
}
------------ 分割线 ----------------
recycler = findViewById(R.id.recycler)
recycler.adapter = TestAdapter()
recycler.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
recycler.setOnItemClickListener(OnItemClickListenerImpl())
这样一来,就完善了功能。
总结
通过参考ListView的OnItemClickListener设计,完成了对RecyclerView的OnItemClickListener设计,但是还有不足:
- 对事件类型的过滤还不够严谨,这里只是最简单情况下的过滤;
另外,由于使用的是kotlin,所以可以通过函数扩展实现Recycler.setOnItemClickListener这种表现形式的方法调用,如果是使用Java的话,建议将相关方法设置到自定义Adapter中:
public class MyTestAdapter extends RecyclerView.Adapter<TestHolder> {
private OnItemClickListener onItemClickListener;
public void setOnItemClickListener(OnItemClickListener listener) {
this.onItemClickListener = listener;
}
@Override
public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
super.onAttachedToRecyclerView(recyclerView);
recyclerView.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_DOWN)
onItemClickListener.record(event);
else if (event.getAction() == MotionEvent.ACTION_UP) {
Rect frame = new Rect();
for (int i = 0; i < recyclerView.getChildCount(); i ++) {
View child = recyclerView.getChildAt(i);
if (child.getVisibility() != View.VISIBLE)
continue;
child.getHitRect(frame);
if(!frame.contains((int)event.getX(), (int)event.getY()))
continue;
int pos = recyclerView.getChildViewHolder(child).getAdapterPosition();
onItemClickListener.performClick(event, recyclerView, pos);
break;
}
}
return false;
}
});
}
....
}