RecyclerView.smoothScrollToPosition瞭解一下

AndyandJennifer發表於2019-03-04

小狗狗.jpg

前言

最近開發中遇到了一個需求,需要RecyclerView滾動到指定位置後置頂顯示,當時遇到這個問題的時候,心裡第一反應是直接使用RecyclerView的smoothScrollToPosition()方法,實現對應位置的平滑滾動。但是在實際使用中發現並沒有到底自己想要的效果。本想著偷懶直接從網上Copy下,但是發現效果並不是很好。於是就自己去研究原始碼。

該系列文章分為兩篇文章。

什麼是可見範圍?

在瞭解RecyclerView的smoothScrollToPosition方法之前,有個知識點,我覺得有必要給大家說一下,因為使用smoothScrollToPosition中遇到的問題都與可見範圍有關。

可見範圍.png

這裡所說的可見範圍是,RecyclerView第一個可見item的位置與最後一個可見item的位置之間的範圍。

一、實際使用中遇見的問題

如果當前滾動位置在可見範圍內,是不會發生滾動的

不會滾動.gif

當前RecyclerView的可見範圍為0到9,當我們想要滾動到1位置時,發現當前RecyclerView並沒有發生滾動。

二、如果當前滾動位置在可見範圍之後,會滾動到底部

滾動到底部.gif

當前RecyclerView的可見範圍為0到9,當我們想要滾動到10位置時,發現RecyclerView滾動了,且當前位置對應的檢視在RecyclreView的底部。

三、如果當前滾動位置在可見範圍之前,會滾動到頂部

滾動到頂部.gif

這裡我們滾動RecyclerView,使其可見範圍為10到19,當我們分別滾動到1、3位置時,RecyclerView滾動了。且當前位置對應的檢視在RecyclerView的頂部。

二、RecyclerView smoothScrollToPosition原始碼解析

到了這裡我們發現對於不同情況,RecyclerView內部處理是不一樣的,所以為了解決實際問題,看原始碼是必不可少的,接下來我們就一起跟著原始碼走一遍。來看看RecyclerView具體的滾動實現。(這裡需要提醒大家的是這裡我採用的是LinearLayoutManager,本文章都是基於LinearLayoutManager進行分析的)

  public void smoothScrollToPosition(int position) {
        if (mLayoutFrozen) {
            return;
        }
        if (mLayout == null) {
            Log.e(TAG, "Cannot smooth scroll without a LayoutManager set. "
                    + "Call setLayoutManager with a non-null argument.");
            return;
        }
        mLayout.smoothScrollToPosition(this, mState, position);
    }
複製程式碼

mRecycler.smoothScrollToPosition()方法時,內部呼叫了LayoutManager的smoothScrollToPosition方法,LayoutManager中smoothScrollToPosition沒有實現,具體實現在其子類中,這裡我們使用的是LinearLayoutManager,所以我們來看看內部是怎麼實現的。

   @Override
    public void smoothScrollToPosition(RecyclerView recyclerView, RecyclerView.State state,
            int position) {
        LinearSmoothScroller scroller = new LinearSmoothScroller(recyclerView.getContext());
        scroller.setTargetPosition(position);//設定目標位置
        startSmoothScroll(scroller);
    }

複製程式碼

這裡我們可以看到,這裡導致RecyclerView滑動的是LinearSmoothScroller,而LinearSmoothScroller的父類是RecyclerView.SmoothScroller,看到這裡我相信大家都會感到一絲熟悉,因為我們在對控制元件內內容進行移動的時候,我們都會使用到一個類,那就是Scroller。這裡RecyclerView也自定了一個滑動Scroller。肯定是與滑動其內部檢視相關的。

 public void startSmoothScroll(SmoothScroller smoothScroller) {
            if (mSmoothScroller != null && smoothScroller != mSmoothScroller
                    && mSmoothScroller.isRunning()) {
                mSmoothScroller.stop();
            }
            mSmoothScroller = smoothScroller;
            mSmoothScroller.start(mRecyclerView, this);
        }
複製程式碼

