收到線上用戶反饋,RecyclerView 實現的 Feed 流列表中的 Banner Item 在滑動過程當中偶現沒有進行內容切換,而是進行了外層頻道切換。嵌套的UI佈局以下圖所示:
java
猜想緣由是:最外層OuterViewPager攔截了Touch事件,沒有將Touch事件傳遞給內層的BannerViewPager,從而致使外層頻道切換。ide
想證明猜想的準確性,定位爲何OuterViewPager攔截了事件,只能經過閱讀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事件的關鍵塊。學習
private boolean isGutterDrag(float x, float dx) { return (x < mGutterSize && dx > 0) || (x > getWidth() - mGutterSize && dx < 0); }
代碼塊做用是判斷滑動起始位置:優化
結合以前的 onInterceptTouchEvent 中判斷條件進行分析:若是觸摸位置位於邊緣,則OuterViewPager直接攔截事件。默認的mGuuterSize是16dp.動畫
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
從對onInterceptTouchEvent源碼的分析,外層OuterViewPager若是攔截的事件,只多是兩個緣由:spa
須要肯定用戶是不是從邊緣滑動致使的這個問題,若是是這樣,那須要優化邊緣距離判斷。
線下諮詢出現問題的用戶,得出不是從邊緣滑動的觸發場景,所以排除isGutterDrag致使的問題。
排除了邊緣滑動,那必定是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的回調時機。
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(); }
真相大白了,看懂的同窗此處應該有掌聲。
問題緣由總結:
按照上述步驟,調整一下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(); } }