讀源碼長知識 —— 更好的RecyclerView點擊監聽器

RecyclerView沒有提供表項點擊事件監聽器,只能本身處理。git

方案一:層層傳遞點擊監聽器

最容易想到的方案是給每一個表項的itemView設置View.OnClickListener,代碼以下:github

//'定義點擊接口'
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中調用接口:bash

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)。這就使它們和點擊事件接口耦合在一塊兒,若是點擊事件接口改動,這兩個類須要跟着一塊兒改。dom

還有一個缺點是,內存中會多出 N 個 OnClickListener 對象(N爲一屏的表項個數)。雖然這也不是一個很大的開銷。ide

有沒有更解耦且全部表項共用一個點擊事件監聽器的方案?ui

從 ListView 源碼中找答案

忽然想到ListView.setOnItemClickListener(),這不就是全部表項共享的一個監聽器嗎?看看它是怎麼實現的:this

/**
     * 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中。spa

接口參數中有被點擊的表項View和其適配器索引,好奇這兩個參數是如何從點擊事件生成的?沿着mOnItemClickListener向上查找調用鏈:code

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

相關文章
相關標籤/搜索