繼續走startSmoothScroll,方法內部判斷了如果正在計算座標值就停止,然後呼叫start()方法重新開始計算座標值。接著開始看start()方法。

 void start(RecyclerView recyclerView, LayoutManager layoutManager) {
            mRecyclerView = recyclerView;
            mLayoutManager = layoutManager;
            if (mTargetPosition == RecyclerView.NO_POSITION) {
                throw new IllegalArgumentException("Invalid target position");
            }
            mRecyclerView.mState.mTargetPosition = mTargetPosition;
            mRunning = true;//設定當前scroller已經開始執行
            mPendingInitialRun = true;
            mTargetView = findViewByPosition(getTargetPosition());//根據目標位置查詢相應View,
            onStart();
            mRecyclerView.mViewFlinger.postOnAnimation();
        }
複製程式碼

在start方法中,會標識當前scroller的執行狀態,同時會根據滾動的位置去尋找對應的目標檢視。這裡需要著重提示一下,findViewByPosition()這個方法,該方法會在Recycler的可見範圍內去查詢是否有目標位置對應的檢視,例如,現在RecyclerView的可見範圍為1-9,目標位置為10,那麼mTargetView =null,如果可見範圍為9-20,目標位置為1,那麼mTargetView =null。

最終呼叫RecyclerView的內部類 ViewFlinger的postOnAnimation()方法。

   class ViewFlinger implements Runnable {
	   ....省略部分程式碼
     void postOnAnimation() {
            if (mEatRunOnAnimationRequest) {
                mReSchedulePostAnimationCallback = true;
            } else {
                removeCallbacks(this);
                ViewCompat.postOnAnimation(RecyclerView.this, this);
            }
        }
   }
複製程式碼

這裡我們發現,ViewFlinger其實一個Runnable,在postOnAnimation()內部又將該Runnable傳送出去了。那下面我們只用關心ViewFlinger的run()方法就行了。

   @Override
        public void run() {
		           ...省略部分程式碼
            final OverScroller scroller = mScroller;
            //獲得layoutManger中的SmoothScroller
            final SmoothScroller smoothScroller = mLayout.mSmoothScroller;
            if (scroller.computeScrollOffset()) {//如果是第一次走,會返回false
	               ...省略部分程式碼
             }
            if (smoothScroller != null) {
                if (smoothScroller.isPendingInitialRun()) {
                    smoothScroller.onAnimation(0, 0);
                }
                if (!mReSchedulePostAnimationCallback) {
                    smoothScroller.stop(); //stop if it does not trigger any scroll
                }
            }
              ...省略部分程式碼
        }
複製程式碼

ViewFlinger的run()方法內部實現比較複雜, 在該方法第一次執行的時候,會執行,if (scroller.computeScrollOffset()) ,其中scroller是ViewFlinger中的屬性mScroller的引用,其中mScroller會在ViewFlinger建立物件的時候,就預設初始化了。那麼第一次判斷時候,因為還沒有開始計算,所以不會進這個if語句塊,那麼接下來就會直接走下面的語句:

	 if (smoothScroller != null) {
                if (smoothScroller.isPendingInitialRun()) {
                    smoothScroller.onAnimation(0, 0);
                }
                if (!mReSchedulePostAnimationCallback) {
                    smoothScroller.stop(); //stop if it does not trigger any scroll
                }
            }
複製程式碼

最後發現,只是走了一個onAnimation(0,0),繼續走該方法。

 private void onAnimation(int dx, int dy) {
            final RecyclerView recyclerView = mRecyclerView;
            if (!mRunning || mTargetPosition == RecyclerView.NO_POSITION || recyclerView == null) {
                stop();
            }
            mPendingInitialRun = false;
            if (mTargetView != null) {//判斷目標檢視是否存在,如果存在則計算移動到位置需要移動的距離
                if (getChildPosition(mTargetView) == mTargetPosition) {
                    onTargetFound(mTargetView, recyclerView.mState, mRecyclingAction);
                    mRecyclingAction.runIfNecessary(recyclerView);
                    stop();
                } else {
                    Log.e(TAG, "Passed over target position while smooth scrolling.");
                    mTargetView = null;
                }
            }
            if (mRunning) {//如果不存在,繼續去找
                onSeekTargetStep(dx, dy, recyclerView.mState, mRecyclingAction);
                boolean hadJumpTarget = mRecyclingAction.hasJumpTarget();
                mRecyclingAction.runIfNecessary(recyclerView);
                if (hadJumpTarget) {
                    // It is not stopped so needs to be restarted
                    if (mRunning) {
                        mPendingInitialRun = true;
                        recyclerView.mViewFlinger.postOnAnimation();
                    } else {
                        stop(); // done
                    }
                }
            }
        }
