RecyclerView扩展(六) - RecyclerView平滑滑动的实现原理

  时隔一年多,我又来更新RecyclerView相关的文章,感觉上一篇RecyclerView相关文章的完成就在昨天(手动狗头)。今天,我们来学习一下RecyclerView内部的smoothScroll相关方法的原理。
  在这之前,我先说一下这篇文章的背景。最近在做一个RecyclerView相关的需求,用到了平滑滑动相关的方法,在开发中发现了,Google爸爸提供的api不能满足我们的要求。于是我就想到了,去看一下相关的源码,然后自己实现。自以为是的认为对RecyclerView的源码比较了解,但是当自己真正看源码的时候,才发现自己想的太天真了,平滑滑动的原理远远没有那么的简单。最后在公司一位大佬的指点下,实现了想要的效果。在实现了效果之后,心中对这一块的原理充满了兴趣,毕竟之前在系统性学习RecyclerView源码,对这部分的知识一直是忽略的。所以,本文就由此产生了。
  注意,本文RecycclerView相关源码均来自于1.2.0-alpha03版本。

1. 概述

  在分析源码之前,我们先来看看RecyclerView平滑滑动的相关API吧。从功能上区分,RecyclerView相关的API主要分为两部分:smoothScrollBysmoothScrollToPosition。其中,smoothScrollBy方法滑动指定的距离,smoothScrollToPosition表示滑动到指定位置的ItemView。
  我们可以先从宏观上思考这两个方法的实现。smoothScrollBy方法很简单,因为知道了滑动的距离,那么使用OverScroller实现即可;那么smoothScrollToPosition方法是怎么实现的呢?我们都知道,我们想要滑动到的位置上的ItemView有可能还没有加到RecyclerView,那么RecyclerView是怎么知道滑动多少距离呢?这是本文需要分析的一个问题。
  同时,我们知道,在RecyclerView的LinearLayoutManager中,有一个scrollToPositionWithOffset方法,但是没有一个smoothScrollToPositionWithOffset方法。换句话说,如果我们想要一个平滑滑动到某一个位置之后再多滑一点距离,通过现在的接口是不能实现的。本文会通过分析SmoothScroller类,进而实现一个类似的接口方法。

