美文网首页Android开发精选
读源码长知识 —— 更好的RecyclerView点击监听器

读源码长知识 —— 更好的RecyclerView点击监听器

作者: 唐子玄 | 来源:发表于2019-06-13 02:16 被阅读436次

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

    方案一:层层传递点击监听器

    最容易想到的方案是给每个表项的itemView设置View.OnClickListener,代码如下:

    //'定义点击接口'
    public interface OnItemClickListener {
        void onItemClick(int position);
    }
    

    Adapter持有接口:

    public class MyAdapter extends RecyclerView.Adapter<MyViewHolder> {
        //'持有接口'
        private OnItemClickListener onItemClickListener;
        
        //'注入接口'
        public void setOnItemClickListener(OnItemClickListener onItemClickListener) {
            this.onItemClickListener = onItemClickListener;
        }
    
        @Override
        public MyViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
            View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.grid_item, null);
            return new MyViewHolder(view);
        }
    
        //'将接口传递给ViewHolder'
        @Override
        public void onBindViewHolder(MyViewHolder holder, int position) {
            holder.bind(onItemClickListener);
        }
    }
    

    然后就能在ViewHolder中调用接口:

    public class MyViewHolder extends RecyclerView.ViewHolder {
        public MyViewHolder(View itemView) {
            super(itemView);
        }
    
        public void bind(final OnItemClickListener onItemClickListener){
            //'为ItemView设置点击事件'
            itemView.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View view) {
                    if (onItemClickListener != null) {
                        onItemClickListener.onItemClick(getAdapterPosition());
                    }
                }
            });
        }
    }
    

    这个方案的优点是简单易懂,但缺点是点击事件的接口经过多方传递:为了给itemView设置点击事件,需要ViewHolderAdapter的传递(因为不能直接拿到itemView)。这就使它们和点击事件接口耦合在一起,如果点击事件接口改动,这两个类需要跟着一起改。

    还有一个缺点是,内存中会多出 N 个 OnClickListener 对象(N为一屏的表项个数)。虽然这也不是一个很大的开销。

    有没有更解耦且所有表项共用一个点击事件监听器的方案?

    从 ListView 源码中找答案

    突然想到ListView.setOnItemClickListener(),这不就是所有表项共享的一个监听器吗?看看它是怎么实现的:

        /**
         * Interface definition for a callback to be invoked when an item in this
         * AdapterView has been clicked.
         */
        public interface OnItemClickListener {
            /**
             * Callback method to be invoked when an item in this AdapterView has
             * been clicked.
             * '第二个参数是被点击的表项'
             * @param view The view within the AdapterView that was clicked
             * '第三个参数是被点击表项的适配器位置'
             * @param position The position of the view in the adapter.
             */
            void onItemClick(AdapterView<?> parent, View view, int position, long id);
        }
        
        /**
         * '注入表项点击监听器'
         */
        public void setOnItemClickListener(@Nullable OnItemClickListener listener) {
            mOnItemClickListener = listener;
        }
    

    这是定义在ListView中的表项点击监听器接口,接口的实例通过setOnItemClickListener()注入并保存在mOnItemClickListener中。

    接口参数中有被点击的表项View和其适配器索引,好奇这两个参数是如何从点击事件生成的?沿着mOnItemClickListener向上查找调用链:

        public boolean performItemClick(View view, int position, long id) {
            final boolean result;
            if (mOnItemClickListener != null) {
                playSoundEffect(SoundEffectConstants.CLICK);
                //'调用点击事件监听器'
                mOnItemClickListener.onItemClick(this, view, position, id);
                result = true;
            } else {
                result = false;
            }
    
            if (view != null) {
                view.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
            }
            return result;
        }
    

    mOnItemClickListener只有在performItemClick(View view, int position, long id)中被调用,沿着调用链继续向上查找第一个参数view是如何生成的:

        private class PerformClick extends WindowRunnnable implements Runnable {
            //'被点击表项的索引值'
            int mClickMotionPosition;
    
            @Override
            public void run() {
                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)) {
                    //'通过motionPosition索引值定位到被点击的View'
                    final View view = getChildAt(motionPosition - mFirstPosition);
                    if (view != null) {
                        performItemClick(view, motionPosition, adapter.getItemId(motionPosition));
                    }
                }
            }
        }
    

    被点击的view是通过getChildAt(index)获得的,问题就转变成对应的索引值是如何产生的?搜索所有PerformClick.mClickMotionPosition被赋值的地方:

    public abstract class AbsListView extends AdapterView<ListAdapter>{
        /**
         * '接收按下事件表项的位置'
         * The position of the view that received the down motion event
         */
        int mMotionPosition;
        
        private void onTouchUp(MotionEvent ev) {
            switch (mTouchMode) {
            case TOUCH_MODE_DOWN:
            case TOUCH_MODE_TAP:
            case TOUCH_MODE_DONE_WAITING:
                //'被AbsListView.mMotionPosition赋值'
                final int motionPosition = mMotionPosition;
                final View child = getChildAt(motionPosition - mFirstPosition);
                if (child != null) {
                    if (mTouchMode != TOUCH_MODE_DOWN) {
                        child.setPressed(false);
                    }
    
                    final float x = ev.getX();
                    final boolean inList = x > mListPadding.left && x < getWidth() - mListPadding.right;
                    if (inList && !child.hasExplicitFocusable()) {
                        if (mPerformClick == null) {
                            mPerformClick = new PerformClick();
                        }
    
                        final AbsListView.PerformClick performClick = mPerformClick;
                        //'被AbsListView.mMotionPosition赋值'
                        performClick.mClickMotionPosition = motionPosition;
                        ...
        }
    }
    

    PerformClick.mClickMotionPosition被赋值的地方只有一个,在AbsListView.onTouchUp()中被AbsListView.mMotionPosition赋值,看着它的注释感觉好像没有找错方向,继续搜索它是在哪里被赋值的:

    public abstract class AbsListView extends AdapterView<ListAdapter>{
        @Override
        public boolean onTouchEvent(MotionEvent ev) {
                case MotionEvent.ACTION_POINTER_UP: {
                    onSecondaryPointerUp(ev);
                    final int x = mMotionX;
                    final int y = mMotionY;
                    //'获得点击表项索引的关键代码'
                    final int motionPosition = pointToPosition(x, y);
                    if (motionPosition >= 0) {
                        // Remember where the motion event started
                        final View child = getChildAt(motionPosition - mFirstPosition);
                        mMotionViewOriginalTop = child.getTop();
                        mMotionPosition = motionPosition;
                    }
                    mLastY = y;
                    break;
                }
    }
    

    最终在onTouchEvent()中找到了索引值产生的方法pointToPosition()

        /**
         * Maps a point to a position in the list.
         *
         * @param x X in local coordinate
         * @param y Y in local coordinate
         * @return The position of the item which contains the specified point, or
         *         {@link #INVALID_POSITION} if the point does not intersect an item.
         */
        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) {
                    //'获取表项区域并存储在frame中'
                    child.getHitRect(frame);
                    //'如果点击坐标落在表项区域内则返回当前表项的索引'
                    if (frame.contains(x, y)) {
                        return mFirstPosition + i;
                    }
                }
            }
            return INVALID_POSITION;
        }
    

    原来是通过遍历表项,判断点击坐标是否落在表项区域内来获取点击表项在列表中的索引。

    方案二:将点击坐标转化成表项索引

    只要把这个算法移植到RecyclerView就可以了!但是有一个新的问题:如何在RecyclerView中检测到单击事件? 当然可以通过综合判断ACTION_DOWNACTION_UP来实现,但这略复杂,Andriod 提供的GestureDetector能帮我们处理这个需求:

    public class BaseRecyclerView extends RecyclerView {
        //'持有GestureDetector'
        private GestureDetector gestureDetector;
        public BaseRecyclerView(Context context) {
            super(context);
            init();
        }
    
        private void init() {
            //'新建GestureDetector'
            gestureDetector = new GestureDetector(getContext(), new GestureListener());
        }
    
        @Override
        public boolean onTouchEvent(MotionEvent e) {
            //'让触摸事件经由GestureDetector处理'
            gestureDetector.onTouchEvent(e);
            //'一定要调super.onTouchEvent()否则列表就不会滚动了'
            return super.onTouchEvent(e);
        }
    
        private class GestureListener implements GestureDetector.OnGestureListener {
            @Override
            public boolean onDown(MotionEvent e) { return false;}
            @Override
            public void onShowPress(MotionEvent e) {}
            @Override
            public boolean onSingleTapUp(MotionEvent e) { return false; }
            @Override
            public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { return false; }
            @Override
            public void onLongPress(MotionEvent e) { }
            @Override
            public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { return false; }
        }
    }
    

    这样BaseRecyclerView就具有检测单击事件的能力了,下一步就是将AbsListView.pointToPosition()复制过来,重写onSingleTapUp()

    public class BaseRecyclerView extends RecyclerView {
        ...
        private class GestureListener implements GestureDetector.OnGestureListener {
            private static final int INVALID_POSITION = -1;
            private Rect mTouchFrame;
            @Override
            public boolean onDown(MotionEvent e) { return false; }
            @Override
            public void onShowPress(MotionEvent e) {}
            @Override
            public boolean onSingleTapUp(MotionEvent e) {
                //'获取单击坐标'
                int x = (int) e.getX();
                int y = (int) e.getY();
                //'获得单击坐标对应的表项索引'
                int position = pointToPosition(x, y);
                if (position != INVALID_POSITION) {
                    try {
                        //'获取索引位置的表项,通过接口传递出去'
                        View child = getChildAt(position);
                        if (onItemClickListener != null) {
                            onItemClickListener.onItemClick(child, getChildAdapterPosition(child), getAdapter());
                        }
                    } catch (Exception e1) {
                    }
                }
                return false;
            }
            @Override
            public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { return false; }
            @Override
            public void onLongPress(MotionEvent e) {}
            @Override
            public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { return false; }
    
            /**
             * convert pointer to the layout position in RecyclerView
             */
            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)) {
                            return i;
                        }
                    }
                }
                return INVALID_POSITION;
            }
        }
        
        //'将表项单击事件传递出去的接口'
        public interface OnItemClickListener {
            //'将表项view,表项适配器位置,适配器传递出去'
            void onItemClick(View item, int adapterPosition, Adapter adapter);
        }
        
        private OnItemClickListener onItemClickListener;
        
        public void setOnItemClickListener(OnItemClickListener onItemClickListener) {
            this.onItemClickListener = onItemClickListener;
        }
    }
    

    大功告成!,现在就可以像这样监听RecyclerView的点击事件了

    public class MainActivity extends AppCompatActivity {
        public static final String[] DATA = {"item1", "item2", "item3", "item4"};
    
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_main);
    
            MyAdapter myAdapter = new MyAdapter(Arrays.asList(DATA));
            BaseRecyclerView rv = (BaseRecyclerView) findViewById(R.id.rv);
            rv.setAdapter(myAdapter);
            rv.setLayoutManager(new LinearLayoutManager(this));
            //'为RecyclerView设置单个表项点击事件监听器'
            rv.setOnItemClickListener(new BaseRecyclerView.OnItemClickListener() {
                @Override
                public void onItemClick(View item, int adapterPosition, RecyclerView.Adapter adapter) {
                    Toast.makeText(MainActivity.this, ((MyAdapter) adapter).getData().get(adapterPosition), Toast.LENGTH_SHORT).show();
                }
            });
        }
    }
    

    talk is cheap, show me the code

    相关文章

      网友评论

        本文标题:读源码长知识 —— 更好的RecyclerView点击监听器

        本文链接:https://www.haomeiwen.com/subject/thmdfctx.html