RecyclerView之SnapHelper源碼分析

好久沒有寫Android控件了,正好最近項目有個自定義控件的需求,整理了下作個總結,主要是實現相似於抖音翻頁的效果,可是有有點不一樣,須要在底部漏出後面的view,這樣說可能很差理解,看下Demo,按頁滑動,後面的View有放大縮放的動畫,滑動速度太小時會有回到原位的效果,下滑也是按頁滑動的效果。android

record.gif

有的小夥伴可能說這個用 SnapHelper就能夠了,沒錯,翻頁是要結合這個,可是也不是純粹靠這個,由於底部須要漏出來後面的view,因此LayoutManager就不能簡單的使用LinearLayoutManager,須要去自定義LayoutManager,而後再自定義SnapHelper緩存

若是把自定義LayoutManagerSnapHelper放在一篇裏面會太長,因此咱們今天主要分析SnapHelperbash

本文分析的源碼是基於recyclerview-v7-26.1.0app

1.ScrollFling

這方面參考個人上篇分享:RecyclerView之Scroll和Flingide

總結一下調用棧就是:源碼分析

SnapHelper
onFling ---> snapFromFling 
複製代碼

上面獲得最終位置targetPosition,把位置給RecyclerView.SmoothScroller, 而後就開始滑動了:post

RecyclerView.SmoothScroller
start --> onAnimation
複製代碼

在滑動過程當中若是targetPosition對應的targetView已經layout出來了,就會回調SnapHelper,而後計算獲得到當前位置到targetView的距離dx,dy動畫

SnapHelper
onTargetFound ---> calculateDistanceToFinalSnap
複製代碼

而後把距離dx,dy更新給RecyclerView.Action:ui

RecyclerView.Action
update --> runIfNecessary --> recyclerView.mViewFlinger.smoothScrollBy
複製代碼

最後調用RecyclerView.ViewFlinger, 而後又回到onAnimationthis

class ViewFlinger implements Runnable

        public void smoothScrollBy(int dx, int dy, int duration, Interpolator interpolator) {
            if (mInterpolator != interpolator) {
                mInterpolator = interpolator;
                mScroller = new OverScroller(getContext(), interpolator);
            }
            setScrollState(SCROLL_STATE_SETTLING);
            mLastFlingX = mLastFlingY = 0;
            mScroller.startScroll(0, 0, dx, dy, duration);
            postOnAnimation();
        }
複製代碼

2.SnapHelper源碼分析

上面其實已經接觸到部分的SnapHelper源碼, SnapHelper實際上是一個抽象類,有三個抽象方法:

/**
     * Override to provide a particular adapter target position for snapping.
     *
     * @param layoutManager the {@link RecyclerView.LayoutManager} associated with the attached
     *                      {@link RecyclerView}
     * @param velocityX fling velocity on the horizontal axis
     * @param velocityY fling velocity on the vertical axis
     *
     * @return the target adapter position to you want to snap or {@link RecyclerView#NO_POSITION}
     *         if no snapping should happen
     */
    public abstract int findTargetSnapPosition(LayoutManager layoutManager, int velocityX,
            int velocityY);

    /**
     * Override this method to snap to a particular point within the target view or the container
     * view on any axis.
     * <p>
     * This method is called when the {@link SnapHelper} has intercepted a fling and it needs
     * to know the exact distance required to scroll by in order to snap to the target view.
     *
     * @param layoutManager the {@link RecyclerView.LayoutManager} associated with the attached
     *                      {@link RecyclerView}
     * @param targetView the target view that is chosen as the view to snap
     *
     * @return the output coordinates the put the result into. out[0] is the distance
     * on horizontal axis and out[1] is the distance on vertical axis.
     */
    @SuppressWarnings("WeakerAccess")
    @Nullable
    public abstract int[] calculateDistanceToFinalSnap(@NonNull LayoutManager layoutManager,
            @NonNull View targetView);

    /**
     * Override this method to provide a particular target view for snapping.
     * <p>
     * This method is called when the {@link SnapHelper} is ready to start snapping and requires
     * a target view to snap to. It will be explicitly called when the scroll state becomes idle
     * after a scroll. It will also be called when the {@link SnapHelper} is preparing to snap
     * after a fling and requires a reference view from the current set of child views.
     * <p>
     * If this method returns {@code null}, SnapHelper will not snap to any view.
     *
     * @param layoutManager the {@link RecyclerView.LayoutManager} associated with the attached
     *                      {@link RecyclerView}
     *
     * @return the target view to which to snap on fling or end of scroll
     */
    @SuppressWarnings("WeakerAccess")
    @Nullable
    public abstract View findSnapView(LayoutManager layoutManager);