2. smoothScrollBy方法的实现原理

  在分析smoothScrollBy方法之前,我先解释一下为啥先分析它。因为smoothScrollToPosition方法在滑动时,最后也是通过该方法实现的,所以,我们理解了smoothScrollBy的实现之后,对smoothScrollToPosition方法的理解就有一大半了。
  我们先来看一下smoothScrollBy方法的实现:

    void smoothScrollBy(@Px int dx, @Px int dy, @Nullable Interpolator interpolator,
            int duration, boolean withNestedScrolling) {
        // ······
        if (!mLayout.canScrollHorizontally()) {
            dx = 0;
        }
        if (!mLayout.canScrollVertically()) {
            dy = 0;
        }
        if (dx != 0 || dy != 0) {
            boolean durationSuggestsAnimation = duration == UNDEFINED_DURATION || duration > 0;
            if (durationSuggestsAnimation) {
                if (withNestedScrolling) {
                    int nestedScrollAxis = ViewCompat.SCROLL_AXIS_NONE;
                    if (dx != 0) {
                        nestedScrollAxis |= ViewCompat.SCROLL_AXIS_HORIZONTAL;
                    }
                    if (dy != 0) {
                        nestedScrollAxis |= ViewCompat.SCROLL_AXIS_VERTICAL;
                    }
                    startNestedScroll(nestedScrollAxis, TYPE_NON_TOUCH);
                }
                mViewFlinger.smoothScrollBy(dx, dy, duration, interpolator);
            } else {
                scrollBy(dx, dy);
            }
        }
    }

  smoothScrollBy的代码很简单,滑动最终走到了ViewFlinger的smoothScrollBy方法。我们再来看看ViewFlinger的smoothScrollBy方法:

        public void smoothScrollBy(int dx, int dy, int duration,
                @Nullable Interpolator interpolator) {

            // Handle cases where parameter values aren't defined.
            if (duration == UNDEFINED_DURATION) {
                duration = computeScrollDuration(dx, dy, 0, 0);
            }
            if (interpolator == null) {
                interpolator = sQuinticInterpolator;
            }

            // If the Interpolator has changed, create a new OverScroller with the new
            // interpolator.
            if (mInterpolator != interpolator) {
                mInterpolator = interpolator;
                mOverScroller = new OverScroller(getContext(), interpolator);
            }

            // Reset the last fling information.
            mLastFlingX = mLastFlingY = 0;

            // Set to settling state and start scrolling.
            setScrollState(SCROLL_STATE_SETTLING);
            mOverScroller.startScroll(0, 0, dx, dy, duration);
            // ······
            postOnAnimation();
        }

  别看smoothScrollBy方法有这么多的代码,其实做的都是一件事,初始化各种信息,包括滑动距离、滑动时间和滑动的插值器等。触发滑动的是通过调用postOnAnimation方法的,而postOnAnimation方法本身没有做什么事,就是任务队列中增加一个Runnable,保证下一次绘制会执行。那么下一次绘制会执行那个方法呢?别忘了ViewFlinger本身一个是Runnable,所以执行的肯定是它的run方法。
  我们来简单的看一下run方法吧,为啥说简单看一下run方法,因为run方法本身比较复杂,涉及的方面有很多,本文就不深入的探讨,有兴趣的可以看看:RecyclerView 源码分析(二) - RecyclerView的滑动机制。两年前的文章,大家将就看吧...(androidX对RecyclerView滑动的实现改动挺大的)。

        public void run() {
            // ······
            final OverScroller scroller = mOverScroller;
            //1. 判断是否需要滑动
            if (scroller.computeScrollOffset()) {
                 // 2. 处理滑动
                 // ······
                // 3.判断是否是否结束
                if (!smoothScrollerPending && doneScrolling) {
                   // ······
                } else {
                    // Otherwise continue the scroll. 
                    postOnAnimation();
                    // ······
            }
            // ······
        }

  总的来说,run方法实现平滑滑动的过程,我将它分为3步:

  1. 首先通过调用OvserScroller的computeScrollOffset方法来判断还有可以滑动的距离。如果可以滑动的距离,那么computeScrollOffset方法返回的true,此时我们可以通过getCurrX方法或者getCurrY方法获取最新的滑动位置。
  2. 处理滑动。RecyclerView在处理滑动比较复杂时,这里面包括对嵌套滑动的分发,以及对LayoutManger的回调实现自己的滑动,还包括我们后面要说的SmoothScroller也是在这里被回调的。这里先不对这部分的代码做过多的谈论,后面在分析SmoothScroller时,会分析其中一部分。说句题外话,这部分的代码时RecyclerView对滑动处理的核心代码,有兴趣的同学可以看看。
  3. 判断是否滑动结束。这里的滑动结束包含多种含义,我们可以将它分为两部分:正常结束和非正常结束。其中,正常结束表示的意思是,平滑滑动或者fling滑动自然的结束,即滑动速度为0;非正常滑动结束表示的意思是,RecyclerView不能再滑动了,被强制停止了,比如说RecyclerView滑动到底部或者顶部,但是滑动速度不为0。如果滑动没有结束,那就正常的执行,继续调用postOnAnimation方法,触发下一次滑动。

  可有人会有疑问,为啥调用postOnAnimation方法会触发下一次滑动呢?这个就得说说OverScroller的原理。我简单的解释一下OvserScroller吧。

其实OvserScroller本身不参与滑动的任何操作,它对外就有一个作用--产生滑动距离。这个怎么理解呢?比如说,如果我们想要在1s内从0滑动到100,那么OvserScroller就要在这1s内产生具体的滑动距离。是不是感觉这个跟属性滑动中的ValueAnimator很相似?但是它们俩有一个不同:ValueAnimator是主动产生的所有数值,就是说我们调用了start方法之后,ValueAnimator就开始为我们产生一系列的数值;而OvserScroller是被动产生数值的,它什么时候产生数值,取决于我们什么时候去调用computeScrollOffset方法,这个computeScrollOffset方法就是用来更新和产生数值的,而OvserScroller的start方法就只做了一件事:记录信息。这也是为啥,我们需要递归的调用computeScrollOffset原因。

  如上便是smoothScrollBy方法的实现原理,是不是很简单?接下来,我们将迎来本文的主角--smoothScrollToPosition方法。

3. smoothScrollToPosition方法

  在分析smoothScrollToPosition方法之前,我先提一个问题:我们都知道smoothScrollToPosition方法是指滑动到指定的位置,那么RecyclerView怎么知道已经滑动到这个View呢?换句话说,RecyclerView怎么知道要滑动多少距离呢?我们都知道,如果ItemView不在屏幕中,我们是不知道它的位置的。
  有人可能会回答,那还不简单,通过如上的递归方式滑动,每次滑动之后都判断指定位置的ItemView是否已经出现在屏幕中,如果已经在屏幕中,表示已经滑动到目的地了,可以停止滑动了。是的,简单来说RecyclerView就是这么实现的!但是大家使用smoothScrollToPosition方法之后会知道一个特性,就是将要滑动目的地时,RecyclerView会减速,上面的方式好像不行,所以RecyclerView是怎么实现这个效果呢?这是接下来的内容要解答的问题之一。我汇总一下,我们需要知道答案的问题:

  1. RecyclerView是怎么通过递归方式滑动到指定位置的?
  2. RecyclerView是怎么知道什么时候可以开始减速的?

(1). 开始滑动

  好了,废话扯的差不多了,接下来我们就从源码上寻找我们想要的答案吧。首先来看一下smoothScrollToPosition方法的源码:

    public void smoothScrollToPosition(int position) {
        if (mLayoutSuppressed) {
            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);
    }

  RecyclerView的smoothScrollToPosition方法很简单,直接调用了LayoutManager的smoothScrollToPosition方法,这里我们就看一下LinearLayoutManagersmoothScrollToPosition吧(其实StaggeredGridLayoutManagerLinearLayoutManager的实现是一样的)。

    @Override
    public void smoothScrollToPosition(RecyclerView recyclerView, RecyclerView.State state,
            int position) {
        LinearSmoothScroller linearSmoothScroller =
                new LinearSmoothScroller(recyclerView.getContext());
        linearSmoothScroller.setTargetPosition(position);
        startSmoothScroll(linearSmoothScroller);
    }

  smoothScrollToPosition方法主要做的事是,创建一个LinearSmoothScroller对象,然后调用了startSmoothScroll方法。看上去好像并没有做什么事,其实不然,这里创建的LinearSmoothScroller对象非常的重要,smoothScrollToPosition的实现全靠这个类来实现的;同时在创建对象的时候,我们可以看到通过调用setTargetPosition设置目标的位置,这一点也非常的重要。我们再来看看startSmoothScroll方法:

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

  startSmoothScroll方法一共做了三件事:

  1. 如果之前已经在滑动了,会将它停止。
  2. 将新的SmoothScroller对象赋值给mSmoothScroller。大家要记得这一步操作,因为后面的内容我们经常看见它。
  3. 调用start方法。这个方法的作用就是触发滑动。

  我们看一下start方法的实现:

        void start(RecyclerView recyclerView, LayoutManager layoutManager) {

            // Stop any previous ViewFlinger animations now because we are about to start a new one.
            recyclerView.mViewFlinger.stop();

            if (mStarted) {
                Log.w(TAG, "An instance of " + this.getClass().getSimpleName() + " was started "
                        + "more than once. Each instance of" + this.getClass().getSimpleName() + " "
                        + "is intended to only be used once. You should create a new instance for "
                        + "each use.");
            }

            mRecyclerView = recyclerView;
            mLayoutManager = layoutManager;
            if (mTargetPosition == RecyclerView.NO_POSITION) {
                throw new IllegalArgumentException("Invalid target position");
            }
            mRecyclerView.mState.mTargetPosition = mTargetPosition;
            mRunning = true;
            mPendingInitialRun = true;
            mTargetView = findViewByPosition(getTargetPosition());
            onStart();
            mRecyclerView.mViewFlinger.postOnAnimation();

            mStarted = true;
        }

  start方法的作用很简单,就是记录滑动需要的信息,其中包括设置mTargetPosition;将mPendingInitialRun设置为true;寻找mTargetView,这个点也非常的重要,如果此时距离TargetView还非常的远,这里返回的就是null,如果不为null,那么就表示即将滑动到TargetView。这个为null或者不为null是非常的重要,这个决定后面应该怎么滑动(决定是继续快速滑动还是减速滑动)。
  最后,就是调用ViewFlinger的postOnAnimation方法开始滑动。看到这里,我们不禁有一个疑问了,这里我们并不知道需要滑动的距离,咋就开始滑动了呢?针对这个疑问,我们去ViewFlinger的run方法中去寻找答案:

        @Override
        public void run() {
            // ······
            final OverScroller scroller = mOverScroller;
            if (scroller.computeScrollOffset()) {
               // ······
            }

            SmoothScroller smoothScroller = mLayout.mSmoothScroller;
            // call this after the onAnimation is complete not to have inconsistent callbacks etc.
            if (smoothScroller != null && smoothScroller.isPendingInitialRun()) {
                smoothScroller.onAnimation(0, 0);
            }
            // ······
        }

  一般来说,当我们调用smoothScrollToPosition触发了run方法的执行时,computeScrollOffset方法都是返回为false(这里就不对特殊case做分析了),因为在这之前,我们没有调用OverScroller的start方法。那么是怎么触发滑动的呢?答案就在下面调用的SmoothScrolleronAnimation方法。从前面的分析,我们知道,我们通过调用smoothScrollToPosition方法,这里SmoothScroller肯定不为null,同时isPendingInitialRun方法肯定也为true,这个在前面已经特别说明了。所以,我们来看看onAnimation方法:

        void onAnimation(int dx, int dy) {
            // ······
            // The following if block exists to have the LayoutManager scroll 1 pixel in the correct
            // direction in order to cause the LayoutManager to draw two pages worth of views so
            // that the target view may be found before scrolling any further.  This is done to
            // prevent an initial scroll distance from scrolling past the view, which causes a
            // jittery looking animation.
            // 1. 先滑动1像素。
            if (mPendingInitialRun && mTargetView == null && mLayoutManager != null) {
                PointF pointF = computeScrollVectorForPosition(mTargetPosition);
                if (pointF != null && (pointF.x != 0 || pointF.y != 0)) {
                    recyclerView.scrollStep(
                            (int) Math.signum(pointF.x),
                            (int) Math.signum(pointF.y),
                            null);
                }
            }

            mPendingInitialRun = false;
            // 2. TargetView即将滑到
            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;
                }
            }
            // 3. TargetView还未滑到。
            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();
                    }
                }
            }
        }

  onAnimation方法里面主要分为三步,如上面的注释,我们分别看一下:

  1. 如果TargetView不为null,先滑动1像素。这样的做目的是处理一个特殊的case,假设我们屏幕中有5个ItemView,并且第5个ItemView的底部恰好跟RecyclerView底部对齐,此时如果我们想要滑动到第6个ItemView,能保证在下一次滑动中看到TargetView,从而执行下面的减速滑动(在实际情况中,RecyclerView是有预加载的,这里假设RecyclerView没有预加载,也就是假设RecyclerView的ItemView没有在屏幕中,是不会加载的,即TargetView为null)
  2. TargetView不为null,表示已经ItemView已经滑动到屏幕中,即将完整展示,此时就会开始减速滑动。从这里我们找到上面本小节前面提的两个问题中的第二个问题。这里还有一个小细节,就是调用stop方法,表示快速滑动的SmoothScroller对象已经停止滑动,这个对象就是我们在LinearLayoutManagersmoothScrollToPosition方法创建的对象。大家应该可以从我的描述中得到一些信息,没错,减速滑动是通过另一个SmoothScroller对象实现的,这里就会创建,只不过是在这里调用的方法里面创建的,并不是onAnimation方法里面。
  3. 如果当前的SmoothScroller还在继续滑动,就是执行另一部分的操作。这里之所以特指继续滑动,是因为上面在执行减速滑动时,会调用stop方法。所以,如果上面执行了减速滑动,这里就不会执行。

  这里我们先来看看第三步吧。上面解释了第3步会执行另一部分的操作,而这里说的另一部分的操作,是指的啥呢?我们主要看两个方法:onSeekTargetStep方法和runIfNecessary方法。
  我们先来看看onSeekTargetStep方法,这里以LinearSmoothScroller为例:

    protected void onSeekTargetStep(int dx, int dy, RecyclerView.State state, Action action) {
        // TODO(b/72745539): Is there ever a time when onSeekTargetStep should be called when
        // getChildCount returns 0?  Should this logic be extracted out of this method such that
        // this method is not called if getChildCount() returns 0?
        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

    }

  onSeekTargetStep方法的作用就是计算SmoothScroller还可以滑动多少距离,其中dy表示本次滑动消耗的距离,mInterimTargetDxmInterimTargetDy表示一共需要滑动的距离。因为我们这里是第一次调用onSeekTargetStep方法,也就是说dy为0,同时mInterimTargetDxmInterimTargetDy也为0。同时mInterimTargetDy如果为0,但是dy不为0,表示不是第一次调用,而是指滑动距离消耗完毕了。总的来说,第一次调用或者距离消耗完毕都会调用updateActionForInterimTarget方法。
  那么updateActionForInterimTarget方法里面做了啥事呢?我们来看看:

    protected void updateActionForInterimTarget(Action action) {
        // find an interim target position
        PointF scrollVector = computeScrollVectorForPosition(getTargetPosition());
        if (scrollVector == null || (scrollVector.x == 0 && scrollVector.y == 0)) {
            final int target = getTargetPosition();
            action.jumpTo(target);
            stop();
            return;
        }
        normalize(scrollVector);
        mTargetVector = scrollVector;

        mInterimTargetDx = (int) (TARGET_SEEK_SCROLL_DISTANCE_PX * scrollVector.x);
        mInterimTargetDy = (int) (TARGET_SEEK_SCROLL_DISTANCE_PX * scrollVector.y);
        final int time = calculateTimeForScrolling(TARGET_SEEK_SCROLL_DISTANCE_PX);
        // To avoid UI hiccups, trigger a smooth scroll to a distance little further than the
        // interim target. Since we track the distance travelled in onSeekTargetStep callback, it
        // won't actually scroll more than what we need.
        action.update((int) (mInterimTargetDx * TARGET_SEEK_EXTRA_SCROLL_RATIO),
                (int) (mInterimTargetDy * TARGET_SEEK_EXTRA_SCROLL_RATIO),
                (int) (time * TARGET_SEEK_EXTRA_SCROLL_RATIO), mLinearInterpolator);
    }

  updateActionForInterimTarget方法看上去挺复杂的,但是实际上就是做了两件事:

  1. 计算mInterimTargetDxmInterimTargetDy,以滑动时间的time。这两个变量,我们前面已经见过了,表示的是可以滑动的距离。同时需要注意的是,这俩的值是固定!!!要么为12000,要么为-12000,是不是挺有意思的?
  2. 同时将计算的值更新到Action里面。Action是SmoothScroller的内部类,主要的作用是记录SmoothScroller滑动需要的滑动距离(即Dx和Dy)、滑动时间(即time)、滑动插值器(即mInterpolator)。快速滑动和最后的减速滑动就是因为这个插值器不同导致的。这里更新Action信息的操作非常的重要。

  到这里,我们应该知道onSeekTargetStep方法干了什么事吧。我简单总结一下吧,onSeekTargetStep方法里面主要做了2件事:

  1. 更新mInterimTargetDxmInterimTargetDx,由于前面有可能滑动了一定的距离,所以这里需要更新,这样后面的滑动才知道还有多少距离。
  2. 当滑动距离消耗完了或者是第一次调用,会调用updateActionForInterimTarget方法,重新给出新的滑动距离,并且记录在Action里面。

  经过onSeekTargetStep方法之后,RecyclerView知道了新的滑动距离之后,此时就是调用ActionrunIfNecessary方法了。我们来看看这个方法:

            void runIfNecessary(RecyclerView recyclerView) {
                // ······
                if (mChanged) {
                    validate();
                    recyclerView.mViewFlinger.smoothScrollBy(mDx, mDy, mDuration, mInterpolator);
                    mConsecutiveUpdates++;
                    if (mConsecutiveUpdates > 10) {
                        // A new action is being set in every animation step. This looks like a bad
                        // implementation. Inform developer.
                        Log.e(TAG, "Smooth Scroll action is being updated too frequently. Make sure"
                                + " you are not changing it unless necessary");
                    }
                    mChanged = false;
                } else {
                    mConsecutiveUpdates = 0;
                }
            }

  runIfNecessary方法比较简单,就是先看看Action的信息是否被更新过,如果更新过,就调用smoothScrollBy方法触发滑动;如果没有被更新过,那么什么都不做。在这里,我多说几句:

  1. 如果mChanged为true,即Action的信息被更新表示两种情况:1. 这是第一次滑动;2.前面的滑动已经完成了,这里会触发一次新的滑动。mChanged设置为true,这个在前面我们已经介绍了,就是在Action的update方法中操作的。需要的注意的是,这里的Dy就是滑动需要的距离,如果TargetView为null的话,mDx和mDy就是为12000或者-12000;如果TargetView不为null,mDx和mDy就表示具体的距离。
  2. 如果mChanged不为true调用到这里的话,表示不需要重新触发滑动,这是为啥呢?如果mChanged不为true,表示当前的滑动还未结束,即还有可滑动的距离,此时ViewFlinger在执行run方法时,会自己调用postOnAnimation方法。这个在前面分析smoothScrollBy时,我们已经了解到了。

