您的点赞和关注是我坚持写作的最大动力,本人正在寻找测试开发的工作机会,欢迎微信联系: gyx764884989
前言
最近在解决 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 内部抉择。