前言
收到线上用户反馈,RecyclerView 实现的 Feed 流列表中的 Banner Item 在滑动过程中偶现没有进行内容切换,而是进行了外层频道切换。嵌套的 UI 布局如下图所示:
问题原因定位
猜测原因是:最外层 OuterViewPager 拦截了 Touch 事件,没有将 Touch 事件传递给内层的 BannerViewPager,从而导致外层频道切换。
想证实猜测的准确性,定位为什么 OuterViewPager 拦截了事件,只能通过阅读 ViewPager 的事件拦截源码进行分析,这是最快也是最靠谱的证实方案。
ViewPager 事件拦截原理
从 onInterceptTouchEvent
源码分析一下 ViewPager 对 Touch 事件的拦截机制,相关源码已经添加中文注解:
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {final int action = ev.getAction() & MotionEvent.ACTION_MASK;
if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) {
// cancel 和 up 事件代表触摸事件结束,需要重置触摸变量
resetTouch();
return false;
}
if (action != MotionEvent.ACTION_DOWN) {if (mIsBeingDragged) {
// 如果 ViewPager 已经响应拖拽事件,则直接拦截后续事件
return true;
}
if (mIsUnableToDrag) {
// 如果 ViewPager 不能响应拖拽事件,则不拦截后续事件
return false;
}
}
switch (action) {
case MotionEvent.ACTION_MOVE: {
// 多指触摸处理,值得学习阅读
final int activePointerId = mActivePointerId;
if (activePointerId == INVALID_POINTER) {break;}
final int pointerIndex = ev.findPointerIndex(activePointerId);
final float x = ev.getX(pointerIndex);
final float dx = x - mLastMotionX;
final float xDiff = Math.abs(dx);
final float y = ev.getY(pointerIndex);
final float yDiff = Math.abs(y - mInitialMotionY);
// 这里是关键,判断 OuterViewPager 是否需要将 touch 事件传递给内层 BannerViewPager
if (dx != 0 && !isGutterDrag(mLastMotionX, dx)
&& canScroll(this, false, (int) dx, (int) x, (int) y)) {
// 如果内层 Child 可以滑动,则 OuterViewPager 不拦截事件,将事件向下传递
mLastMotionX = x;
mLastMotionY = y;
mIsUnableToDrag = true;
return false;
}
// OuterViewPager 开始接管 Touch 事件处理.
// X 轴横向偏移量大于最小滑动距离,并且滑动角度小于 45 度
if (xDiff > mTouchSlop && xDiff * 0.5f > yDiff) {
// 设置拦截拖拽标记位
mIsBeingDragged = true;
// 通知父 View 不要拦截事件
requestParentDisallowInterceptTouchEvent(true);
// 设置滑动状态为开始拖拽
setScrollState(SCROLL_STATE_DRAGGING);
// 设置滑动开始的坐标
mLastMotionX = dx > 0
? mInitialMotionX + mTouchSlop : mInitialMotionX - mTouchSlop;
mLastMotionY = y;
setScrollingCacheEnabled(true);
} else if (yDiff > mTouchSlop) {
// 竖向滑动不拦截后续 TOUCH 事件
mIsUnableToDrag = true;
}
if (mIsBeingDragged) {
// 执行滑动
if (performDrag(x)) {ViewCompat.postInvalidateOnAnimation(this);
}
}
break;
}
case MotionEvent.ACTION_DOWN: {
// 多指处理的逻辑,值得学习,标准写法
mLastMotionX = mInitialMotionX = ev.getX();
mLastMotionY = mInitialMotionY = ev.getY();
mActivePointerId = ev.getPointerId(0);
mIsUnableToDrag = false;
mIsScrollStarted = true;
mScroller.computeScrollOffset();
if (mScrollState == SCROLL_STATE_SETTLING
&& Math.abs(mScroller.getFinalX() - mScroller.getCurrX()) > mCloseEnough) {
// down 事件到来,需要终止上次的滑动
mScroller.abortAnimation();
mPopulatePending = false;
populate();
// 因为上次滑动没有终止,因此需要拦截后续 TOUCH 事件,开始新的滑动
mIsBeingDragged = true;
requestParentDisallowInterceptTouchEvent(true);
setScrollState(SCROLL_STATE_DRAGGING);
} else {completeScroll(false);
mIsBeingDragged = false;
}
break;
}
case MotionEvent.ACTION_POINTER_UP:
onSecondaryPointerUp(ev);
break;
}
// 速度追踪
if (mVelocityTracker == null) {mVelocityTracker = VelocityTracker.obtain();
}
mVelocityTracker.addMovement(ev);
return mIsBeingDragged;
}
通过 onInterceptTouchEvent 源码分析,可以看出:
if (dx != 0 && !isGutterDrag(mLastMotionX, dx) && canScroll(this, false, (int) dx, (int) x, (int) y)){}
是外层 OuterViewPager 是否拦截 Touch 事件的关键块。
isGutterDrag
private boolean isGutterDrag(float x, float dx) {return (x < mGutterSize && dx > 0) || (x > getWidth() - mGutterSize && dx < 0);
}
代码块作用是判断滑动起始位置:
- dx > 0 代表是从左向右滑动,如果 x < mGutterSize,说明是从左侧边缘滑动。
- dx < 0 代表是从右向左滑动,如果 x > getWidth() – mGutterSize,说明是从右侧边缘滑动。
结合之前的 onInterceptTouchEvent 中判断条件进行分析:如果触摸位置位于边缘,则 OuterViewPager 直接拦截事件。默认的 mGuuterSize 是 16dp.
canScroll
protected boolean canScroll(View v, boolean checkV, int dx, int x, int y) {if (v instanceof ViewGroup) {final ViewGroup group = (ViewGroup) v;
final int scrollX = v.getScrollX();
final int scrollY = v.getScrollY();
final int count = group.getChildCount();
// Count backwards - let topmost views consume scroll distance first.
for (int i = count - 1; i >= 0; i--) {final View child = group.getChildAt(i);
// 判断 touch 的点位是否处于 child 的布局范围之内
if (x + scrollX >= child.getLeft() && x + scrollX < child.getRight()
&& y + scrollY >= child.getTop() && y + scrollY < child.getBottom()
&& canScroll(child, true, dx, x + scrollX - child.getLeft(),
y + scrollY - child.getTop())) {return true;}
}
}
// 递归重点,检测 child 是否具备横向滑动能力
return checkV && v.canScrollHorizontally(-dx);
}
这个代码块用于检测 OuterViewpager 中的 Child View 是否能够横向滑动。BannerViewPager 不能横向滑动场景只有两个:
- 如果是从左向右滑动,并且 Touch 触摸位于第一个 item 上,是不能滑动的。
- 如果是从右向左滑动,并且 Touch 触摸位于最后一个 item 上,那也是不能滑动的。
小结
从对 onInterceptTouchEvent 源码的分析,外层 OuterViewPager 如果拦截的事件,只可能是两个原因:
- 用户从边缘滑动。
- BannerViewPager 触发了不能横向滑动场景。
用户从边缘滑动
需要确定用户是否是从边缘滑动导致的这个问题,如果是这样,那需要优化边缘距离判断。
线下咨询出现问题的用户,得出不是从边缘滑动的触发场景,因此排除 isGutterDrag 导致的问题。
BannerViewPager 触发了不能横向滑动场景:
排除了边缘滑动,那一定是 BannerViewPager 触发了不能横向滑动场景。
再考虑 BannerViewPager 不可滑动触发场景前,先介绍一下无限滑动 BannerViewPager 的实现机制。
如上图所示,在正常的 3 个元素的第 0 个位置(即原 Item0)前插入一个 Item2(暂且叫作假 Item2),在原始的第 2 个位置(即原 Item2)后插入一个假 Item0。
当假 Item0 被完整的显示出来之后,立马切换到原 Item0 的位置,也就到达了看起来是无限循环的效果;原 item 向右滑动的情况是一样的实现原理。
假 Item 切换真 Item 是通过 OnPageChangeListener.onPageScrollStateChanged 方法回调实现的。这个方法会在 ViewPager 滑动开始、停止、fly 状态进行回调。而我们只需要在滑动开始和停止的时候进行切换即可。
@Override
public void onPageScrollStateChanged(int state) {if (mOnPageChangeListener != null) {mOnPageChangeListener.onPageScrollStateChanged(state);
}
currentItem = viewPager.getCurrentItem();
switch (state) {
case 0: // 无操作
if (currentItem == 0) {viewPager.setCurrentItem(count, false);
} else if (currentItem == count + 1) {viewPager.setCurrentItem(1, false);
}
break;
case 1: // 开始滑动
if (currentItem == 0) {viewPager.setCurrentItem(count, false);
} else if (currentItem == count + 1) {viewPager.setCurrentItem(1, false);
}
break;
case 2: // 结束滑动
break;
}
}
讲道理 BannerViewPager 内容切换时只要 onPageScrollStateChanged 正常回调,是不会出现外层 OuterViewPager 切换 tab 行为的。因此需要确认一下 onPageScrollStateChanged 的回调时机。
setCurrentItem
BannerViewPager 切换内容并且回调 onPageScrollStateChanged,都是通过 setCurrentItem 方法实现的。我们跟踪一下 setCurrentItem 源码:
public void setCurrentItem(int item) {
mPopulatePending = false;
setCurrentItemInternal(item, !mFirstLayout, false);
}
void setCurrentItemInternal(int item, boolean smoothScroll, boolean always, int velocity) {if (mAdapter == null || mAdapter.getCount() <= 0) {setScrollingCacheEnabled(false);
return;
}
if (!always && mCurItem == item && mItems.size() != 0) {setScrollingCacheEnabled(false);
return;
}
if (item < 0) {item = 0;} else if (item >= mAdapter.getCount()) {item = mAdapter.getCount() - 1;
}
final int pageLimit = mOffscreenPageLimit;
if (item > (mCurItem + pageLimit) || item < (mCurItem - pageLimit)) {for (int i = 0; i < mItems.size(); i++) {mItems.get(i).scrolling = true;
}
}
final boolean dispatchSelected = mCurItem != item;
if (mFirstLayout) {
// 如果是 FirstLayout,则是通过 requestLayout 方式显示当前 item
mCurItem = item;
if (dispatchSelected) {dispatchOnPageSelected(item);
}
requestLayout();} else {
// 通过 populate 显示当前 item,并且 scrollToItem 会回调 onPageScrollStateChanged 回调
populate(item);
scrollToItem(item, smoothScroll, velocity, dispatchSelected);
}
}
源码分析到这里,可以确认,一定是 mFirstLayout 为 true,导致了 onPageScrollStateChanged 没有回调。
接下来,分析 mFirstLayout 赋值的地方。通过源码分析,除了类初始化将 mFirstLayout 赋值为 true 之外,只有 onAttachedToWindow 一处地方将 mFirstLayout 赋值为 true:
@Override
protected void onAttachedToWindow() {super.onAttachedToWindow();
mFirstLayout = true;
}
接下来,我在 BannerViewPager 的 onAttachedToWindow 方法中加了 log,发现 RecyclerView 将 BannerViewPager 划出屏幕时,会调用 BannerViewPager 的 onDetachedFromWindow 方法,再将 BannerViewPager 划入屏幕时,会调用 BannerViewPager 的 onAttachedToWindow 方法。
并且,恰好 BannerViewPager 的 onDetachedFromWindow 中会停止掉滑动动画:
@Override
protected void onDetachedFromWindow() {removeCallbacks(mEndScrollRunnable);
// 停止滑动动画
if ((mScroller != null) && !mScroller.isFinished()) {mScroller.abortAnimation();
}
super.onDetachedFromWindow();}
真相大白了,看懂的同学此处应该有掌声。
问题原因总结:
- Banner 是可以自动播放的,当 Banner 从原 Item2 切换到假 Item0 的过程中,用户突然上滑将 BannerViewPager 移除屏幕,这时 onDetachedFromWindow 回调将动画停止,onPageScrollStateChanged 无法得到调用。
- 当用户再次将 BannerBannerViewPager 移入屏幕时,onAttachedToWindow 回调将 mFirstLayout 变量设置为 true。自动播放再次触发,通过 setCurrentItem 将展示内容设置为假 item0。但是 mFirstLayout 为 true,因此通过了 requestLayout 机制进行实现,没有回调 onPageScrollStateChanged 方法,因此假 Item0 位置无法切换成原 Item0,此时内部的 BannerViewPager 是无法滑动状态。
- 根据之前外部 ViewPager 对事件拦截机制的分析,外部 ViewPager 判断 BannerViewPager 无法滑动,因此拦截了事件,进行了 tab 切换。
按照上述步骤,调整一下 BannerViewPager 的滑动速度,很容易复现这个问题。问题原因定位成功。
问题修复
定位原因之后,修复就变得容易很多。只需要在 onAttachedToWindow 方法里,通过反射修改 mFirstLayout 的值为 false 即可。
@Override
protected void onAttachedToWindow() {super.onAttachedToWindow();
try {Field mFirstLayout = ViewPager.class.getDeclaredField("mFirstLayout");
mFirstLayout.setAccessible(true);
mFirstLayout.set(this, false);
getAdapter().notifyDataSetChanged();
setCurrentItem(getCurrentItem());
} catch (Exception e) {e.printStackTrace();
}
}