RecyclerView之SnapHelper源碼分析

很久沒有寫Android控件了几缭,正好最近項目有個自定義控件的需求茶行,整理了下做個總結(jié),主要是實現(xiàn)類似于抖音翻頁的效果气堕,但是有有點不同纺腊,需要在底部漏出后面的view,這樣說可能不好理解茎芭,看下Demo揖膜,按頁滑動,后面的View有放大縮放的動畫梅桩,滑動速度過小時會有回到原位的效果壹粟,下滑也是按頁滑動的效果。

record.gif

有的小伙伴可能說這個用 SnapHelper就可以了宿百,沒錯趁仙,翻頁是要結(jié)合這個,但是也不是純粹靠這個垦页,因為底部需要漏出來后面的view雀费,所以LayoutManager就不能簡單的使用LinearLayoutManager,需要去自定義LayoutManager,然后再自定義SnapHelper

如果把自定義LayoutManagerSnapHelper放在一篇里面會太長痊焊,所以我們今天主要分析SnapHelper盏袄。

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

1.ScrollFling

這方面參考我的上篇分享:RecyclerView之Scroll和Fling

總結(jié)一下調(diào)用棧就是:

SnapHelper
onFling ---> snapFromFling 

上面得到最終位置targetPosition,把位置給RecyclerView.SmoothScroller, 然后就開始滑動了:

RecyclerView.SmoothScroller
start --> onAnimation

在滑動過程中如果targetPosition對應的targetView已經(jīng)layout出來了忿峻,就會回調(diào)SnapHelper,然后計算得到到當前位置到targetView的距離dx,dy

SnapHelper
onTargetFound ---> calculateDistanceToFinalSnap

然后把距離dx,dy更新給RecyclerView.Action:

RecyclerView.Action
update --> runIfNecessary --> recyclerView.mViewFlinger.smoothScrollBy

最后調(diào)用RecyclerView.ViewFlinger, 然后又回到onAnimation

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源碼分析

上面其實已經(jīng)接觸到部分的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需要實現(xiàn)的逛尚,很重要,簡單介紹下它們的作用和調(diào)用時機:

findTargetSnapPosition用來找到最終的目標位置刁愿,在fling操作剛觸發(fā)的時候會根據(jù)速度計算一個最終目標位置绰寞,然后開始fling操作
calculateDistanceToFinalSnap 這個用來計算滑動到最終位置還需要滑動的距離,在一開始attachToRecyclerView或者targetView layout的時候會調(diào)用
findSnapView用來找到上面的targetView铣口,就是需要對其的view克握,在calculateDistanceToFinalSnap調(diào)用之前會調(diào)用該方法。

我們看下SnapHelper怎么用的枷踏,其實就一行代碼:

this.snapHelper.attachToRecyclerView(view);

SnapHelper正是通過該方法附著到RecyclerView上菩暗,從而實現(xiàn)輔助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對象的回調(diào)(如果有的話),對新設置進來的RecyclerView對象設置回調(diào),然后初始化一個Scroller對象,最后調(diào)用snapToTargetExistingView()方法對SnapView進行對齊調(diào)整旭蠕。

snapToTargetExistingView()

該方法的作用是對SnapView進行滾動調(diào)整停团,以使得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當前坐標到目的坐標之間的距離佑稠,然后調(diào)用RecyclerView.smoothScrollBy()方法實現(xiàn)對RecyclerView內(nèi)容的平滑滾動,從而將SnapView移到目標位置旗芬,達到對齊效果舌胶。

其實這個時候RecyclerView還沒進行l(wèi)ayout,一般findSnapView會返回null疮丛,不需要對齊幔嫂。

回調(diào)

SnapHelper要有對齊功能,肯定需要知道RecyclerView的滾動scroll和fling過程的誊薄,這個就是通過回調(diào)接口實現(xiàn)履恩。再看下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();
        }
    }

一開始會先清空之前的回調(diào)接口然后再注冊接口,先看下destroyCallbacks:

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

可以看出SnapHelperRecyclerView設置了兩個回調(diào)呢蔫,一個是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實現(xiàn)了RecyclerView.OnFlingListener接口片吊,所以OnFlingListener就是SnapHelper自身绽昏。

先來看下RecyclerView.OnScrollListener對象mScrollListener

RecyclerView.OnScrollListener

先看下mScrollListener是怎么實現(xiàn)的:

    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全谤。這個監(jiān)聽器的實現(xiàn)其實很簡單,就是在滾動停止的時候調(diào)用snapToTargetExistingView對目標View進行滾動調(diào)整對齊联予。

RecyclerView.OnFlingListener

