從RecyclerView、NestedScrollView源碼分析嵌套滑動異常

一、顯示不全、自動滾動異常

NestedScrollView嵌套RecyclerView時,有2個問題:
1、RecyclerView數據加載完成後,會自動滾動到第一個itemView的位置上,導致RecyclerView上面的佈局不顯示;
2、當RecyclerView的高度發生改變時,也會自動滾動到第一個itemView的位置上;

兩個問題的原因其實都一樣,就是NestedScrollView的子控件佈局發生改變,導致NestedScrollView的高度發生改變,然後會自動滾動到擁有焦點的子view上。

NestedScrollView源碼:

@Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);

        // 獲取當前擁有焦點的子view
        View currentFocused = findFocus();
        if (null == currentFocused || this == currentFocused) {
            return;
        }

        // 源碼官方註釋,大意就是:
        // 如果height改變前,“焦點view”顯示在屏幕上,那麼height改變後,也應該滾動屏幕,讓其仍然顯示在屏幕上
        // If the currently-focused view was visible on the screen when the
        // screen was at the old height, then scroll the screen to make that
        // view visible with the new screen height.
        if (isWithinDeltaOfScreen(currentFocused, 0, oldh)) {
            currentFocused.getDrawingRect(mTempRect);
            offsetDescendantRectToMyCoords(currentFocused, mTempRect);
            // 這個方法是計算需要滾動的距離
            int scrollDelta = computeScrollDeltaToGetChildRectOnScreen(mTempRect);
            // 執行滾動
            doScrollY(scrollDelta);
        }
    }

問題出在computeScrollDeltaToGetChildRectOnScreen()方法上,它的功能是根據“焦點view”的寬高計算需要滾動的距離,計算一般view都是OK的,但是計算RecyclerView/ListView時會出問題,得到的結果總是要滑到第一個item處。具體的計算邏輯就不在這裏分析了。

所以解決方案有兩種:
1、讓“焦點view”不是RecyclerView/ListView,而是一個其它不影響滾動效果的view。
比較簡單的做法就是讓NestedScrollView的一級子view獲取焦點,成爲“焦點view”。一個比較簡單的方法是,在xml佈局時,設置focusable、focusableInTouchMode屬性爲true,如下:

    <android.support.v4.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:focusable="true"
            android:focusableInTouchMode="true"
            android:orientation="vertical">

            <android.support.v7.widget.RecyclerView
                android:id="@+id/rv_many_item"
                android:layout_width="match_parent"
                android:layout_height="0dp"
                android:layout_weight="1"/>
        </LinearLayout>
    </android.support.v4.widget.NestedScrollView>


2、重寫computeScrollDeltaToGetChildRectOnScreen()方法,讓其返回正確的值。
有時候佈局比較複雜、上面兩個屬性不生效,就重寫該方法,判斷如果“焦點view”是RecyclerView/ListView,就返回0。

二、慣性滑動,即fling失效

網上有很多解決慣性滑動失效的方案,主要是以下兩種:

// 方案一:
mRecyclerView.setNestedScrollingEnabled(false);

// 方案二:
mRecyclerView.setLayoutManager(new LinearLayoutManager(this) {
    @Override
    public boolean canScrollVertically() {
        return false;
    }
});

// 這兩種方案的本質都是一樣:禁止RecyclerView的滑動事件,讓NestedScrollView來管理滑動。

但是卻找不到一篇分析原因的文章(難道大家都是亂試出來的?),下面就從源碼入手,分析一下原因所在。慣性滑動肯定是在dispatchTouchEvent或onTouchEvent的ACTION_UP中,我們直接進去找。

RecyclerView的onTouchEvent()方法:

case MotionEvent.ACTION_UP: {
                mVelocityTracker.addMovement(vtev);
                eventAddedToVelocityTracker = true;
                mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
                final float xvel = canScrollHorizontally ?
                        -VelocityTrackerCompat.getXVelocity(mVelocityTracker, mScrollPointerId) : 0;
                final float yvel = canScrollVertically ?
                        -VelocityTrackerCompat.getYVelocity(mVelocityTracker, mScrollPointerId) : 0;

                // 前面經過一大堆判斷之後,終於執行了fling()方法
                if (!((xvel != 0 || yvel != 0) && fling((int) xvel, (int) yvel))) {
                    setScrollState(SCROLL_STATE_IDLE);
                }
                resetTouch();
            } break;


RecyclerView的fling()方法:

public boolean fling(int velocityX, int velocityY) {
        if (mLayout == null) {
            Log.e(TAG, "Cannot fling without a LayoutManager set. " +
                    "Call setLayoutManager with a non-null argument.");
            return false;
        }
        if (mLayoutFrozen) {
            return false;
        }
        final boolean canScrollHorizontal = mLayout.canScrollHorizontally();
        // 判斷豎直方向上是否能滑動。這個mLayout就是LayoutManager
        final boolean canScrollVertical = mLayout.canScrollVertically();

        if (!canScrollHorizontal || Math.abs(velocityX) < mMinFlingVelocity) {
            velocityX = 0;
        }
        if (!canScrollVertical || Math.abs(velocityY) < mMinFlingVelocity) {
            velocityY = 0;
        }
        if (velocityX == 0 && velocityY == 0) {
            // If we don't have any velocity, return false
            return false;
        }

        if (!dispatchNestedPreFling(velocityX, velocityY)) {
            final boolean canScroll = canScrollHorizontal || canScrollVertical;
            // 通知父view(即NestedScrollView)調用onNestedFling()方法,參數canScroll告訴父view自己是否消費;
            // 如果不消費,父view就會自己處理fling;如果消費了,父view就不會處理了。這就是上面的解決方案能生效的原因
            dispatchNestedFling(velocityX, velocityY, canScroll);

            if (mOnFlingListener != null && mOnFlingListener.onFling(velocityX, velocityY)) {
                return true;
            }

            if (canScroll) {
                velocityX = Math.max(-mMaxFlingVelocity, Math.min(velocityX, mMaxFlingVelocity));
                velocityY = Math.max(-mMaxFlingVelocity, Math.min(velocityY, mMaxFlingVelocity));
                // 如果能滑動,就啓動fling了。mViewFlinger是RecyclerView的內部類,專門處理fling的,不是很懂的可以參考之前那篇講Scroller的博客
                mViewFlinger.fling(velocityX, velocityY);
                return true;
            }
        }
        return false;
    }


ViewFlinger類的fling()方法:

public void fling(int velocityX, int velocityY) {
            setScrollState(SCROLL_STATE_SETTLING);
            mLastFlingX = mLastFlingY = 0;
            // 調用Scroller的fling()方法,計算目標點的座標,並記錄下來
            mScroller.fling(0, 0, velocityX, velocityY,
                    Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE);
            // 啓動“動畫”
            postOnAnimation();
        }

void postOnAnimation() {
            if (mEatRunOnAnimationRequest) {
                mReSchedulePostAnimationCallback = true;
            } else {
                removeCallbacks(this);
                // 參數this是Runable,因爲ViewFlinger實現了Runable接口。真正的滑動操作在run()方法裏
                ViewCompat.postOnAnimation(RecyclerView.this, this);
            }
        }


ViewFlinger類的run()方法:

// 方法很長,只貼出部分,可以看到裏面調用了類似於scrollBy()的方法來進行view的移動
public void run() {
......
......
......
    final int x = scroller.getCurrX();
                final int y = scroller.getCurrY();
                final int dx = x - mLastFlingX;
                final int dy = y - mLastFlingY;
                int hresult = 0;
                int vresult = 0;
                mLastFlingX = x;
                mLastFlingY = y;
                int overscrollX = 0, overscrollY = 0;
                if (mAdapter != null) {
                    eatRequestLayout();
                    onEnterLayoutOrScroll();
                    TraceCompat.beginSection(TRACE_SCROLL_TAG);
                    if (dx != 0) {
                        hresult = mLayout.scrollHorizontallyBy(dx, mRecycler, mState);
                        overscrollX = dx - hresult;
                    }
                    if (dy != 0) {
                        // 所有的滑動都是由N次scrollBy()一點一點移動的。mLayout就是LayoutManager,可見,真正的滑動是在LayoutManager裏面實現的
                        // scrollVerticallyBy()返回的就是滑動的距離
                        vresult = mLayout.scrollVerticallyBy(dy, mRecycler, mState);
                        overscrollY = dy - vresult;
                    }
                    TraceCompat.endSection();
                    repositionShadowingViews();
                }
......
......
......
}


由於RecyclerView、NestedScrollView的方法基本都是缺省、私有的,所以從外部很難跟蹤問題,最終在NestedScrollView的onNestedScroll()方法中跟蹤到“RecyclerView消費的距離總是0”,所以才導致滑動異常。

現在的研究方向就是:爲什麼RecyclerView消費的距離總是0?

最終自己寫了一個類,把LinearLayoutManager類的代碼完全copy過來,修復各種報錯信息,然後通過打印log找到了原因。

LinearLayoutManager的scrollVerticallyBy()方法:

// 這個方法返回的值就是RecyclerView消費的距離,總是返回0
    @Override
    public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler,
            RecyclerView.State state) {
        // LinearLayoutManager的方向默認是VERTICAL
        if (mOrientation == HORIZONTAL) {
            return 0;
        }
        // 所以是scrollBy()返回了0
        return scrollBy(dy, recycler, state);
    }

int scrollBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
    // 打印出來dy肯定不爲0,childCount也不爲0
        if (getChildCount() == 0 || dy == 0) {
            return 0;
        }
        mLayoutState.mRecycle = true;
        ensureLayoutState();
        final int layoutDirection = dy > 0 ? LayoutState.LAYOUT_END : LayoutState.LAYOUT_START;
        final int absDy = Math.abs(dy);
        updateLayoutState(layoutDirection, absDy, true, state);
        // consumed就是消費的距離,fill()方法的作用就是根據一列參數,計算出應該消費的距離
        final int consumed = mLayoutState.mScrollingOffset
                + fill(recycler, mLayoutState, state, false);
        if (consumed < 0) {
            if (DEBUG) {
                Log.d(TAG, "Don't have any more elements to scroll");
            }
            return 0;
        }
        // 返回值scrolled最終是在這裏賦值,由於consumed爲0,所以scrolled也爲0
        final int scrolled = absDy > consumed ? layoutDirection * consumed : dy;
        mOrientationHelper.offsetChildren(-scrolled);
        if (DEBUG) {
            Log.d(TAG, "scroll req: " + dy + " scrolled: " + scrolled);
        }
        mLayoutState.mLastScrollDelta = scrolled;
        // 最終返回值也是0
        return scrolled;
    }

再看fill()方法:

private int fill(RecyclerView.Recycler recycler, LayoutState layoutState,RecyclerView.State state, boolean stopOnFocusable) {
        // 記錄滑動前的位置start
        final int start = layoutState.mAvailable;
        if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
            if (layoutState.mAvailable < 0) {
                layoutState.mScrollingOffset += layoutState.mAvailable;
            }
            recycleByLayoutState(recycler, layoutState);
        }
        int remainingSpace = layoutState.mAvailable + layoutState.mExtra;
        LayoutChunkResult layoutChunkResult = mLayoutChunkResult;
        // 如果是向下滑,layoutState.hasMore(state)就是獲取“下面是否還有itemView未加載”;上滑則反之
        // layoutState.hasMore(state)總是返回false,while循環進不去
        while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
            layoutChunkResult.resetInternal();
            layoutChunk(recycler, state, layoutState, layoutChunkResult);
            if (layoutChunkResult.mFinished) {
                break;
            }
            layoutState.mOffset += layoutChunkResult.mConsumed * layoutState.mLayoutDirection;
            if (!layoutChunkResult.mIgnoreConsumed || mLayoutState.mScrapList != null
                    || !state.isPreLayout()) {
                // 通過一些列計算,得到消費距離,用當前位置減去消費的距離,得到滑動後的位置
                layoutState.mAvailable -= layoutChunkResult.mConsumed;
                remainingSpace -= layoutChunkResult.mConsumed;
            }

            if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
                layoutState.mScrollingOffset += layoutChunkResult.mConsumed;
                if (layoutState.mAvailable < 0) {
                    layoutState.mScrollingOffset += layoutState.mAvailable;
                }
                recycleByLayoutState(recycler, layoutState);
            }
            if (stopOnFocusable && layoutChunkResult.mFocusable) {
                break;
            }
        }
        // 滑動開始前的位置減去滑動後的位置,就是滑動的距離
        // 由於while循環進不去,所以layoutState.mAvailable的值不會改變,所以結果總是爲0
        return start - layoutState.mAvailable;
    }

// 判斷上/下面是否還有itemView未顯示,如果還有,就允許滑動,否則就禁止滑動。由此來保證不會滑出界
@Override
boolean hasMore(RecyclerView.State state) {
    // 通過打印發現,下滑時,mCurrentPosition的值總是等於itemCount;上滑時,總是等於-1。所以總是返回false
    return mCurrentPosition >= 0 && mCurrentPosition < state.getItemCount();
}

那mCurrentPosition爲什麼會等於itemCount或-1呢?

因爲NestedScrollView加載RecyclerView時,無法確定其高度,所以RecyclerView總是把所有item一次性加載完(可以通過打印onCreateViewHolder()發現)。對於屏幕顯示來說,只顯示了部分item,但對於RecyclerView來說,所有item都處於“顯示狀態”,所以hasMore()肯定就返回false。

一點警惕:如果需要加載大量item,最好不要用NestedScrollView嵌套RecyclerView,一次性new出大量itemView,可能會導致OOM。

發佈了35 篇原創文章 · 獲贊 52 · 訪問量 8萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章