乐趣区

RecyclerView内嵌ViewPager无限滑动Banner的爬坑之旅

前言

收到线上用户反馈,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 如果拦截的事件,只可能是两个原因:

  1. 用户从边缘滑动。
  2. 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();}

真相大白了,看懂的同学此处应该有掌声。

问题原因总结:

  1. Banner 是可以自动播放的,当 Banner 从原 Item2 切换到假 Item0 的过程中,用户突然上滑将 BannerViewPager 移除屏幕,这时 onDetachedFromWindow 回调将动画停止,onPageScrollStateChanged 无法得到调用。
  2. 当用户再次将 BannerBannerViewPager 移入屏幕时,onAttachedToWindow 回调将 mFirstLayout 变量设置为 true。自动播放再次触发,通过 setCurrentItem 将展示内容设置为假 item0。但是 mFirstLayout 为 true,因此通过了 requestLayout 机制进行实现,没有回调 onPageScrollStateChanged 方法,因此假 Item0 位置无法切换成原 Item0,此时内部的 BannerViewPager 是无法滑动状态。
  3. 根据之前外部 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();
    }
}
退出移动版