(2). 滑动中

  经过上面一小节,我们知道,如果才开始滑动的话,滑动距离是12000像素(这里就以正数为例)。那么接下来就是正常的滑动,正常的滑动就如上面分析smoothScrollBy一样,就是通过递归的方式从OverScroller里面获取最新的滑动位置,然后开始滑动。
  不过,这里还是跟之前的分析有不同的地方,我们来看看:

                if (mAdapter != null) {
                    // ······
                    // If SmoothScroller exists, this ViewFlinger was started by it, so we must
                    // report back to SmoothScroller.
                    SmoothScroller smoothScroller = mLayout.mSmoothScroller;
                    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);
                            smoothScroller.onAnimation(consumedX, consumedY);
                        } else {
                            smoothScroller.onAnimation(consumedX, consumedY);
                        }
                    }
                }

  如果我们通过smoothScrollToPosition方法触发了run方法的执行,那么在每次滑动执行之后,都会调用onAnimation方法,来告知SmoothScroller本次滑动了一部分的距离,进而SmoothScroller 会更新相关的信息,执行一些其他的操作,比如说滑动结束了,触发了新的滑动,或者TargetView滑动到屏幕中了,开始减速滑动。
  上面的点非常重要,SmoothScroller要随时知道滑动的状态,因为SmoothScroller可能随时改变滑动的策略。这个滑动策略改变主要从滑动结束说起,接下来我们就看看滑动结束的情况。

