区伯肺癌病重:一位逐渐被遗忘的广州公民

前外交部副部长傅莹:一旦中美闹翻,有没有国家会站在中国一边

Weixin Official Accounts Platform

去泰国看了一场“成人秀”,画面尴尬到让人窒息.....

多年来,中国有个省,几乎每一个村庄都在偷偷“虎门销烟”

生成图片,分享到微信朋友圈

自由微信安卓APP发布,立即下载! | 提交文章网址
查看原文

读源码长知识 | 更好的 RecyclerView 表项点击监听器

徐公 2022-10-21

作者:唐子玄
链接:https://juejin.cn/post/6844903862361391117

RecyclerView没有提供表项点击事件监听器,只能自己处理。

这是读源码长知识系列的第一篇,该系列的特点是将源码中的设计思想运用到真实项目之中,系列文章目录如下:

  1. 读源码长知识 | 更好的RecyclerView点击监听器

  2. Android自定义控件 | 源码里有宝藏之自动换行控件

  3. Android自定义控件 | 小红点的三种实现(下)

  4. 读源码长知识 | 动态扩展类并绑定生命周期的新方式

  5. 读源码长知识 | Android卡顿真的是因为”掉帧“?

方案一:层层传递点击监听回调

RecyclerView表项被ViewHolder持有,而ViewHolderRecyclerView.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设置点击事件,需要ViewHolderAdapter的传递(因为不能直接拿到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_DOWNACTION_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

推荐阅读:

Android IO监控 | 性能监控系列

RxJava 堆栈异常信息显示不全,怎么搞

Android 音视频开发【特效篇】【一】抖音传送带特效

快手线上 OOM 监控方案 -  KOOM 分析

Flutter自定义之旋转木马 - 带你回到童年时光

爱奇艺  Xcrash 是怎么捕获 crash 的

真牛系列 - 一步步解决 App 隐私违规问题




文章有问题?点此查看未经处理的缓存