前言
收到线上用户反馈,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();
}
}