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

前言

收到線上用戶反饋,RecyclerView 實現的 Feed 流列表中的 Banner Item 在滑動過程當中偶現沒有進行內容切換,而是進行了外層頻道切換。嵌套的UI佈局以下圖所示:
bannerjava

問題緣由定位

猜想緣由是:最外層OuterViewPager攔截了Touch事件,沒有將Touch事件傳遞給內層的BannerViewPager,從而致使外層頻道切換。ide

想證明猜想的準確性,定位爲何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源碼分析,能夠看出:post

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 不能橫向滑動場景只有兩個:this

  • 若是是從左向右滑動,而且Touch觸摸位於第一個item上,是不能滑動的。
  • 若是是從右向左滑動,而且Touch觸摸位於最後一個item上,那也是不能滑動的。

小結

從對onInterceptTouchEvent源碼的分析,外層OuterViewPager若是攔截的事件,只多是兩個緣由:spa

  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();
    }
}
相關文章
相關標籤/搜索