複製代碼

上面三個方法就是咱們重寫SnapHelper須要實現的,很重要,簡單介紹下它們的做用和調用時機:

findTargetSnapPosition用來找到最終的目標位置,在fling操做剛觸發的時候會根據速度計算一個最終目標位置,而後開始fling操做 calculateDistanceToFinalSnap 這個用來計算滑動到最終位置還須要滑動的距離,在一開始attachToRecyclerView或者targetView layout的時候會調用 findSnapView用來找到上面的targetView,就是須要對其的view,在calculateDistanceToFinalSnap調用以前會調用該方法。

咱們看下SnapHelper怎麼用的,其實就一行代碼:

this.snapHelper.attachToRecyclerView(view);
複製代碼

SnapHelper正是經過該方法附着到RecyclerView上,從而實現輔助RecyclerView滾動對齊操做,那咱們就從上面的attachToRecyclerView開始入手:

public void attachToRecyclerView(@Nullable RecyclerView recyclerView)
            throws IllegalStateException {
        if (mRecyclerView == recyclerView) {
            return; // nothing to do
        }
        if (mRecyclerView != null) {
            destroyCallbacks();
        }
        mRecyclerView = recyclerView;
        if (mRecyclerView != null) {
            setupCallbacks();
            mGravityScroller = new Scroller(mRecyclerView.getContext(),
                    new DecelerateInterpolator());
            snapToTargetExistingView();
        }
    }
複製代碼

attachToRecyclerView()方法中會清掉SnapHelper以前保存的RecyclerView對象的回調(若是有的話),對新設置進來的RecyclerView對象設置回調,而後初始化一個Scroller對象,最後調用snapToTargetExistingView()方法對SnapView進行對齊調整。

snapToTargetExistingView()

該方法的做用是對SnapView進行滾動調整,以使得SnapView達到對齊效果。

看下源碼:

void snapToTargetExistingView() {
        if (mRecyclerView == null) {
            return;
        }
        LayoutManager layoutManager = mRecyclerView.getLayoutManager();
        if (layoutManager == null) {
            return;
        }
        View snapView = findSnapView(layoutManager);
        if (snapView == null) {
            return;
        }
        int[] snapDistance = calculateDistanceToFinalSnap(layoutManager, snapView);
        if (snapDistance[0] != 0 || snapDistance[1] != 0) {
            mRecyclerView.smoothScrollBy(snapDistance[0], snapDistance[1]);
        }
    }
複製代碼

snapToTargetExistingView()方法就是先找到SnapView,而後計算SnapView當前座標到目的座標之間的距離,而後調用RecyclerView.smoothScrollBy()方法實現對RecyclerView內容的平滑滾動,從而將SnapView移到目標位置,達到對齊效果。

其實這個時候RecyclerView還沒進行layout,通常findSnapView會返回null,不須要對齊。

回調

SnapHelper要有對齊功能,確定須要知道RecyclerView的滾動scroll和fling過程的,這個就是經過回調接口實現。再看下attachToRecyclerView的源碼:

public void attachToRecyclerView(@Nullable RecyclerView recyclerView)
            throws IllegalStateException {
        if (mRecyclerView == recyclerView) {
            return; // nothing to do
        }
        if (mRecyclerView != null) {
            destroyCallbacks();
        }
        mRecyclerView = recyclerView;
        if (mRecyclerView != null) {
            setupCallbacks();
            mGravityScroller = new Scroller(mRecyclerView.getContext(),
                    new DecelerateInterpolator());
            snapToTargetExistingView();
        }
    }
複製代碼

一開始會先清空以前的回調接口而後再註冊接口,先看下destroyCallbacks:

/**
     * Called when the instance of a {@link RecyclerView} is detached.
     */
    private void destroyCallbacks() {
        mRecyclerView.removeOnScrollListener(mScrollListener);
        mRecyclerView.setOnFlingListener(null);
    }
複製代碼

能夠看出SnapHelperRecyclerView設置了兩個回調,一個是OnScrollListener對象mScrollListener,另一個就是OnFlingListener對象。

再看下setupCallbacks:

/**
     * Called when an instance of a {@link RecyclerView} is attached.
     */
    private void setupCallbacks() throws IllegalStateException {
        if (mRecyclerView.getOnFlingListener() != null) {
            throw new IllegalStateException("An instance of OnFlingListener already set.");
        }
        mRecyclerView.addOnScrollListener(mScrollListener);
        mRecyclerView.setOnFlingListener(this);
    }
