前言

收到线上用户反馈,RecyclerView 实现的 Feed 流列表中的 Banner Item 在滑动过程中偶现没有进行内容切换,而是进行了外层频道切换。嵌套的UI布局如下图所示:
banner

问题原因定位

猜测原因是:最外层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的实现机制。

Banner

如上图所示,在正常的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

  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();
    }
}

王正一
1.6k 声望27 粉丝