读源码长知识 | 更好的 RecyclerView 表项点击监听器
作者:唐子玄
链接:https://juejin.cn/post/6844903862361391117
RecyclerView没有提供表项点击事件监听器,只能自己处理。
这是读源码长知识系列的第一篇,该系列的特点是将源码中的设计思想运用到真实项目之中,系列文章目录如下:
读源码长知识 | 更好的RecyclerView点击监听器
Android自定义控件 | 源码里有宝藏之自动换行控件
Android自定义控件 | 小红点的三种实现(下)
读源码长知识 | 动态扩展类并绑定生命周期的新方式
读源码长知识 | Android卡顿真的是因为”掉帧“?
方案一:层层传递点击监听回调
RecyclerView表项被ViewHolder持有,而ViewHolder在RecyclerView.Adapter中被构建。所以将点击事件回调注入RecyclerView.Adapter,再传递给ViewHolder,并为itemView设置View.OnClickListener,是最直接的解决方案:
1//'定义点击回调'
2public interface OnItemClickListener {
3 void onItemClick(int position);
4}
5复制代码
让Adapter持有接口:
1public class MyAdapter extends RecyclerView.Adapter<MyViewHolder> {
2 //'持有接口'
3 private OnItemClickListener onItemClickListener;
4
5 //'注入接口'
6 public void setOnItemClickListener(OnItemClickListener onItemClickListener) {
7 this.onItemClickListener = onItemClickListener;
8 }
9
10 @Override
11 public MyViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
12 View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.grid_item, null);
13 return new MyViewHolder(view);
14 }
15
16 //'将接口传递给ViewHolder'
17 @Override
18 public void onBindViewHolder(MyViewHolder holder, int position) {
19 holder.bind(onItemClickListener);
20 }
21}
22复制代码
然后就能在ViewHolder中调用接口:
1public class MyViewHolder extends RecyclerView.ViewHolder {
2 public MyViewHolder(View itemView) {
3 super(itemView);
4 }
5
6 public void bind(final OnItemClickListener onItemClickListener){
7 //'为ItemView设置点击事件'
8 itemView.setOnClickListener(new View.OnClickListener() {
9 @Override
10 public void onClick(View view) {
11 if (onItemClickListener != null) {
12 onItemClickListener.onItemClick(getAdapterPosition());
13 }
14 }
15 });
16 }
17}
18复制代码
这个方案的优点是简单易懂,但缺点是点击事件的接口经过多方传递:为了给itemView设置点击事件,需要ViewHolder和Adapter的传递(因为不能直接拿到itemView)。这就使它们和点击事件接口耦合在一起,如果点击事件接口改动,这两个类需要跟着一起改。
还有一个缺点是,内存中会多出 N 个 OnClickListener 对象(N为一屏的表项个数)。虽然这也不是一个很大的开销。而且onBindViewHolder()会在列表滚动时多次触发,导致会为同一个表项无谓地多次设置点击监听器。
上面这两点还不是最致命的,在onBindViewHolder()中设置点击监听器还会导致 bug,因为“快照机制”,作为参数传入onItemClick()的索引值是在调用onBindViewHolder()那一刻生成的快照,如果数据发生增删,但因为各种原因没有及时刷新对应位置的视图(onBindViewHolder()没有被再次调用),此时发生的点击事件拿到的索引就是错的。
有没有更解耦且所有表项共用一个点击事件监听器的方案?
从 ListView 源码中找答案
突然想到ListView.setOnItemClickListener(),这不就是所有表项共享的一个监听器吗?看看它是怎么实现的:
1 /**
2 * Interface definition for a callback to be invoked when an item in this
3 * AdapterView has been clicked.
4 */
5 public interface OnItemClickListener {
6 /**
7 * Callback method to be invoked when an item in this AdapterView has
8 * been clicked.
9 * '第二个参数是被点击的表项'
10 * @param view The view within the AdapterView that was clicked
11 * '第三个参数是被点击表项的适配器位置'
12 * @param position The position of the view in the adapter.
13 */
14 void onItemClick(AdapterView<?> parent, View view, int position, long id);
15 }
16
17 /**
18 * '注入表项点击监听器'
19 */
20 public void setOnItemClickListener(@Nullable OnItemClickListener listener) {
21 mOnItemClickListener = listener;
22 }
23复制代码
这是定义在ListView中的表项点击监听器接口,接口的实例通过setOnItemClickListener()注入并保存在mOnItemClickListener中。
接口参数中有被点击的表项View和其适配器索引,好奇这两个参数是如何从点击事件生成的?沿着mOnItemClickListener向上查找调用链:
1 public boolean performItemClick(View view, int position, long id) {
2 final boolean result;
3 if (mOnItemClickListener != null) {
4 playSoundEffect(SoundEffectConstants.CLICK);
5 //'调用点击事件监听器'
6 mOnItemClickListener.onItemClick(this, view, position, id);
7 result = true;
8 } else {
9 result = false;
10 }
11
12 if (view != null) {
13 view.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
14 }
15 return result;
16 }
17复制代码
mOnItemClickListener只有在performItemClick(View view, int position, long id)中被调用,沿着调用链继续向上查找第一个参数view是如何生成的:
1 private class PerformClick extends WindowRunnnable implements Runnable {
2 //'被点击表项的索引值'
3 int mClickMotionPosition;
4
5 @Override
6 public void run() {
7 if (mDataChanged) return;
8 final ListAdapter adapter = mAdapter;
9 final int motionPosition = mClickMotionPosition;
10 if (adapter != null && mItemCount > 0 &&
11 motionPosition != INVALID_POSITION &&
12 motionPosition < adapter.getCount() && sameWindow() &&
13 adapter.isEnabled(motionPosition)) {
14 //'通过motionPosition索引值定位到被点击的View'
15 final View view = getChildAt(motionPosition - mFirstPosition);
16 if (view != null) {
17 performItemClick(view, motionPosition, adapter.getItemId(motionPosition));
18 }
19 }
20 }
21 }
22复制代码
被点击的view是通过getChildAt(index)获得的,问题就转变成对应的索引值是如何产生的?搜索所有PerformClick.mClickMotionPosition被赋值的地方:
1public abstract class AbsListView extends AdapterView<ListAdapter>{
2 /**
3 * '接收按下事件表项的位置'
4 * The position of the view that received the down motion event
5 */
6 int mMotionPosition;
7
8 private void onTouchUp(MotionEvent ev) {
9 switch (mTouchMode) {
10 case TOUCH_MODE_DOWN:
11 case TOUCH_MODE_TAP:
12 case TOUCH_MODE_DONE_WAITING:
13 //'被AbsListView.mMotionPosition赋值'
14 final int motionPosition = mMotionPosition;
15 final View child = getChildAt(motionPosition - mFirstPosition);
16 if (child != null) {
17 if (mTouchMode != TOUCH_MODE_DOWN) {
18 child.setPressed(false);
19 }
20
21 final float x = ev.getX();
22 final boolean inList = x > mListPadding.left && x < getWidth() - mListPadding.right;
23 if (inList && !child.hasExplicitFocusable()) {
24 if (mPerformClick == null) {
25 mPerformClick = new PerformClick();
26 }
27
28 final AbsListView.PerformClick performClick = mPerformClick;
29 //'被AbsListView.mMotionPosition赋值'
30 performClick.mClickMotionPosition = motionPosition;
31 ...
32 }
33}
34复制代码
PerformClick.mClickMotionPosition被赋值的地方只有一个,在AbsListView.onTouchUp()中被AbsListView.mMotionPosition赋值,看着它的注释感觉好像没有找错方向,继续搜索它是在哪里被赋值的:
1public abstract class AbsListView extends AdapterView<ListAdapter>{
2 @Override
3 public boolean onTouchEvent(MotionEvent ev) {
4 case MotionEvent.ACTION_POINTER_UP: {
5 onSecondaryPointerUp(ev);
6 final int x = mMotionX;
7 final int y = mMotionY;
8 //'获得点击表项索引的关键代码'
9 final int motionPosition = pointToPosition(x, y);
10 if (motionPosition >= 0) {
11 // Remember where the motion event started
12 final View child = getChildAt(motionPosition - mFirstPosition);
13 mMotionViewOriginalTop = child.getTop();
14 mMotionPosition = motionPosition;
15 }
16 mLastY = y;
17 break;
18 }
19}
20复制代码
最终在onTouchEvent()中找到了索引值产生的方法pointToPosition():
1 /**
2 * Maps a point to a position in the list.
3 *
4 * @param x X in local coordinate
5 * @param y Y in local coordinate
6 * @return The position of the item which contains the specified point, or
7 * {@link #INVALID_POSITION} if the point does not intersect an item.
8 */
9 public int pointToPosition(int x, int y) {
10 Rect frame = mTouchFrame;
11 if (frame == null) {
12 mTouchFrame = new Rect();
13 frame = mTouchFrame;
14 }
15
16 //'遍历列表表项'
17 final int count = getChildCount();
18 for (int i = count - 1; i >= 0; i--) {
19 final View child = getChildAt(i);
20 if (child.getVisibility() == View.VISIBLE) {
21 //'获取表项区域并存储在frame中'
22 child.getHitRect(frame);
23 //'如果点击坐标落在表项区域内则返回当前表项的索引'
24 if (frame.contains(x, y)) {
25 return mFirstPosition + i;
26 }
27 }
28 }
29 return INVALID_POSITION;
30 }
31复制代码
原来是通过遍历表项,判断点击坐标是否落在表项区域内来获取点击表项在列表中的索引。
方案二:将点击坐标转化成表项索引
只要把这个算法移植到RecyclerView就可以了!但是有一个新的问题:如何在RecyclerView中检测到单击事件? 当然可以通过综合判断ACTION_DOWN和ACTION_UP来实现,但这略复杂,Andriod 提供的GestureDetector能帮我们处理这个需求:
1public class BaseRecyclerView extends RecyclerView {
2 //'持有GestureDetector'
3 private GestureDetector gestureDetector;
4 public BaseRecyclerView(Context context) {
5 super(context);
6 init();
7 }
8
9 private void init() {
10 //'新建GestureDetector'
11 gestureDetector = new GestureDetector(getContext(), new GestureListener());
12 }
13
14 @Override
15 public boolean onTouchEvent(MotionEvent e) {
16 //'让触摸事件经由GestureDetector处理'
17 gestureDetector.onTouchEvent(e);
18 //'一定要调super.onTouchEvent()否则列表就不会滚动了'
19 return super.onTouchEvent(e);
20 }
21
22 private class GestureListener implements GestureDetector.OnGestureListener {
23 @Override
24 public boolean onDown(MotionEvent e) { return false;}
25 @Override
26 public void onShowPress(MotionEvent e) {}
27 @Override
28 public boolean onSingleTapUp(MotionEvent e) { return false; }
29 @Override
30 public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { return false; }
31 @Override
32 public void onLongPress(MotionEvent e) { }
33 @Override
34 public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { return false; }
35 }
36}
37复制代码
这样BaseRecyclerView就具有检测单击事件的能力了,下一步就是将AbsListView.pointToPosition()复制过来,重写onSingleTapUp():
1public class BaseRecyclerView extends RecyclerView {
2 ...
3 private class GestureListener implements GestureDetector.OnGestureListener {
4 private static final int INVALID_POSITION = -1;
5 private Rect mTouchFrame;
6 @Override
7 public boolean onDown(MotionEvent e) { return false; }
8 @Override
9 public void onShowPress(MotionEvent e) {}
10 @Override
11 public boolean onSingleTapUp(MotionEvent e) {
12 //'获取单击坐标'
13 int x = (int) e.getX();
14 int y = (int) e.getY();
15 //'获得单击坐标对应的表项索引'
16 int position = pointToPosition(x, y);
17 if (position != INVALID_POSITION) {
18 try {
19 //'获取索引位置的表项,通过接口传递出去'
20 View child = getChildAt(position);
21 if (onItemClickListener != null) {
22 onItemClickListener.onItemClick(child, getChildAdapterPosition(child), getAdapter());
23 }
24 } catch (Exception e1) {
25 }
26 }
27 return false;
28 }
29 @Override
30 public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { return false; }
31 @Override
32 public void onLongPress(MotionEvent e) {}
33 @Override
34 public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { return false; }
35
36 /**
37 * convert pointer to the layout position in RecyclerView
38 */
39 public int pointToPosition(int x, int y) {
40 Rect frame = mTouchFrame;
41 if (frame == null) {
42 mTouchFrame = new Rect();
43 frame = mTouchFrame;
44 }
45
46 final int count = getChildCount();
47 for (int i = count - 1; i >= 0; i--) {
48 final View child = getChildAt(i);
49 if (child.getVisibility() == View.VISIBLE) {
50 child.getHitRect(frame);
51 if (frame.contains(x, y)) {
52 return i;
53 }
54 }
55 }
56 return INVALID_POSITION;
57 }
58 }
59
60 //'将表项单击事件传递出去的接口'
61 public interface OnItemClickListener {
62 //'将表项view,表项适配器位置,适配器传递出去'
63 void onItemClick(View item, int adapterPosition, Adapter adapter);
64 }
65
66 private OnItemClickListener onItemClickListener;
67
68 public void setOnItemClickListener(OnItemClickListener onItemClickListener) {
69 this.onItemClickListener = onItemClickListener;
70 }
71}
72复制代码
大功告成!,现在就可以像这样监听RecyclerView的点击事件了
1public class MainActivity extends AppCompatActivity {
2 public static final String[] DATA = {"item1", "item2", "item3", "item4"};
3
4 @Override
5 protected void onCreate(Bundle savedInstanceState) {
6 super.onCreate(savedInstanceState);
7 setContentView(R.layout.activity_main);
8
9 MyAdapter myAdapter = new MyAdapter(Arrays.asList(DATA));
10 BaseRecyclerView rv = (BaseRecyclerView) findViewById(R.id.rv);
11 rv.setAdapter(myAdapter);
12 rv.setLayoutManager(new LinearLayoutManager(this));
13 //'为RecyclerView设置单个表项点击事件监听器'
14 rv.setOnItemClickListener(new BaseRecyclerView.OnItemClickListener() {
15 @Override
16 public void onItemClick(View item, int adapterPosition, RecyclerView.Adapter adapter) {
17 Toast.makeText(MainActivity.this, ((MyAdapter) adapter).getData().get(adapterPosition), Toast.LENGTH_SHORT).show();
18 }
19 });
20 }
21}
22复制代码
更简约的 Kotlin 版本
感谢 HitenDev 的评论,所以就用了下面这个更加简洁 Kotlin 版本:
1//'为 RecyclerView 扩展表项点击监听器'
2fun RecyclerView.setOnItemClickListener(listener: (View, Int) -> Unit) {
3 //'为 RecyclerView 子控件设置触摸监听器'
4 addOnItemTouchListener(object : RecyclerView.OnItemTouchListener {
5 //'构造手势探测器,用于解析单击事件'
6 val gestureDetector = GestureDetector(context, object : GestureDetector.OnGestureListener {
7 override fun onShowPress(e: MotionEvent?) {
8 }
9
10 override fun onSingleTapUp(e: MotionEvent?): Boolean {
11 //'当单击事件发生时,寻找单击坐标下的子控件,并回调监听器'
12 e?.let {
13 findChildViewUnder(it.x, it.y)?.let { child ->
14 listener(child, getChildAdapterPosition(child))
15 }
16 }
17 return false
18 }
19
20 override fun onDown(e: MotionEvent?): Boolean {
21 return false
22 }
23
24 override fun onFling(e1: MotionEvent?, e2: MotionEvent?, velocityX: Float, velocityY: Float): Boolean {
25 return false
26 }
27
28 override fun onScroll(e1: MotionEvent?, e2: MotionEvent?, distanceX: Float, distanceY: Float): Boolean {
29 return false
30 }
31
32 override fun onLongPress(e: MotionEvent?) {
33 }
34 })
35
36 override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) {
37
38 }
39
40 //'在拦截触摸事件时,解析触摸事件'
41 override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
42 gestureDetector.onTouchEvent(e)
43 return false
44 }
45
46 override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
47 }
48 })
49}
然后就可以像这样监听 RecyclerView 表项点击事件了:
1recyclerView.setOnItemClickListener { view, pos ->
2 // view 是表项根视图,pos是表项在adapter中的位置
3}
下一篇会继续深入,讲解如何扩展这套方案以处理 RecyclerView 表项子控件的点击事件。
https://github.com/wisdomtl/Layout_DSL
推荐阅读: