前言
收到线上用户反馈,RecyclerView 实现的 Feed 流列表中的 Banner Item 在滑动过程中偶现没有进行内容切换,而是进行了外层频道切换。嵌套的UI布局如下图所示:
问题原因定位
猜测原因是:最外层OuterViewPager拦截了Touch事件,没有将Touch事件传递给内层的BannerViewPager,从而导致外层频道切换。
想证实猜测的准确性,定位为什么OuterViewPager拦截了事件,只能通过阅读ViewPager的事件拦截源码进行分析,这是最快也是最靠谱的证实方案。
ViewPager事件拦截原理
从onInterceptTouchEvent
源码分析一下ViewPager对Touch事件的拦截机制,相关源码已经添加中文注解:
@Overridepublic 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状态进行回调。而我们只需要在滑动开始和停止的时候进行切换即可。
@Overridepublic 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:
@Overrideprotected void onAttachedToWindow() { super.onAttachedToWindow(); mFirstLayout = true;}
接下来,我在BannerViewPager的onAttachedToWindow方法中加了log,发现RecyclerView将BannerViewPager划出屏幕时,会调用BannerViewPager的onDetachedFromWindow方法,再将BannerViewPager划入屏幕时,会调用BannerViewPager的onAttachedToWindow方法。
并且,恰好BannerViewPager的onDetachedFromWindow中会停止掉滑动动画:
@Overrideprotected 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即可。
@Overrideprotected 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(); }}