(3).滑动结束

  一般来说,每次onAnimation的调用都有可能表示滑动结束,那么怎么来区分它们呢?我们将滑动结束分为两类:

  1. 被动结束。前面已经说了,smoothScrollToPosition方法一次滑动12000像素,如果RecyclerView还没有到我们想要的位置呢?此时调用onAnimation方法时,SmoothScroller就会知道本次滑动的滑动距离已经消耗完毕了,然后产生新的滑动距离,也是12000像素,重新触发一次滑动。这个在前面分析 onSeekTargetStep方法已经说了,这里就不过多的分析了。这就是上面提的第一个问题答案。
  2. 主动结束。这种情况是ItemView已经滑动到屏幕中,此时调用onAnimation方法,SmoothScroller就会停止本次滑动,开始新的一次滑动,即减速滑动。需要注意的是,此时RecyclerView已经知道了具体的滑动距离,即不用调用onSeekTargetStep方法产生12000像素的距离。

  本小节就是重点分析主动结束的情况,也就是可以寻找到上面提的第二个问题的答案。我们直接来看看onAnimation方法:

        void onAnimation(int dx, int dy) {
            // ······
            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;
                }
            }
            // ······
        }

  在onAnimation方法中,主动结束主要做了三件事:

  1. 调用onTargetFound方法,表示当ItemView即将滑到屏幕中。同时从LinearSmoothScrolleronTargetFound方法的实现,我们知道它内部实际上对Action进行了更新,即更新可以滑动距离,滑动需要的时间,以及滑动需要的插值器(减速的插值器)。
  2. 调用runIfNecessary方法触发一个新的滑动。从这里,我们可以对onAnimation方法对runIfNecessary方法做一个简单的总结,就是在调用runIfNecessary方法,都需要对Action内部的信息进行更新,只不过这里是调用onTargetFound方法,正常滑动时调用onSeekTargetStep方法。
  3. 调用stop方法,表示当前快速滑动已经结束。这里的调用能避免onAnimation方法下面的操作执行。

  我们来看看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);
        }
    }

  onTargetFound方法主要做了3件事:

  1. 调用calculateDxToMakeVisible方法,计算可以滑动的距离,即滑动到目标ItemView需要的距离。在calculateDxToMakeVisible内部调用calculateDtToFit方法真正返回滑动所需的距离。关于calculateDtToFit方法,后面自定义实现smoothScrollToPositionWithOffset方法是会使用到,这里就不过多的讨论了。
  2. 调用calculateTimeForDeceleration方法,计算减速滑动需要的时间。
  3. 调用Action的updte方法,更新相关的信息。在这里,我们传递了一个DecelerateInterpolator对象,这个就是减速使用的插值器。

  至此,我们就知道,RecyclerView在不知道滑动距离的情况下,是怎么通过smoothScrollToPosition方法滑动到具体的ItemView。待会,我会做一个简单的总结,在这里,我们先学以致用,实现一个smoothScrollToPositionWithOffset方法。