複製程式碼

在onAnimation方法中,判斷了目標檢視是否為空,大家應該還記得上文中,我們對目標檢視的查詢。如果當前位置不在可見範圍之內,那麼mTargetView =null,就不回走對應的判斷語句。繼續檢視onSeekTargetStep()。

    protected void onSeekTargetStep(int dx, int dy, RecyclerView.State state, Action action) {
        if (getChildCount() == 0) {
            stop();
            return;
        }
        //noinspection PointlessBooleanExpression
        if (DEBUG && mTargetVector != null
                && ((mTargetVector.x * dx < 0 || mTargetVector.y * dy < 0))) {
            throw new IllegalStateException("Scroll happened in the opposite direction"
                    + " of the target. Some calculations are wrong");
        }
        mInterimTargetDx = clampApplyScroll(mInterimTargetDx, dx);
        mInterimTargetDy = clampApplyScroll(mInterimTargetDy, dy);

        if (mInterimTargetDx == 0 && mInterimTargetDy == 0) {
            updateActionForInterimTarget(action);
        } // everything is valid, keep going

    }
複製程式碼

直接通過程式碼,發現並不理解改函式要做什麼樣的工作,這裡我們只知道第一次發生滾動時,mInterimTargetDx=0與mInterimTargetDy =0,那麼會走updateActionForInterimTarget()方法。

    protected void updateActionForInterimTarget(Action action) {
        // find an interim target position
        PointF scrollVector = computeScrollVectorForPosition(getTargetPosition());
		...省略部分程式碼
        normalize(scrollVector);
        mTargetVector = scrollVector;

        mInterimTargetDx = (int) (TARGET_SEEK_SCROLL_DISTANCE_PX * scrollVector.x);
        mInterimTargetDy = (int) (TARGET_SEEK_SCROLL_DISTANCE_PX * scrollVector.y);
        
		//計算需要滾動的時間,  預設滾動距離,TARGET_SEEK_SCROLL_DISTANCE_PX = 10000;
        final int time = calculateTimeForScrolling(TARGET_SEEK_SCROLL_DISTANCE_PX);
		  
		//為了避免在滾動的時候出現停頓,我們會跟蹤onSeekTargetStep中的回撥距離,實際上不會滾動超出實際的距離
        action.update((int) (mInterimTargetDx * TARGET_SEEK_EXTRA_SCROLL_RATIO),
                (int) (mInterimTargetDy * TARGET_SEEK_EXTRA_SCROLL_RATIO),
                //這裡存入的時間要比實際花費的時間大一點。
                (int) (time * TARGET_SEEK_EXTRA_SCROLL_RATIO), mLinearInterpolator);
    }
複製程式碼

根據官方文件進行翻譯:當目標滾動位置對應檢視不在RecyclerView的可見範圍內,該方法計算朝向該檢視的方向向量並觸發平滑滾動。預設滾動的距離為12000(單位:px),(也就是說了為了滾動到目標位置,會讓Recycler至多滾動12000個畫素)

既然該方法計算了時間,那麼我們就看看calculateTimeForScrolling()方法,通過方法名我們就應該瞭解了該方法是計算給定距離在預設速度下需要滾動的時間。

    protected int calculateTimeForScrolling(int dx) {
	    //這裡對時間進行了四捨五入操作。 
        return (int) Math.ceil(Math.abs(dx) * MILLISECONDS_PER_PX);
    }

複製程式碼

其中MILLISECONDS_PER_PX 會在LinearSmoothScroller初始化的時候建立。

  public LinearSmoothScroller(Context context) {
      MILLISECONDS_PER_PX = calculateSpeedPerPixel(context.getResources().getDisplayMetrics());
    }
  
複製程式碼

檢視calculateSpeedPerPixel()方法

    private static final float MILLISECONDS_PER_INCH = 25f;// 預設為移動一英寸需要花費25ms
    protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
        return MILLISECONDS_PER_INCH / displayMetrics.densityDpi;
    }