複製代碼

SnapHelper實現了RecyclerView.OnFlingListener接口,因此OnFlingListener就是SnapHelper自身。

先來看下RecyclerView.OnScrollListener對象mScrollListener

RecyclerView.OnScrollListener

先看下mScrollListener是怎麼實現的:

private final RecyclerView.OnScrollListener mScrollListener =
            new RecyclerView.OnScrollListener() {
                boolean mScrolled = false;

                @Override
                public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
                    super.onScrollStateChanged(recyclerView, newState);
                    if (newState == RecyclerView.SCROLL_STATE_IDLE && mScrolled) {
                        mScrolled = false;
                        snapToTargetExistingView();
                    }
                }

                @Override
                public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
                    if (dx != 0 || dy != 0) {
                        mScrolled = true;
                    }
                }
            };
複製代碼

mScrolled = true表示以前滾動過,RecyclerView.SCROLL_STATE_IDLE表示滾動中止,這個不清楚的能夠看考以前的博客RecyclerView之Scroll和Fling。這個監聽器的實現其實很簡單,就是在滾動中止的時候調用snapToTargetExistingView對目標View進行滾動調整對齊。

RecyclerView.OnFlingListener

RecyclerView.OnFlingListener接口只有一個方法,這個就是在Fling操做觸發的時候會回調,返回true就是已處理,返回false就會交給系統處理。

/**
     * This class defines the behavior of fling if the developer wishes to handle it.
     * <p>
     * Subclasses of {@link OnFlingListener} can be used to implement custom fling behavior.
     *
     * @see #setOnFlingListener(OnFlingListener)
     */
    public abstract static class OnFlingListener {

        /**
         * Override this to handle a fling given the velocities in both x and y directions.
         * Note that this method will only be called if the associated {@link LayoutManager}
         * supports scrolling and the fling is not handled by nested scrolls first.
         *
         * @param velocityX the fling velocity on the X axis
         * @param velocityY the fling velocity on the Y axis
         *
         * @return true if the fling was handled, false otherwise.
         */
        public abstract boolean onFling(int velocityX, int velocityY);
    }
複製代碼

看下SnapHelper怎麼實現onFling()方法:

@Override
    public boolean onFling(int velocityX, int velocityY) {
        LayoutManager layoutManager = mRecyclerView.getLayoutManager();
        if (layoutManager == null) {
            return false;
        }
        RecyclerView.Adapter adapter = mRecyclerView.getAdapter();
        if (adapter == null) {
            return false;
        }
        int minFlingVelocity = mRecyclerView.getMinFlingVelocity();
        return (Math.abs(velocityY) > minFlingVelocity || Math.abs(velocityX) > minFlingVelocity)
                && snapFromFling(layoutManager, velocityX, velocityY);
    }
複製代碼

首先會獲取mRecyclerView.getMinFlingVelocity()須要進行fling操做的最小速率,只有超過該速率,Item才能在手指離開的時候進行Fling操做。 關鍵就是調用snapFromFling方法實現平滑滾動。

snapFromFling

看下怎麼實現的:

private boolean snapFromFling(@NonNull LayoutManager layoutManager, int velocityX,
            int velocityY) {
        if (!(layoutManager instanceof ScrollVectorProvider)) {
            return false;
        }

        SmoothScroller smoothScroller = createScroller(layoutManager);
        if (smoothScroller == null) {
            return false;
        }

        int targetPosition = findTargetSnapPosition(layoutManager, velocityX, velocityY);
        if (targetPosition == RecyclerView.NO_POSITION) {
            return false;
        }

        smoothScroller.setTargetPosition(targetPosition);
        layoutManager.startSmoothScroll(smoothScroller);
        return true;
    }
複製代碼
  1. 首先判斷是否是實現了ScrollVectorProvider接口,系統提供的Layoutmanager默認都實現了該接口
  2. 建立SmoothScroller對象,默認是LinearSmoothScroller對象,會用LinearInterpolator進行平滑滾動,在目標位置成爲Recyclerview的子View時會用DecelerateInterpolator進行減速中止。
  3. 經過findTargetSnapPosition()方法,以layoutManager和速率做爲參數,找到targetSnapPosition,這個方法就是自定義SnapHelper須要實現的。
  4. 把targetSnapPosition設置給平滑滾動器,而後開始進行滾動操做。

很明顯重點就是要看下平滑滾動器了。

LinearSmoothScroller

看下系統怎麼實現:

