前言
收到線上用戶反饋,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如果攔截的事件,只可能是兩個原因:
- 用戶從邊緣滑動。
- 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();
}
真相大白了,看懂的同學此處應該有掌聲。
問題原因總結:
- Banner是可以自動播放的,當Banner從原Item2切換到假Item0的過程中,用戶突然上滑將BannerViewPager移除屏幕,這時onDetachedFromWindow回調將動畫停止,onPageScrollStateChanged無法得到調用。
- 當用戶再次將BannerBannerViewPager移入屏幕時,onAttachedToWindow回調將mFirstLayout變量設置爲true。自動播放再次觸發,通過setCurrentItem將展示內容設置爲假item0。但是mFirstLayout爲true,因此通過了requestLayout機制進行實現,沒有回調onPageScrollStateChanged方法,因此假Item0位置無法切換成原Item0,此時內部的BannerViewPager是無法滑動狀態。
- 根據之前外部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();
}
}