複製程式碼

也就是說,當前滾動的速度是與螢幕的畫素密度相關, 通過獲取當前手機螢幕每英寸的畫素密度,與每英寸移動所需要花費的時間,用每英寸移動所需要花費的時間除以畫素密度就能計算出移動一個畫素密度需要花費的時間。OK,既然我們已經算出了移動一個畫素密度需要花費的時間,那麼直接乘以畫素,就能算出移動該畫素所需要花費的時間了。

既然現在我們算出了時間,我們現在只用關心Action的update()方法到底是幹什麼的就好了,

		//儲存關於SmoothScroller滑動距離資訊
        public static class Action {
		       ...省略程式碼
	         public void update(int dx, int dy, int duration, Interpolator interpolator) {
                mDx = dx;
                mDy = dy;
                mDuration = duration;
                mInterpolator = interpolator;
                mChanged = true;
            }
	     }
複製程式碼

這裡我們發現Action,只是儲存關於SmoothScroller滑動資訊的一個類,那麼初始時儲存了橫向與豎直滑動的距離(12000px)、滑動時間,插值器。同時記錄當前資料改變的狀態。

現在我們已經把Action的onSeekTargetStep方法走完了,那接下來,我們繼續看Action的runIfNecessary()方法。

   void runIfNecessary(RecyclerView recyclerView) {
		         ....省略程式碼
                if (mChanged) {
                    validate();
                    if (mInterpolator == null) {
                        if (mDuration == UNDEFINED_DURATION) {
                            recyclerView.mViewFlinger.smoothScrollBy(mDx, mDy);
                        } else {
	                        //這裡傳入的mDx,mDy,mDuration.是Action之前update()方法。儲存的資訊
                            recyclerView.mViewFlinger.smoothScrollBy(mDx, mDy, mDuration);
                        }
                    } else {
                        recyclerView.mViewFlinger.smoothScrollBy(
                                mDx, mDy, mDuration, mInterpolator);
                    }
              mChanged = false;
                ....省略程式碼
            }
複製程式碼

TNND,調來調去最後又把Action儲存的資訊傳給了ViewFlinger的smoothScrollBy()方法。這裡需要注意:一旦呼叫該方法會將mChanged置為false,下次再次進入該方法時,那麼就不會呼叫ViewFlinger的滑動方法了。

 public void smoothScrollBy(int dx, int dy, int duration, Interpolator interpolator) {
			 //判斷是否是同一插值器,如果不是,重新建立mScroller
            if (mInterpolator != interpolator) {
                mInterpolator = interpolator;
                mScroller = new OverScroller(getContext(), interpolator);
            }
            setScrollState(SCROLL_STATE_SETTLING);
            mLastFlingX = mLastFlingY = 0;
            mScroller.startScroll(0, 0, dx, dy, duration);
            if (Build.VERSION.SDK_INT < 23) {
                mScroller.computeScrollOffset();
            }
            postOnAnimation();
        }
複製程式碼

這裡mScroller接受到Acttion傳入的滑動資訊開始滑動後。最後會呼叫postOnAnimation(),又將ViewFiinger的run()法傳送出去。那麼最終我們又回到了ViewFiinger的run()方法。

    public void run() {
         ...省略部分程式碼
   if (scroller.computeScrollOffset()) {
                final int[] scrollConsumed = mScrollConsumed;
                final int x = scroller.getCurrX();
                final int y = scroller.getCurrY();
                int dx = x - mLastFlingX;
                int dy = y - mLastFlingY;
                int hresult = 0;
                int vresult = 0;
                mLastFlingX = x;
                mLastFlingY = y;
                int overscrollX = 0, overscrollY = 0;
		        ...省略部分程式碼
                if (mAdapter != null) {
                    startInterceptRequestLayout();
                    onEnterLayoutOrScroll();
                    TraceCompat.beginSection(TRACE_SCROLL_TAG);
                    fillRemainingScrollValues(mState);
                    if (dx != 0) {//如果橫向方向大於0,開始讓RecyclerView滾動
                        hresult = mLayout.scrollHorizontallyBy(dx, mRecycler, mState);
                        overscrollX = dx - hresult;
                    }
                    if (dy != 0) {//如果豎直方向大於0,開始讓RecyclerView滾動,獲得當前滾動的距離
                        vresult = mLayout.scrollVerticallyBy(dy, mRecycler, mState);
                        overscrollY = dy - vresult;
                    }
                    TraceCompat.endSection();
                    repositionShadowingViews();

                    onExitLayoutOrScroll();
                    stopInterceptRequestLayout(false);
                    if (smoothScroller != null && !smoothScroller.isPendingInitialRun()
                            && smoothScroller.isRunning()) {
                        final int adapterSize = mState.getItemCount();
                        if (adapterSize == 0) {
                            smoothScroller.stop();
                        } else if (smoothScroller.getTargetPosition() >= adapterSize) {
                            smoothScroller.setTargetPosition(adapterSize - 1);
                            //傳入當前RecylerView滾動的距離 dx dy
                            smoothScroller.onAnimation(dx - overscrollX, dy - overscrollY);
                        } else {
                            //傳入當前RecylerView滾動的距離 dx dy
                            smoothScroller.onAnimation(dx - overscrollX, dy - overscrollY);
                        }
                    }
                }
                enableRunOnAnimationRequests();
             }
複製程式碼

這裡scroller(拿到之前Action傳入的滑動距離資訊)已經開始滑動了,故 if (scroller.computeScrollOffset()) 條件為true, 那麼scroller拿到當前豎直方向的值就開始讓RecyclerView滾動了,也就是程式碼 mLayout.scrollVerticallyBy(dy, mRecycler, mState);接著又讓smoothScroller執行onAnimation()方法。其中傳入的引數是RecyclerView已經滾動的距離。那我們現在繼續看onAnimation方法。

    private void onAnimation(int dx, int dy) {
            final RecyclerView recyclerView = mRecyclerView;
            if (!mRunning || mTargetPosition == RecyclerView.NO_POSITION || recyclerView == null) {
                stop();
            }
            mPendingInitialRun = false;
            if (mTargetView != null) {
                // verify target position
                if (getChildPosition(mTargetView) == mTargetPosition) {
                    onTargetFound(mTargetView, recyclerView.mState, mRecyclingAction);
                    mRecyclingAction.runIfNecessary(recyclerView);
                    stop();
                } else {
                    Log.e(TAG, "Passed over target position while smooth scrolling.");
                    mTargetView = null;
                }
            }
            if (mRunning) {//獲得當前Recycler需要滾動的距離
                onSeekTargetStep(dx, dy, recyclerView.mState, mRecyclingAction);
                boolean hadJumpTarget = mRecyclingAction.hasJumpTarget();
                mRecyclingAction.runIfNecessary(recyclerView);
                if (hadJumpTarget) {
                    // It is not stopped so needs to be restarted
                    if (mRunning) {
                        mPendingInitialRun = true;
                        recyclerView.mViewFlinger.postOnAnimation();
                    } else {
                        stop(); // done
                    }
                }
            }
        }
複製程式碼

那麼現在程式碼就明瞭了,RecylerView會判斷在滾動的時候,目標檢視是否已經出現,如果沒有出現,會呼叫onSeekTargetStep儲存當前RecylerView滾動距離,然後判斷RecyclerView是否需要滑動,然後又通過postOnAnimation()將ViewFlinger 傳送出去了。那麼直到找到目標檢視才會停止。

那什麼情況下,目標檢視不為空呢,其實在RecylerView內部滾動的時候。會判斷目標檢視是否存在,如果存在會對mTargetView進行賦值操作。由於篇幅限制,這裡就不對目標檢視的查詢進行介紹了,有興趣的小夥伴可以自己看一下原始碼。

那接下來,我們就假如當前已經找到了目標檢視,那麼接下來程式會走onTargetFound()方法。

  protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
        //計算讓目標檢視可見的,需要滾動的橫向距離
        final int dx = calculateDxToMakeVisible(targetView, getHorizontalSnapPreference());
       //計算讓目標檢視可見的,需要滾動的橫向距離
        final int dy = calculateDyToMakeVisible(targetView, getVerticalSnapPreference());
        final int distance = (int) Math.sqrt(dx * dx + dy * dy);
        final int time = calculateTimeForDeceleration(distance);
        if (time > 0) {
            //更新需要滾動的距離。
            action.update(-dx, -dy, time, mDecelerateInterpolator);
        }
    }
複製程式碼

當目標檢視被找到以後,會計算讓目標檢視出現在可見範圍內,需要移動的橫向與縱向距離。並計算所需要花費的時間。然後重新讓RecyclerView滾動一段距離。

這裡我們著重看calculateDyToMakeVisible。

    public int calculateDyToMakeVisible(View view, int snapPreference) {
        final RecyclerView.LayoutManager layoutManager = getLayoutManager();
        if (layoutManager == null || !layoutManager.canScrollVertically()) {
            return 0;
        }
        final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
                view.getLayoutParams();
        //獲取當前view在其父佈局的開始位置
        final int top = layoutManager.getDecoratedTop(view) - params.topMargin;
        //獲取當前View在其父佈局結束位置
        final int bottom = layoutManager.getDecoratedBottom(view) + params.bottomMargin;
        //獲取當前佈局的開始位置 
        final int start = layoutManager.getPaddingTop();
        //獲取當前佈局的結束位置
        final int end = layoutManager.getHeight() - layoutManager.getPaddingBottom();
        return calculateDtToFit(top, bottom, start, end, snapPreference);
    }
複製程式碼

這裡我們會根據當前view的top、bottom及當前佈局的start、end等座標資訊,然後呼叫了calculateDtToFit()方法。現在最重要的出現了,也是我們那三個問題出現的原因!!

    public int calculateDtToFit(int viewStart, int viewEnd, int boxStart, int boxEnd, int
            snapPreference) {
        switch (snapPreference) {
            case SNAP_TO_START:
                return boxStart - viewStart;
            case SNAP_TO_END:
                return boxEnd - viewEnd;
            case SNAP_TO_ANY:
                final int dtStart = boxStart - viewStart;
                if (dtStart > 0) {//滾動位置在可見範圍之前
                    return dtStart;
                }
                final int dtEnd = boxEnd - viewEnd;
                if (dtEnd < 0) {//滾動位置在可見範圍之後
                    return dtEnd;
                }
                break;
            default:
                throw new IllegalArgumentException("snap preference should be one of the"
                        + " constants defined in SmoothScroller, starting with SNAP_");
        }
        return 0;//在可見範圍之內,直接返回
    }

複製程式碼

我們會根據snapPreference對應的值來計算相應的距離,同時snapPreference的具體值與getVerticalSnapPreference(這裡我們是豎直方向)所以我們看該方法。

 protected int getVerticalSnapPreference() {
        return mTargetVector == null || mTargetVector.y == 0 ? SNAP_TO_ANY :
                mTargetVector.y > 0 ? SNAP_TO_END : SNAP_TO_START;
    }
複製程式碼

其中mTargetVector與layoutManager.computeScrollVectorForPosition有關。

  @Override
    public PointF computeScrollVectorForPosition(int targetPosition) {
        if (getChildCount() == 0) {
            return null;
        }
        final int firstChildPos = getPosition(getChildAt(0));
        final int direction = targetPosition < firstChildPos != mShouldReverseLayout ? -1 : 1;
        if (mOrientation == HORIZONTAL) {
            return new PointF(direction, 0);
        } else {
            return new PointF(0, direction);
        }
    }
複製程式碼

也就是說在LinerlayoutManager為豎直的情況下,snapPreference預設為SNAP_ANY,那麼我們就可以得到,下面三種情況。

  • 當滾動位置在可見範圍之內時 boxStart - viewStart<=0 boxEnd - viewEnd>0 滾動距離為0,故不會滾動
  • 當滾動位置在可見範圍之前時 boxStart - viewStart> 0 那麼實際滾動距離為正值,內容向上滾動,故只能滾動到頂部
  • 當滾動位置在可見範圍距離之外時 boxEnd - viewEnd<0 那麼實際滾動距離為其差值,內容向下滾動,故只能滾動到底部

有可能大家現在看程式碼已經看暈了,下面我就用一張圖來總結整個流程,結合流程圖再去看程式碼,我相信大家能有更好的理解。

基本流程圖.png

相關文章