最近在解決 RecyclerView 滑動衝突問題時,遇到了使用 OnItemTouchLister
沒法解決問題的場景,本篇文章將結合實際案例,重點介紹以下幾個問題:java
RecyclerView
事件分發執行流程簡要分析OnItemTouchListener
爲何不能解決問題?在一個視頻通話界面中,放置一個發言方列表,這個列表支持橫向滑動,稱爲小窗列表, 處於背景的窗口稱之大窗,當用戶想將小窗列表中的某一個 item 切換到大窗時,可使用手指觸摸想要切換的 item, 並向上方滑動,便可將選定的小窗切換至大窗位置,並且上滑須要支持垂直向上和斜向上的方向。 算法
原始解決方案是爲 item view 設置 OnTouchListener
方法, 在其 onTouch()
方法中的 ACTION_MOVE
事件中判斷 dy (Y 座標偏移量) 是否大於某個閾值。app
遇到的問題是當在 item 斜向上滑動時,item view 收到的 ACTION_MOVE
事件的 dy 老是特別小,即便你肯定已經滑動了不少時ide
經過翻閱源碼發現,RecyclerView 內部提供了 OnItemTouchListener
, 介紹以下:源碼分析
/** * An OnItemTouchListener allows the application to intercept touch events in progress at the * view hierarchy level of the RecyclerView before those touch events are considered for * RecyclerView's own scrolling behavior. * * <p>This can be useful for applications that wish to implement various forms of gestural * manipulation of item views within the RecyclerView. OnItemTouchListeners may intercept * a touch interaction already in progress even if the RecyclerView is already handling that * gesture stream itself for the purposes of scrolling.</p> * * @see SimpleOnItemTouchListener */
public static interface OnItemTouchListener{
/** * Silently observe and/or take over touch events sent to the RecyclerView * before they are handled by either the RecyclerView itself or its child views. * * <p>The onInterceptTouchEvent methods of each attached OnItemTouchListener will be run * in the order in which each listener was added, before any other touch processing * by the RecyclerView itself or child views occurs.</p> * * @param e MotionEvent describing the touch event. All coordinates are in * the RecyclerView's coordinate system. * @return true if this OnItemTouchListener wishes to begin intercepting touch events, false * to continue with the current behavior and continue observing future events in * the gesture. */
public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e);
...
}
複製代碼
OnItemTouchListener
的做用主要有兩個:this
本文提到的問題看似能夠解決,思路就是爲 RecyclerView 添加 OnItemTouchListener
, 在其 onInterceptTouchEvent(RecyclerView rv, MotionEvent e)
調用時判斷,若是 Y 軸的偏移量大於某一閾值,代表當前用戶想觸發窗口置換操做,那麼就在 onInterceptTouchEvent()
中返回 false, 咱們指望 RecyclerView 徹底不消費事件,使事件下沉到 RecyclerView 的 item view 中,那麼 item 就能夠正常獲取到 MOVE 事件,部分代碼以下:spa
/** * 縱座標偏移量閾值 */
private final int Y_AXIS_MOVE_THRESHOLD = 15;
private int downY = 0;
@Override
public boolean onInterceptTouchEvent(MotionEvent e) {
if (e.getAction() == MotionEvent.ACTION_DOWN) {
downY = (int) e.getRawY();
} else if (e.getAction() == MotionEvent.ACTION_MOVE) {
int realtimeY = (int) e.getRawY();
int dy = Math.abs(downY - realtimeY);
if (dy > Y_AXIS_MOVE_THRESHOLD) {
return false;
}
}
return true;
}
複製代碼
但其實這樣是沒法實現需求的,由於若是按照咱們目前的實現方案,是指望在 dy 大於閾值時,RecyclerView 能夠徹底對 MOVE 事件放手,將事件下沉到 item view 中去處理,根據事件分發規則,這就須要 RecyclerView 的 onInterceptTouchEvent()
return false,而後子 View 即 item view 的 onTouchEvent()
會被調用。進而實現窗口置換,下面咱們來經過源碼分析爲何這種方案不能實現。code
@Override
public boolean onInterceptTouchEvent(MotionEvent e) {
if (mLayoutFrozen) {
// When layout is frozen, RV does not intercept the motion event.
// A child view e.g. a button may still get the click.
return false;
}
if (dispatchOnItemTouchIntercept(e)) {
cancelTouch();
return true;
}
if (mLayout == null) {
return false;
}
final boolean canScrollHorizontally = mLayout.canScrollHorizontally();
final boolean canScrollVertically = mLayout.canScrollVertically();
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain();
}
mVelocityTracker.addMovement(e);
final int action = MotionEventCompat.getActionMasked(e);
final int actionIndex = MotionEventCompat.getActionIndex(e);
switch (action) {
case MotionEvent.ACTION_DOWN:
if (mIgnoreMotionEventTillDown) {
mIgnoreMotionEventTillDown = false;
}
mScrollPointerId = e.getPointerId(0);
mInitialTouchX = mLastTouchX = (int) (e.getX() + 0.5f);
mInitialTouchY = mLastTouchY = (int) (e.getY() + 0.5f);
if (mScrollState == SCROLL_STATE_SETTLING) {
getParent().requestDisallowInterceptTouchEvent(true);
setScrollState(SCROLL_STATE_DRAGGING);
}
// Clear the nested offsets
mNestedOffsets[0] = mNestedOffsets[1] = 0;
int nestedScrollAxis = ViewCompat.SCROLL_AXIS_NONE;
if (canScrollHorizontally) {
nestedScrollAxis |= ViewCompat.SCROLL_AXIS_HORIZONTAL;
}
if (canScrollVertically) {
nestedScrollAxis |= ViewCompat.SCROLL_AXIS_VERTICAL;
}
startNestedScroll(nestedScrollAxis);
break;
case MotionEventCompat.ACTION_POINTER_DOWN:
mScrollPointerId = e.getPointerId(actionIndex);
mInitialTouchX = mLastTouchX = (int) (e.getX(actionIndex) + 0.5f);
mInitialTouchY = mLastTouchY = (int) (e.getY(actionIndex) + 0.5f);
break;
case MotionEvent.ACTION_MOVE: {
final int index = e.findPointerIndex(mScrollPointerId);
if (index < 0) {
Log.e(TAG, "Error processing scroll; pointer index for id " +
mScrollPointerId + " not found. Did any MotionEvents get skipped?");
return false;
}
final int x = (int) (e.getX(index) + 0.5f);
final int y = (int) (e.getY(index) + 0.5f);
if (mScrollState != SCROLL_STATE_DRAGGING) {
final int dx = x - mInitialTouchX;
final int dy = y - mInitialTouchY;
boolean startScroll = false;
if (canScrollHorizontally && Math.abs(dx) > mTouchSlop) {
mLastTouchX = mInitialTouchX + mTouchSlop * (dx < 0 ? -1 : 1);
startScroll = true;
}
if (canScrollVertically && Math.abs(dy) > mTouchSlop) {
mLastTouchY = mInitialTouchY + mTouchSlop * (dy < 0 ? -1 : 1);
startScroll = true;
}
if (startScroll) {
setScrollState(SCROLL_STATE_DRAGGING);
}
}
} break;
case MotionEventCompat.ACTION_POINTER_UP: {
onPointerUp(e);
} break;
case MotionEvent.ACTION_UP: {
mVelocityTracker.clear();
stopNestedScroll();
} break;
case MotionEvent.ACTION_CANCEL: {
cancelTouch();
}
}
return mScrollState == SCROLL_STATE_DRAGGING;
}
複製代碼
mLayoutFrozen
用於標識 RecyclerView 是否禁用了 layout 過程和 scroll 能力,RecyclerView 提供了對其設置的方法setLayoutFrozen(boolean frozen)
, 若是 mLayoutFrozen 被標識爲 true, RecyclreView 會發生以下變化:smoothScrollBy(int, int)
, scrollBy(int, int)
, scrollToPosition(int)
, smoothScrollToPosition(int)
OnItemTouchListener
, 則在 RecyclerView 自身滑動前,調用 dispatchOnItemTouchIntercept(MotionEvent e)
進行分發,代碼以下:private boolean dispatchOnItemTouchIntercept(MotionEvent e) {
final int action = e.getAction();
if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_DOWN) {
mActiveOnItemTouchListener = null;
}
final int listenerCount = mOnItemTouchListeners.size();
for (int i = 0; i < listenerCount; i++) {
final OnItemTouchListener listener = mOnItemTouchListeners.get(i);
if (listener.onInterceptTouchEvent(this, e) && action != MotionEvent.ACTION_CANCEL) {
mActiveOnItemTouchListener = listener;
return true;
}
}
return false;
}
複製代碼
a. mActiveOnItemTouchListener
是 OnItemTouchListener
類型的對象,若是收到了 ACTION_CANCEL 或者 ACTION_DOWN 事件,則將回調置 null, 清除上個事件序列對本次事件序列的影響,那咱們何時會收到 ACTION_CANCEL
事件呢?答案是當子 View 正在消費 ACTION_MOVE
事件時,若是父 View 在 onInterceptTouchEvent()
中 return true, 那麼子 View 會收到 ACTION_CANCEL
事件,並且這個 ACTION_CANCEL
事件沒法被父 View 攔截。orm
b. 遍歷全部註冊過的 OnItemTouchListener,若是當前事件不是 ACTION_CANCEL
,調用 OnItemTouchListener
的 onInterceptTouchEvent()
, 並 return true, 表示 RecyclerView 攔截了這個 事件序列,根據事件分發規則,事件被分發到 RecyclerView 的 onTouchEvent()
中,若是知足滑動條件,RecyclerView 會對其進行消費,使自身滑動。cdn
OnItemTouchListener
爲何不能解決問題?經過以上線索,咱們獲得了答案,爲何在 OnItemTouchListener
的方案會失敗會失敗,
listener.onInterceptTouchEvent(this, e)
return true, 則 RecyclerView 的 onInterceptTouchEvent()
會 return true, 事件轉向了 RecyclerView 的 onTouchEvent()
被消費。listener.onInterceptTouchEvent(this, e)
return false, 則 RecyclerView 仍是繼續會對這組 MOVE 事件作處理,最終事件轉向了 RecyclerView 的 onTouchEvent()
被消費。最終結局方案其實和使用 OnItemTouchListener
的 onInterceptTouchEvent
一致,不一樣的是,此次咱們新建一個 RecyclerView 的子類,重寫RecyclerView的 onInterceptTouchEvent
,具體代碼以下:
/** * 自定義 RecyclerView ,在某些場景下攔截其橫向水平移動 * Designed by 0xCAFEBOY */
public class InterceptHScrollRecyclerView extends RecyclerView {
private final String TAG = InterceptHScrollRecyclerView.class.getSimpleName();
/** * 縱座標偏移量閾值,超過這個 */
private final int Y_AXIS_MOVE_THRESHOLD = 15;
public InterceptHScrollRecyclerView(Context context) {
super(context);
}
public InterceptHScrollRecyclerView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public InterceptHScrollRecyclerView(Context context, @Nullable AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}
int downY = 0;
@Override
public boolean onInterceptTouchEvent(MotionEvent e) {
if (e.getAction() == MotionEvent.ACTION_DOWN) {
downY = (int) e.getRawY();
} else if (e.getAction() == MotionEvent.ACTION_MOVE) {
int realtimeY = (int) e.getRawY();
int dy = Math.abs(downY - realtimeY);
if (dy > Y_AXIS_MOVE_THRESHOLD) {
return false;
}
}
return super.onInterceptTouchEvent(e);
}
}
複製代碼
爲何這個方案能夠解決問題,是由於若是使用繼承的話,這段代碼至關於在 RecyclerView 執行事件分發流程以前插入了一段代碼,有點 AOP 的感受,若是 return false, 能夠完全避免 RecyclerView 接管事件,從而實現目的,注意最後這行代碼,
return super.onInterceptTouchEvent(e);
複製代碼
不能直接返回 true, 由於若是不攔截的話,具體的返回值仍是 RecyclerView 內部抉擇。