RecyclerView之SnapHelper源码分析

很久没有写Android控件了,正好最近项目有个自定义控件的需求,整理了下做个总结,主要是实现类似于抖音翻页的效果,但是有有点不同,需要在底部漏出后面的view,这样说可能不好理解,看下Demo,按页滑动,后面的View有放大缩放的动画,滑动速度过小时会有回到原位的效果,下滑也是按页滑动的效果。

record.gif

有的小伙伴可能说这个用 SnapHelper就可以了,没错,翻页是要结合这个,但是也不是纯粹靠这个,因为底部需要漏出来后面的view,所以LayoutManager就不能简单的使用LinearLayoutManager,需要去自定义LayoutManager,然后再自定义SnapHelper

如果把自定义LayoutManagerSnapHelper放在一篇里面会太长,所以我们今天主要分析SnapHelper

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

1.ScrollFling

这方面参考我的上篇分享:RecyclerView之Scroll和Fling

总结一下调用栈就是:

SnapHelper
onFling ---> snapFromFling 

上面得到最终位置targetPosition,把位置给RecyclerView.SmoothScroller, 然后就开始滑动了:

RecyclerView.SmoothScroller
start --> onAnimation

在滑动过程中如果targetPosition对应的targetView已经layout出来了,就会回调SnapHelper,然后计算得到到当前位置到targetView的距离dx,dy

SnapHelper
onTargetFound ---> calculateDistanceToFinalSnap

然后把距离dx,dy更新给RecyclerView.Action:

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

最后调用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源码分析

上面其实已经接触到部分的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感兴趣的欢迎关注下一篇博客了。

完。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342

推荐阅读更多精彩内容