RecyclerView.OnFlingListener接口只有一個方法啼县,這個就是在Fling操作觸發(fā)的時候會回調(diào),返回true就是已處理沸久,返回false就會交給系統(tǒng)處理季眷。

    /**
     * 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怎么實現(xiàn)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操作子刮。
關鍵就是調(diào)用snapFromFling方法實現(xiàn)平滑滾動。

snapFromFling

看下怎么實現(xiàn)的:

    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. 首先判斷是不是實現(xiàn)了ScrollVectorProvider接口窑睁,系統(tǒng)提供的Layoutmanager默認都實現(xiàn)了該接口
  2. 創(chuàng)建SmoothScroller對象,默認是LinearSmoothScroller對象挺峡,會用LinearInterpolator進行平滑滾動,在目標位置成為Recyclerview的子View時會用DecelerateInterpolator進行減速停止担钮。
  3. 通過findTargetSnapPosition()方法橱赠,以layoutManager和速率作為參數(shù),找到targetSnapPosition,這個方法就是自定義SnapHelper需要實現(xiàn)的箫津。
  4. 把targetSnapPosition設置給平滑滾動器狭姨,然后開始進行滾動操作。

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

LinearSmoothScroller

看下系統(tǒng)怎么實現(xiàn):

    @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時(根據(jù)Recyclerview的緩存機制饼拍,這個時候可能該View在屏幕上還看不到),會回調(diào)onTargetFound,看下系統(tǒng)定義:

        /**
         * 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);

傳入的第一個參數(shù)targetView就是我們希望滾動到的位置對應的View田炭,最后一個參數(shù)就是我們可以用來通知滾動器要減速滾動的距離师抄。

其實就是我們要在這個方法里面告訴滾動器在目標子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.總結(jié)

到這里蛤铜,SnapHelper的源碼就分析完了,整理下思路丛肢,SnapHelper輔助RecyclerView實現(xiàn)滾動對齊就是通過給RecyclerView設置OnScrollerListenerOnFlingListener這兩個監(jiān)聽器實現(xiàn)的围肥。
整個過程如下:

  1. onFling操作觸發(fā)的時候首先通過findTargetSnapPosition找到最終需要滾動到的位置,然后啟動平滑滾動器滾動到指定位置蜂怎,
  2. 在指定位置需要渲染的View -targetView layout出來后穆刻,系統(tǒng)會回調(diào)onTargetFound,然后調(diào)用calculateDistanceToFinalSnap方法計算targetView需要減速滾動的距離,然后通過Action更新給滾動器杠步。
  3. 在滾動停止的時候氢伟,也就是state變成SCROLL_STATE_IDLE時會調(diào)用snapToTargetExistingView榜轿,通過findSnapView找到SnapView,然后通過calculateDistanceToFinalSnap計算得到滾動的距離朵锣,做最后的對齊調(diào)整谬盐。

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

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

完诬烹。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末砸烦,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子绞吁,更是在濱河造成了極大的恐慌幢痘,老刑警劉巖,帶你破解...
    沈念sama閱讀 221,820評論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件家破,死亡現(xiàn)場離奇詭異雪隧,居然都是意外死亡,警方通過查閱死者的電腦和手機员舵,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,648評論 3 399
  • 文/潘曉璐 我一進店門脑沿,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人马僻,你說我怎么就攤上這事庄拇。” “怎么了韭邓?”我有些...
    開封第一講書人閱讀 168,324評論 0 360
  • 文/不壞的土叔 我叫張陵措近,是天一觀的道長。 經(jīng)常有香客問我女淑,道長瞭郑,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 59,714評論 1 297
  • 正文 為了忘掉前任鸭你,我火速辦了婚禮屈张,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘袱巨。我一直安慰自己阁谆,他們只是感情好,可當我...
    茶點故事閱讀 68,724評論 6 397
  • 文/花漫 我一把揭開白布愉老。 她就那樣靜靜地躺著场绿,像睡著了一般。 火紅的嫁衣襯著肌膚如雪嫉入。 梳的紋絲不亂的頭發(fā)上焰盗,一...
    開封第一講書人閱讀 52,328評論 1 310
  • 那天璧尸,我揣著相機與錄音,去河邊找鬼熬拒。 笑死爷光,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的梦湘。 我是一名探鬼主播,決...
    沈念sama閱讀 40,897評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼件甥,長吁一口氣:“原來是場噩夢啊……” “哼捌议!你這毒婦竟也來了溉知?” 一聲冷哼從身側(cè)響起卒落,我...
    開封第一講書人閱讀 39,804評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎唬涧,沒想到半個月后譬正,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體宫补,經(jīng)...
    沈念sama閱讀 46,345評論 1 318
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,431評論 3 340
  • 正文 我和宋清朗相戀三年曾我,在試婚紗的時候發(fā)現(xiàn)自己被綠了粉怕。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,561評論 1 352
  • 序言:一個原本活蹦亂跳的男人離奇死亡抒巢,死狀恐怖贫贝,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情蛉谜,我是刑警寧澤稚晚,帶...
    沈念sama閱讀 36,238評論 5 350
  • 正文 年R本政府宣布,位于F島的核電站型诚,受9級特大地震影響客燕,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜狰贯,卻給世界環(huán)境...
    茶點故事閱讀 41,928評論 3 334
  • 文/蒙蒙 一也搓、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧涵紊,春花似錦还绘、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,417評論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至塘幅,卻和暖如春昔案,著一層夾襖步出監(jiān)牢的瞬間尿贫,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,528評論 1 272
  • 我被黑心中介騙來泰國打工踏揣, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留庆亡,地道東北人。 一個月前我還...
    沈念sama閱讀 48,983評論 3 376
  • 正文 我出身青樓捞稿,卻偏偏與公主長得像又谋,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子娱局,可洞房花燭夜當晚...
    茶點故事閱讀 45,573評論 2 359

推薦閱讀更多精彩內(nèi)容