RecyclerView沒有提供表項點擊事件監(jiān)聽器,只能自己處理。
方案一:層層傳遞點擊監(jiān)聽器
最容易想到的方案是給每個表項的itemView設(shè)置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中調(diào)用接口:
public class MyViewHolder extends RecyclerView.ViewHolder {
public MyViewHolder(View itemView) {
super(itemView);
}
public void bind(final OnItemClickListener onItemClickListener){
//'為ItemView設(shè)置點擊事件'
itemView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
if (onItemClickListener != null) {
onItemClickListener.onItemClick(getAdapterPosition());
}
}
});
}
}
這個方案的優(yōu)點是簡單易懂,但缺點是點擊事件的接口經(jīng)過多方傳遞:為了給itemView設(shè)置點擊事件,需要ViewHolder和Adapter的傳遞(因為不能直接拿到itemView)。這就使它們和點擊事件接口耦合在一起,如果點擊事件接口改動,這兩個類需要跟著一起改。
還有一個缺點是,內(nèi)存中會多出 N 個 OnClickListener 對象(N為一屏的表項個數(shù))。雖然這也不是一個很大的開銷。
有沒有更解耦且所有表項共用一個點擊事件監(jiān)聽器的方案?
從 ListView 源碼中找答案
突然想到ListView.setOnItemClickListener(),這不就是所有表項共享的一個監(jiān)聽器嗎?看看它是怎么實現(xiàn)的:
/**
* 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.
* '第二個參數(shù)是被點擊的表項'
* @param view The view within the AdapterView that was clicked
* '第三個參數(shù)是被點擊表項的適配器位置'
* @param position The position of the view in the adapter.
*/
void onItemClick(AdapterView<?> parent, View view, int position, long id);
}
/**
* '注入表項點擊監(jiān)聽器'
*/
public void setOnItemClickListener(@Nullable OnItemClickListener listener) {
mOnItemClickListener = listener;
}
這是定義在ListView中的表項點擊監(jiān)聽器接口,接口的實例通過setOnItemClickListener()注入并保存在mOnItemClickListener中。
接口參數(shù)中有被點擊的表項View和其適配器索引,好奇這兩個參數(shù)是如何從點擊事件生成的?沿著mOnItemClickListener向上查找調(diào)用鏈:
public boolean performItemClick(View view, int position, long id) {
final boolean result;
if (mOnItemClickListener != null) {
playSoundEffect(SoundEffectConstants.CLICK);
//'調(diào)用點擊事件監(jiān)聽器'
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)中被調(diào)用,沿著調(diào)用鏈繼續(xù)向上查找第一個參數(shù)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)獲得的,問題就轉(zhuǎn)變成對應(yīng)的索引值是如何產(chǎn)生的?搜索所有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賦值,看著它的注釋感覺好像沒有找錯方向,繼續(xù)搜索它是在哪里被賦值的:
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;
//'獲得點擊表項索引的關(guān)鍵代碼'
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()中找到了索引值產(chǎn)生的方法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) {
//'獲取表項區(qū)域并存儲在frame中'
child.getHitRect(frame);
//'如果點擊坐標(biāo)落在表項區(qū)域內(nèi)則返回當(dāng)前表項的索引'
if (frame.contains(x, y)) {
return mFirstPosition + i;
}
}
}
return INVALID_POSITION;
}
原來是通過遍歷表項,判斷點擊坐標(biāo)是否落在表項區(qū)域內(nèi)來獲取點擊表項在列表中的索引。
方案二:將點擊坐標(biāo)轉(zhuǎn)化成表項索引
只要把這個算法移植到RecyclerView就可以了!但是有一個新的問題:如何在RecyclerView中檢測到單擊事件? 當(dāng)然可以通過綜合判斷ACTION_DOWN和ACTION_UP來實現(xiàn),但這略復(fù)雜,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) {
//'讓觸摸事件經(jīng)由GestureDetector處理'
gestureDetector.onTouchEvent(e);
//'一定要調(diào)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()復(fù)制過來,重寫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) {
//'獲取單擊坐標(biāo)'
int x = (int) e.getX();
int y = (int) e.getY();
//'獲得單擊坐標(biāo)對應(yīng)的表項索引'
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;
}
}
大功告成!,現(xiàn)在就可以像這樣監(jiān)聽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設(shè)置單個表項點擊事件監(jiān)聽器'
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();
}
});
}
}