@Nullable
    protected LinearSmoothScroller createSnapScroller(LayoutManager layoutManager) {
        if (!(layoutManager instanceof ScrollVectorProvider)) {
            return null;
        }
        return new LinearSmoothScroller(mRecyclerView.getContext()) {
            @Override
            protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
                int[] snapDistances = calculateDistanceToFinalSnap(mRecyclerView.getLayoutManager(),
                        targetView);
                final int dx = snapDistances[0];
                final int dy = snapDistances[1];
                final int time = calculateTimeForDeceleration(Math.max(Math.abs(dx), Math.abs(dy)));
                if (time > 0) {
                    action.update(dx, dy, time, mDecelerateInterpolator);
                }
            }

            @Override
            protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
                return MILLISECONDS_PER_INCH / displayMetrics.densityDpi;
            }
        };
    }
複製代碼

在經過findTargetSnapPosition()方法找到的targetSnapPosition成爲Recyclerview的子View時(根據Recyclerview的緩存機制,這個時候可能該View在屏幕上還看不到),會回調onTargetFound,看下系統定義:

/**
         * Called when the target position is laid out. This is the last callback SmoothScroller
         * will receive and it should update the provided {@link Action} to define the scroll
         * details towards the target view.
         * @param targetView    The view element which render the target position.
         * @param state         Transient state of RecyclerView
         * @param action        Action instance that you should update to define final scroll action
         *                      towards the targetView
         */
        protected abstract void onTargetFound(View targetView, State state, Action action);
複製代碼

傳入的第一個參數targetView就是咱們但願滾動到的位置對應的View,最後一個參數就是咱們能夠用來通知滾動器要減速滾動的距離。

其實就是咱們要在這個方法裏面告訴滾動器在目標子View layout出來後還須要滾動多少距離, 而後經過Action通知滾動器。

第二個方法是計算滾動速率,返回值會影響onTargetFound中的calculateTimeForDeceleration方法,看下源碼:

private final float MILLISECONDS_PER_PX;
    public LinearSmoothScroller(Context context) {
        MILLISECONDS_PER_PX = calculateSpeedPerPixel(context.getResources().getDisplayMetrics());
    }

    /**
     * Calculates the time it should take to scroll the given distance (in pixels)
     *
     * @param dx Distance in pixels that we want to scroll
     * @return Time in milliseconds
     * @see #calculateSpeedPerPixel(android.util.DisplayMetrics)
     */
    protected int calculateTimeForScrolling(int dx) {
        // In a case where dx is very small, rounding may return 0 although dx > 0.
        // To avoid that issue, ceil the result so that if dx > 0, we'll always return positive // time. return (int) Math.ceil(Math.abs(dx) * MILLISECONDS_PER_PX); } /** * <p>Calculates the time for deceleration so that transition from LinearInterpolator to * DecelerateInterpolator looks smooth.</p> * * @param dx Distance to scroll * @return Time for DecelerateInterpolator to smoothly traverse the distance when transitioning * from LinearInterpolation */ protected int calculateTimeForDeceleration(int dx) { // we want to cover same area with the linear interpolator for the first 10% of the // interpolation. After that, deceleration will take control. // area under curve (1-(1-x)^2) can be calculated as (1 - x/3) * x * x // which gives 0.100028 when x = .3356 // this is why we divide linear scrolling time with .3356 return (int) Math.ceil(calculateTimeForScrolling(dx) / .3356); } 複製代碼

能夠看到,第二個方法返回值越大,須要滾動的時間越長,也就是滾動越慢。

3.總結

到這裏,SnapHelper的源碼就分析完了,整理下思路,SnapHelper輔助RecyclerView實現滾動對齊就是經過給RecyclerView設置OnScrollerListenerOnFlingListener這兩個監聽器實現的。 整個過程以下:

  1. onFling操做觸發的時候首先經過findTargetSnapPosition找到最終須要滾動到的位置,而後啓動平滑滾動器滾動到指定位置,
  2. 在指定位置須要渲染的View -targetView layout出來後,系統會回調onTargetFound,而後調用calculateDistanceToFinalSnap方法計算targetView須要減速滾動的距離,而後經過Action更新給滾動器。
  3. 在滾動中止的時候,也就是state變成SCROLL_STATE_IDLE時會調用snapToTargetExistingView,經過findSnapView找到SnapView,而後經過calculateDistanceToFinalSnap計算獲得滾動的距離,作最後的對齊調整。

前面分享的Demo就留到下一篇博客再說了,其實只要理解了SnapHelper的源碼,自定義就很簡單了。

對Demo感興趣的歡迎關注下一篇博客了。

完。

相關文章
相關標籤/搜索