4. 实现smoothScrollToPositionWithOffset方法

  我们知道,不管是RecyclerView还是LayoutManger,都没有这个方法供我们使用,那么如果我们有这个要求,自己怎么实现呢?其实很简单的,我们直接上代码:

    fun smoothScrollToPositionWithOffset(position: Int, offset: Int) {
        layoutManager?.let {
            val smoothScroller = object : LinearSmoothScroller(context) {
                override fun calculateDtToFit(
                    viewStart: Int,
                    viewEnd: Int,
                    boxStart: Int,
                    boxEnd: Int,
                    snapPreference: Int
                ): Int {
                    val rawOffset =
                        super.calculateDtToFit(viewStart, viewEnd, boxStart, boxEnd, snapPreference)
                    return rawOffset - offset;
                }
            }
            smoothScroller.targetPosition = position
            it.startSmoothScroll(smoothScroller)
        }
    }

  其实,实现的本质就是通过重写LinearSmoothScrollercalculateDtToFit方法,我们在前面已经知道了,calculateDtToFit方法就是计算滑动到TargetView还需要多少的距离。我们的实现就是在它的基础加上我们想要的offset就行了,是不是很简单?
  同时SmoothScroller还是很多其他的方法,我们可以自定义或者重写,实现我们想要的效果。不得不说,RecyclerView这一块的扩展太大了!!!

5. 总结

  到这里,本文就结束了,我在这里对本文的内容做一个简单的总结。

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