RecyclerView內嵌ViewPager(無限滑動Banner)的爬坑之旅

前言

收到線上用戶反饋,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();
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章