前言
最近在解決 RecyclerView 滑動衝突問題時,遇到了使用 OnItemTouchLister
無法解決問題的場景,本篇文章將結合實際案例,重點介紹如下幾個問題:
RecyclerView
事件分發執行流程簡要分析- 新增
OnItemTouchListener
為什麼不能解決問題? - 該場景下最終的解決方案
業務需求
在一個視訊通話介面中,放置一個發言方列表,這個列表支援橫向滑動,稱為小窗列表, 處於背景的視窗稱之大窗,當使用者想將小窗列表中的某一個 item 切換到大窗時,可以使用手指觸控想要切換的 item, 並向上方滑動,即可將選定的小窗切換至大窗位置,而且上滑需要支援垂直向上和斜向上的方向。
原始解決方案
解決方案
原始解決方案是為 item view 設定 OnTouchListener
方法, 在其 onTouch()
方法中的 ACTION_MOVE
事件中判斷 dy (Y 座標偏移量) 是否大於某個閾值。
遇到的問題
遇到的問題是當在 item 斜向上滑動時,item view 收到的 ACTION_MOVE
事件的 dy 總是特別小,即使你確定已經滑動了很多時
問題定位 & 懷疑
- 該問題定位為 在橫向滑動時,RecyclerView 與 item 發生了巢狀滑動衝突
- 懷疑是 RecyclerView 消費了部分滑動事件,導致 item view 收到的滑動距離特別小。
嘗試新的解決方案
通過翻閱原始碼發現,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
的作用主要有兩個:
- 在 RecyclerView 對事件消費之前,給予開發者自定義事件分發演算法的權利。
- 當 RecyclerView 已經在對事件消費過程中時,可以通過本類對 RecylerView 正在處理的事件序列進行攔截。
本文提到的問題看似可以解決,思路就是為 RecyclerView 新增 OnItemTouchListener
, 在其 onInterceptTouchEvent(RecyclerView rv, MotionEvent e)
呼叫時判斷,如果 Y 軸的偏移量大於某一閾值,表明當前使用者想觸發視窗置換操作,那麼就在 onInterceptTouchEvent()
中返回 false, 我們期望 RecyclerView 完全不消費事件,使事件下沉到 RecyclerView 的 item view 中,那麼 item 就可以正常獲取到 MOVE 事件,部分程式碼如下:
/**
* 縱座標偏移量閾值
*/
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()
會被呼叫。進而實現視窗置換,下面我們來通過原始碼分析為什麼這種方案不能實現。
RecyclerView 事件分發程式碼分析
@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 會發生如下變化:
- 所有對 RecyclerView 的 Layout 請求會被推遲執行,直到 mLayoutFrozen 再度被設定 false
- 子 View 也不會被重新整理
- RecyclerView 也不會響應滑動的請求,即不會響應
smoothScrollBy(int, int)
,scrollBy(int, int)
,scrollToPosition(int)
,smoothScrollToPosition(int)
- 不響應 Touch Event 和 GenericMotionEvents
- 如果 RecyclerView 設定了
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 攔截。
b. 遍歷所有註冊過的 OnItemTouchListener,如果當前事件不是 ACTION_CANCEL
,呼叫 OnItemTouchListener
的 onInterceptTouchEvent()
, 並 return true, 表示 RecyclerView 攔截了這個 事件序列,根據事件分發規則,事件被分發到 RecyclerView 的 onTouchEvent()
中,如果滿足滑動條件,RecyclerView 會對其進行消費,使自身滑動。
新增 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 內部抉擇。