实用Android-CoordinatorLayout.……高级UI

Android - 有趣的嵌套滑动(Demo不定期更新)

2020-01-06  本文已影响0人  东方未曦

我的CSDN: ListerCi
我的简书: 东方未曦

写在前面

博客中的demo都上传到了github:NestedScrollTest,欢迎各位同学下载。

一、demo1-吸顶效果,及RecyclerView源码简析

吸顶效果是CoordinatorLayout中的一个基础功能,它的本质就是嵌套滑动,因此我们可以自己尝试去实现它。同时本章将会对RecyclerView源码中的嵌套滑动部分进行分析,深入理解嵌套滑动事件的分发与回调。

1.1 吸顶效果展示

效果展示.gif

1.2 嵌套滑动API介绍

上面所展示的界面是一个线性布局,如图所示:

布局文件.png

外部父LinearLayout包裹ImageView、TextView和RecyclerView,如果我们希望滑动RecyclerView的时候能先将ImageView滑动上去,我们该怎么做呢?
这就可以使用嵌套滑动,假设当前用户手指上滑RecyclerView,我们需要将RecyclerView的滑动事件先传递给父布局,如果父布局发现头部的ImageView还在显示,那么先消耗该事件并将整个父布局LinearLayout向上移动;如果图片已经上滑至消失,那么将滑动事件交给RecyclerView处理。
手指在RecyclerView上滑时如图所示,此时整个LinearLayout会向上滚动,直到TextView吸顶,再开始滑动RecyclerView。注意:RecyclerView的高度其实是界面的高度减去TexView的高度,比布局文件图中画的高度要高。

图片-移动示意.png

根据上面的流程不难发现,嵌套滑动由RecyclerView主动发起,父Layout被动接受,并且父Layout可以先于子View处理滑动事件。举个栗子,假设在一次事件中手指在RecyclerView向上滑动dy,那么大体的流程如下:
① RecyclerView判断是否有父Layout能接受嵌套滑动,如果有,则将事件传递给父Layout。
② 父Layout收到该滑动事件,此时父Layout判断当前图片是否还在展示,如果还在展示,则父Layout向上滑动。但是父Layout不一定会在每次事件中都将dy全部消耗掉(例如滑动到边缘的时候),这里通过一个值consumed来保存父Layout消耗的值,并计算出剩余的值dy-consumed
③ 如果dy-consumed不为0,则由RecyclerView自己处理。
④ 如果RecyclerView消耗完之后剩余的距离还不为0,则再交由父Layout处理。

想要实现嵌套滑动的子View需要实现NestedScrollingChild接口,里面包含的方法如下。

public interface NestedScrollingChild {
    // 设置当前子View是否支持嵌套滑动
    void setNestedScrollingEnabled(boolean enabled);

    // 当前子View是否支持嵌套滑动
    boolean isNestedScrollingEnabled();

    // 开始嵌套滑动,对应Parent的onStartNestedScroll
    boolean startNestedScroll(@ScrollAxis int axes);

    // 停止本次嵌套滑动,对应Parent的onStopNestedScroll
    void stopNestedScroll();

    // true表示这个子View有一个支持嵌套滑动的父View
    boolean hasNestedScrollingParent();

    // 通知父Layout即将开始滑动了,由父Layout先处理,对应父View的onNestedPreScroll方法
    boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
            @Nullable int[] offsetInWindow);

    // 子View处理完事件再交给父Layout,对应父Layout的onNestedScroll方法
    boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);

    // 通知父View开始Fling了,对应Parent的onNestedFling方法
    boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);

    // 通知父View要开始fling了,对应Parent的onNestedPreFling方法
    boolean dispatchNestedPreFling(float velocityX, float velocityY);
}

想要实现嵌套滑动的父Layout需要实现NestedScrollingParent接口,里面包含的方法如下。

public interface NestedScrollingParent {
    // 当子View开始滑动时调用,返回true表示接受嵌套滑动
    boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes);

    // 接受嵌套滑动后进行准备工作
    void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes);

    // 嵌套滑动结束时回调
    void onStopNestedScroll(@NonNull View target);

    // 父Layout先处理滑动距离dx或dy,consumed[0]保存父Layout在x轴上消耗的距离,consumed[1]保存父Layout在y轴上消耗的距离
    void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed);

    // 父Layout处理子View消耗完后剩余的距离
    void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed);

    // 当子View fling时,会触发这个回调,consumed代表速度是否被子View消耗
    boolean onNestedFling(@NonNull View target, float velocityX, float velocityY, boolean consumed);

    // 当子View要开始fling时,会先询问父View是否要拦截本次fling,返回true表示要拦截,那么子View就不会惯性滑动了
    boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY);

    // 表示目前正在进行的嵌套滑动的方向,值有:
    // ViewCompat.SCROLL_AXIS_HORIZONTAL
    // ViewCompat.SCROLL_AXIS_VERTICAL、SCROLL_AXIS_NONE
    @ScrollAxis
    int getNestedScrollAxes();
}

可以看到这两个接口的方法名都很通俗易懂,子View主动触发嵌套滑动,父Layout被动接受触发回调。以RecyclerView为例,一次滑动事件的执行顺序如下所示:

-> child.ACTION_DOWN
-> child.startNestedScroll
-> parent.onStartNestedScroll (如果返回false,则流程终止)
-> parent.onNestedScrollAccepted
-> child.ACTION_MOVE
-> child.dispatchNestedPreScroll
-> parent.onNestedPreScroll
-> child.ACTION_UP
-> chid.stopNestedScroll
-> parent.onStopNestedScroll
-> child.fling
-> child.dispatchNestedPreFling
-> parent.onNestedPreFling
-> child.dispatchNestedFling
-> parent.onNestedFling

那么问题来了,子View主动开启嵌套滑动之后父Layout是怎么接收到的呢?
那就不得不提两个工具类NestedScrollingChildHelperNestedScrollingParentHelper了,这两个工具类的作用就是连接父Layout和子View并完成一些基础工作。当子View调用startNestedScroll()方法时,内部究竟做了什么呢?来看一下RecyclerView里的写法。

@Override
public boolean startNestedScroll(int axes) {
    return getScrollingChildHelper().startNestedScroll(axes);
}

emmmm...直接调用了NestedScrollingChildHelperstartNestedScroll(axes)方法,这里的axes表示方向,点进去看下。

public boolean startNestedScroll(@ScrollAxis int axes) {
    return startNestedScroll(axes, TYPE_TOUCH);
}

这方法是个套娃,再点进去看下。

    public boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type) {
        if (hasNestedScrollingParent(type)) {
            // Already in progress
            return true;
        }
        if (isNestedScrollingEnabled()) {
            ViewParent p = mView.getParent();
            View child = mView;
            while (p != null) {
                if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)) {
                    setNestedScrollingParentForType(type, p);
                    ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes, type);
                    return true;
                }
                if (p instanceof View) {
                    child = (View) p;
                }
                p = p.getParent();
            }
        }
        return false;
    }

终于看到方法本体了,type参数表示什么下面再谈,看一下方法做了什么。
mView表示当前这个子View,方法里一层一层向上寻找mView的父Layout,直到ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)返回true,也就是此时的父Layout实现了NestedScrollingParent接口并接受此次嵌套滑动。看一下ViewParentCompat的onStartNestedScroll(...)方法。

public static boolean onStartNestedScroll(ViewParent parent, View child, View target,
            int nestedScrollAxes, int type) {
        if (parent instanceof NestedScrollingParent2) {
            return ((NestedScrollingParent2) parent).onStartNestedScroll(child, target,
                    nestedScrollAxes, type);
        } else if (type == ViewCompat.TYPE_TOUCH) {
            if (Build.VERSION.SDK_INT >= 21) {
                // ......
            } else if (parent instanceof NestedScrollingParent) {
                return ((NestedScrollingParent) parent).onStartNestedScroll(child, target,
                        nestedScrollAxes);
            }
        }
        return false;
    }

从这里可以看出来,嵌套滑动的parent不一定是child的直接父Layout,它们中间可能隔了好几层。仔细看一下上面的方法,你会发现除了NestedScrollingParent接口外还有NestedScrollingParent2接口,那么相比于第1代,NestedScrollingParent2升级了什么呢?
还记得上面提到的type参数吗?第2代嵌套滑动接口通过该参数区分当前触发嵌套滑动的是SCROLL事件还是FLING事件,父Layout可以统一在onNestedPreScroll()onNestedScroll()方法中进行处理。至于这是怎么做到的,我们接着往下看。

1.3 RecyclerView嵌套滑动源码简析(版本androidx-1.1.0)

现在先让我们来探究一下嵌套滑动的源头,上面提到,嵌套滑动是由子View发起,父Layout接收的,那么子View究竟在什么时候开启嵌套滑动呢?
RecyclerView在嵌套滑动中经常作为子View,这里以RecyclerView为例,来分析其处理嵌套滑动的逻辑,该逻辑主要在onTouchEvent()方法中,来看一下精简后的代码。

    @Override
    public boolean onTouchEvent(MotionEvent e) {
        // 省略部分代码......
        switch (action) {
            case MotionEvent.ACTION_DOWN: {
                // 手指按下时尝试开启嵌套滑动, 寻找可以嵌套滑动的父Layout
                startNestedScroll(nestedScrollAxis, TYPE_TOUCH);
            } break;

            case MotionEvent.ACTION_MOVE: {
                final int x = (int) (e.getX(index) + 0.5f);
                final int y = (int) (e.getY(index) + 0.5f);
                int dx = mLastTouchX - x;
                int dy = mLastTouchY - y;

                if (mScrollState == SCROLL_STATE_DRAGGING) {
                    mReusableIntPair[0] = 0;
                    mReusableIntPair[1] = 0;
                    // 根据当前的滑动方向开始嵌套滑动, 由父Layout先scroll
                    if (dispatchNestedPreScroll(
                            canScrollHorizontally ? dx : 0, canScrollVertically ? dy : 0,
                            mReusableIntPair, mScrollOffset, TYPE_TOUCH)) {
                        // 减去父Layout消耗掉的距离
                        dx -= mReusableIntPair[0];
                        dy -= mReusableIntPair[1];
                        // 更新offsets, 不常用到
                        mNestedOffsets[0] += mScrollOffset[0];
                        mNestedOffsets[1] += mScrollOffset[1];
                        // 滑动已经初始化, 阻止父Layout拦截事件
                        getParent().requestDisallowInterceptTouchEvent(true);
                    }
                    mLastTouchX = x - mScrollOffset[0];
                    mLastTouchY = y - mScrollOffset[1];
                    // RecyclerView内部的scroll
                    if (scrollByInternal(
                            canScrollHorizontally ? dx : 0, canScrollVertically ? dy : 0, e)) {
                        getParent().requestDisallowInterceptTouchEvent(true);
                    }
                }
            } break;

            case MotionEvent.ACTION_UP: {
                // 手指抬起时计算速度, 开启fling
                mVelocityTracker.addMovement(vtev);
                mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
                final float xvel = canScrollHorizontally
                        ? -mVelocityTracker.getXVelocity(mScrollPointerId) : 0;
                final float yvel = canScrollVertically
                        ? -mVelocityTracker.getYVelocity(mScrollPointerId) : 0;
                // 如果某个方向上的速度不为0就调用fling方法, 否则设置RecyclerView的状态为IDLE
                if (!((xvel != 0 || yvel != 0) && fling((int) xvel, (int) yvel))) {
                    setScrollState(SCROLL_STATE_IDLE);
                }
            } break;

            case MotionEvent.ACTION_CANCEL: {
                cancelScroll();
            } break;
        }
        return true;
    }

onTouchEvent()中的代码进行精简,只留下处理嵌套滑动的部分,整体的逻辑就清晰了起来。这里主要是对scroll的处理,关于fling的待会再看。
ACTION_DOWN的时候RecyclerView调用startNestedScroll()方法开始寻找可以进行嵌套滑动的父Layout,其实内部就是调用了NestedScrollingChildHelperstartNestedScroll()方法向上寻找最近的实现了NestedScrollingParent接口的父Layout并将其记录。
ACTION_MOVE中执行了嵌套滑动关键的3步:一是由父Layout最先消耗滚动距离dxdy;二是子View消耗剩余距离dx - mReusableIntPair[0]dy - mReusableIntPair[1];三是如果还有滚动距离未消耗完,则再交给父Layout消耗。
onTouchEvent()中进行了第1步,第2和第3步的逻辑在scrollByInternal()方法中:即首先让RecyclerView自身滚动,再通过dispatchNestedScroll()将剩余的距离分发给父Layout,源码精简后如下。

boolean scrollByInternal(int x, int y, MotionEvent ev) {
        int unconsumedX = 0; int unconsumedY = 0;
        int consumedX = 0; int consumedY = 0;

        if (mAdapter != null) {
            mReusableIntPair[0] = 0;
            mReusableIntPair[1] = 0;
            // RecyclerView本身的滑动, 最终调用了LayoutManager的scrollHorizontallyBy()或scrollVerticallyBy()
            scrollStep(x, y, mReusableIntPair);
            consumedX = mReusableIntPair[0]; consumedY = mReusableIntPair[1];
            unconsumedX = x - consumedX; unconsumedY = y - consumedY;
        }
        // ......
        dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, mScrollOffset,
                TYPE_TOUCH, mReusableIntPair);
        // ......
        return consumedNestedScroll || consumedX != 0 || consumedY != 0;
    }

ACTION_UP的时候计算速度并调用fling()方法。一般来说我们通过Scroller来实现惯性滑动,在computeScroll()方法中不断计算当前的坐标并移动。不了解Scroller的可以看参考的[1~3]。
但是实现了NestedScrollingChild2接口的View有所不同,上面提到,这种View的Scroll和Fling事件都可以由dispatchNestedPreScroll()传递,由type参数区分事件类型,TYPE_TOUCH为Scroll事件,TYPE_NON_TOUCH为Fling事件。
是不是感觉怪怪的?按照方法的名字,dispatchNestedPreScroll()方法应该只传递Scroll事件,而Fling事件由dispatchNestedPreFling()方法比较合理。确实,对于只实现了NestedScrollingChild接口的View就是这么处理的,但是用这种方式传递速率比较粗暴,在滑动到边界时可能存在卡顿现象。而实现了NestedScrollingChild2接口的View用了新的方式传递Fling事件,来看一下RecyclerView作为子View是怎么传递Fling事件给父Layout的。

public boolean fling(int velocityX, int velocityY) {
        // ......
        if (!dispatchNestedPreFling(velocityX, velocityY)) {
            final boolean canScroll = canScrollHorizontal || canScrollVertical;
            dispatchNestedFling(velocityX, velocityY, canScroll);

            if (mOnFlingListener != null && mOnFlingListener.onFling(velocityX, velocityY)) {
                return true;
            }

            if (canScroll) {
                // ......
                startNestedScroll(nestedScrollAxis, TYPE_NON_TOUCH);
                velocityX = Math.max(-mMaxFlingVelocity, Math.min(velocityX, mMaxFlingVelocity));
                velocityY = Math.max(-mMaxFlingVelocity, Math.min(velocityY, mMaxFlingVelocity));
                mViewFlinger.fling(velocityX, velocityY);
                return true;
            }
        }
        return false;
    }

代码中虽然调用了dispatchNestedPreFling()dispatchNestedFling()方法,但是对于实现了NestedScrollingParent2的父Layout来说,对应的回调方法都不用实现。
我们重点来看下面的mViewFlinger.fling(velocityX, velocityY),这句代码实现了RecyclerView本身的惯性滑动,mViewFlinger是RecyclerView内部类ViewFlinger的对象。该类精简后的源码如下:

class ViewFlinger implements Runnable {

        @Override
        public void run() {
            // ......
            final OverScroller scroller = mOverScroller;
            if (scroller.computeScrollOffset()) {
                // Nested Pre Scroll
                mReusableIntPair[0] = 0;
                mReusableIntPair[1] = 0;
                if (dispatchNestedPreScroll(unconsumedX, unconsumedY, mReusableIntPair, null,
                        TYPE_NON_TOUCH)) {
                    unconsumedX -= mReusableIntPair[0];
                    unconsumedY -= mReusableIntPair[1];
                }
                // ......
                // Nested Post Scroll
                mReusableIntPair[0] = 0;
                mReusableIntPair[1] = 0;
                dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, null,
                        TYPE_NON_TOUCH, mReusableIntPair);
                unconsumedX -= mReusableIntPair[0];
                unconsumedY -= mReusableIntPair[1];
                // ......
        }

        void postOnAnimation() {
            if (mEatRunOnAnimationRequest) {
                mReSchedulePostAnimationCallback = true;
            } else {
                internalPostOnAnimation();
            }
        }

        private void internalPostOnAnimation() {
            removeCallbacks(this);
            ViewCompat.postOnAnimation(RecyclerView.this, this);
        }

        public void fling(int velocityX, int velocityY) {
            setScrollState(SCROLL_STATE_SETTLING);
            mLastFlingX = mLastFlingY = 0;
            // Because you can't define a custom interpolator for flinging, we should make sure we
            // reset ourselves back to the teh default interpolator in case a different call
            // changed our interpolator.
            if (mInterpolator != sQuinticInterpolator) {
                mInterpolator = sQuinticInterpolator;
                mOverScroller = new OverScroller(getContext(), sQuinticInterpolator);
            }
            mOverScroller.fling(0, 0, velocityX, velocityY,
                    Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE);
            postOnAnimation();
        }

        public void smoothScrollBy(int dx, int dy, int duration,
                @Nullable Interpolator interpolator) {
            // ......
            postOnAnimation();
        }

        public void stop() {
            removeCallbacks(this);
            mOverScroller.abortAnimation();
        }
    }

还记得我们平时通过Scroller是怎么实现惯性滑动的吗?由于View每次draw()时会调用computeScroll(),如果Scroller的滑动尚未结束,就在computeScroll()中计算当前View应该所处的scroll位置并移动至该处,最后调用invalidate()继续触发draw()形成一个循环,直到惯性滑动结束。
RecyclerView实现惯性滑动和Fling事件传递的方式与之类似,都是使用Scroller计算惯性滑动的滑动距离。但是并没有重写computeScroll(),那么循环调用的机制是在哪儿实现的呢?
这里就不得不提postOnAnimation()的作用了,其内部调用了ViewCompat.postOnAnimation(View, Runnable),它会将当前这个Runnable对象,也就是mViewFlinger添加到执行队列中,等到下一帧到来的时候会执行该Runnnable对象的run()方法。
也就是说,每一帧刷新的时候都会通过Scroller计算这一帧应该滑动的距离dxdy,然后开启嵌套滑动,只不过此时的type不是TYPE_TOUCH,而是TYPE_NON_TOUCH

1.4 吸顶效果代码

上面说了这么多,可以发现RecyclerView本身为嵌套滑动做了很多事情,如果以RecyclerView作为嵌套滑动的子View,父Layout实现onNestedPreScroll()就可以实现初步的嵌套滑动效果。

public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
    if (dy > 0 && getScrollY() < mBannerHeight) {
        // 手指向上, 内容向下, 可上滑的距离为 Banner 高度
        consumed[1] = dy;
        scrollBy(0, dy);
    } else if (dy < 0 && !mRecyclerView.canScrollVertically(-1) && getScrollY() > 0) {
        // 手指向下, 内容向上, 当 RecyclerView 无法上滑时可以开始显示 Banner
        consumed[1] = dy;
        scrollBy(0, dy);
    }
}

当然只滑动RecyclerView是不够的,用户可能会滑动上方的图片,此时父Layout本身就需要实现惯性滑动,并且父Layout在滑动到图片消失时需要将速度传递给RecyclerView。而示例中的图片其实是一个Banner,由于Banner是可以左右滑动的,如果父Layout需要接收Banner区域上下滑动的事件,需要重写onInterceptTouchEvent()方法将其拦截。

    public boolean onInterceptTouchEvent(MotionEvent ev) {
        int action = ev.getAction();
        switch (action & MotionEvent.ACTION_MASK) {
            case MotionEvent.ACTION_DOWN:
                mIsBeingDragged = false;
                mLastMotionY = (int) ev.getRawY();
                break;
            case MotionEvent.ACTION_MOVE:
                int y = (int) ev.getRawY();
                int diff = Math.abs(mLastMotionY - y);
                mLastMotionY = y;
                boolean canRecyclerViewScroll = true;
                if (mRecyclerView != null) {
                    canRecyclerViewScroll = mRecyclerView.canScrollVertically(-1);
                }
                if (diff > TOUCH_SLOP && !canRecyclerViewScroll
                        && !isInNestedArea((int) ev.getRawX(), (int) ev.getRawY())) {
                    mIsBeingDragged = true;
                    ViewParent parent = getParent();
                    if (parent != null) {
                        parent.requestDisallowInterceptTouchEvent(true);
                    }
                }
                break;
            case MotionEvent.ACTION_UP:
                mIsBeingDragged = false;
                break;
        }
        return mIsBeingDragged;
    }

剩下的我就不多说了,大家下载源码查看吧。

二、demo2-列表回弹

2.1 效果展示

列表回弹的效果如下所示,在列表滑动到边缘时可以超过RecyclerView的滑动边界,并在用户松手后回弹至原本的边界,这种行为也被称为OverScroll。

gif-列表回弹.gif

2.2 实现原理

从现象上来看是用户在滑动到RecyclerView的边界之后还可以多滑动一段距离,并在用户松手时触发回弹,但实际上被OverScroll的不是RecyclerView本身,而是它的父Layout,我们不需要对RecyclerView做任何改变,只需要在它外面套一个支持OverScroll的BounceLayout即可。
布局如下所示,蓝色框表示包裹了RecyclerView的BounceLayout,黑色框表示BounceLayout的父Layout。当用户下拉RecyclerView到边界时,BounceLayout开始向下移动,产生OverScroll的效果。

图片-回弹布局.png

RecyclerView本身实现了嵌套滑动,当它滑动到边界时,经常会产生未消耗的滑动距离,也就是dyUnconsumed,并通过dispatchNestedScroll(...)将这段距离分发给BounceLayout进行处理,BounceLayout即可通过scrollBy(...)滑动自己来达到OverScroll的效果。用户松手时RecyclerView会调用stopNestedScroll(),此时BounceLayout进行回弹即可。
上面说的是用户拖动RecyclerView时的情况,在惯性滑动下,如果fling到了边界,那么BounceLayout需要在RecyclerView fling到边界时计算当前的速率,根据速率向外弹出一段距离,最终在速度为0时回弹。

了解原理之后可以发现BounceLayout不仅仅可以用于实现RecyclerView的回弹,任何像RV一样实现了嵌套滑动子View功能的视图都可以实现该功能,因此这种实现方式具有很好的解耦性。下面来看具体实现。

2.3 具体实现

2.3.1 最大OverScroll距离

先来讨论一下如何限制OverScroll的滑动距离,定义当前OverScroll的距离为OverScrollDistance,最大可滑动距离为MaxOverScrollDistance。假设当前用户下拉y,则BounceLayout调用scrollBy(-y)使其整体向下移动,当BounceLayout的Math.abs(scrollY) == MaxOverScrollDistance时,不管用户怎么下拉,BounceLayout也不该再移动了。

上面描述的是OverScrollDistance=scrollY,也就是线性关系时的效果:用户下拉dy,BounceLayout移动dy。不过如果你使用过OverScroll的功能你就知道,你下拉的距离和BounceLayout移动的距离并不是线性关系:当你下拉y时,当前OverScrollDistance越大,BounceLayout的实际移动距离就越小,说得通俗一点:当前已经滑动的距离越大,你越难滑动它。

想要实现这样的效果并不难,我们为OverScrollDistance和scrollY定义一个插值器OverScrollerInterpolator
input = OverScrollDistance/MaxOverScrollDistance
output = scrollY/MaxOverScrollDistance,公式为:output = (1 - factor ^ (input * 2)),当factor为0.6时,函数图如下所示。

图片-overScrollDistance和scrollY.png

该函数是先快后慢的效果,越临近最大值,用户越难拖动,这能给用户带来较好的体验。而且不管input多大,output始终<1,因此Math.abs(scrollY)永远<MaxOverScrollDistance。根据该公式,我们可以定义如下插值器:

    private class OverScrollerInterpolator implements Interpolator {
        private float mFactor;

        public OverScrollerInterpolator(float factor) {
            mFactor = factor;
        }

        public float getInterpolationBack(float input) {
            return (float) (Math.log(1 - input) / Math.log(mFactor) / 2);
        }

        @Override
        public float getInterpolation(float input) {
            return (float) (1 - Math.pow(mFactor, input * 2));
        }
    }

令x = OverScrollDistance/MaxOverScrollDistance
令y = scrollY/MaxOverScrollDistance
当已知x时,可以通过getInterpolation()计算y,那么已知y时,该怎么计算x呢?我们来算一下:

  y = 1 - factor ^ x* 2
=>x * 2 = log(factor, 1 - y)
=>x * 2 = log(2, 1 - y) / log(2, factor)
=>x = (log(2, 1 - y) / log(2, factor)) / 2

得到的结果就是getInterpolationBack()里的算式。
至此,我们可以通过OverScrollerInterpolator中的两个方法建立scrollY和overScrollDistance之间的函数关系,demo中取factor为0.6,新建插值器如下:

private OverScrollerInterpolator mInterpolator = new OverScrollerInterpolator(0.6f);

对于这个插值器的用法,我们举个例子:
用户滑动RecyclerView到边界时,BounceLayout可以在onNestedScroll(...)方法中处理dyUnconsumed,如下所示,我们将未消耗的滑动距离dyUnconsumed加到mOverScrollDistance并通过mInterpolator的getInterpolation()方法将其转化成scrollY,再调用scrollTo()移动到最终的位置。

    @Override
    public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) {
        if (mOrientation == ViewCompat.SCROLL_AXIS_VERTICAL && dyUnconsumed != 0) {
            if (type == ViewCompat.TYPE_TOUCH) {
                startOverScroll(dyUnconsumed);
            } else {
                // ......
            }
        }
    }

    private void startOverScroll(int dy) {
        updateOverScrollDistance(mOverScrollDistance + dy);
    }

    private void updateOverScrollDistance(int distance) {
        mOverScrollDistance = distance;
        if (mOverScrollDistance < 0) {
            scrollTo(0, (int) (-mMaxOverScrollDistance * mInterpolator.getInterpolation(
                    Math.abs((float) mOverScrollDistance / mOverScrollBorder))));
        } else {
            scrollTo(0, (int) (mMaxOverScrollDistance * mInterpolator.getInterpolation(
                    Math.abs((float) mOverScrollDistance / mOverScrollBorder))));
        }
    }
2.3.2 SpringBack

SpringBack指回弹,表现为将当前处于OverScroll状态下的BounceLayout恢复到初始状态,我们选择使用ValueAnimator实现该功能,当需要回弹时,调用startScrollBackAnimator ()方法即可,相关代码如下。

    private ValueAnimator mSpringBackAnimator;
    private int mMaxOverScrollDistance = 200;
    // mOverScrollBorder为mMaxOverScrollDistance的n倍
    // 主要用于优化滑动体验,n越大,滑动阻力越大
    private int mOverScrollBorder = mMaxOverScrollDistance * 3;

    public void startScrollBackAnimator() {
        if (mSpringBackAnimator != null) {
            mSpringBackAnimator.cancel();
        }
        mSpringBackAnimator = ValueAnimator.ofInt(mOverScrollDistance, 0);
        mSpringBackAnimator.setInterpolator(new DecelerateInterpolator());
        mSpringBackAnimator.setDuration(250);
        mSpringBackAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                updateOverScrollDistance((Integer) animation.getAnimatedValue());
            }
        });
        mSpringBackAnimator.start();
    }

    private void updateOverScrollDistance(int distance) {
        mOverScrollDistance = distance;
        if (mOverScrollDistance < 0) {
            scrollTo(0, (int) (-mMaxOverScrollDistance * mInterpolator.getInterpolation(
                    Math.abs((float) mOverScrollDistance / mOverScrollBorder))));
        } else {
            scrollTo(0, (int) (mMaxOverScrollDistance * mInterpolator.getInterpolation(
                    Math.abs((float) mOverScrollDistance / mOverScrollBorder))));
        }
    }

当回弹时,建立一个value从mOverScrollDistance到0的ValueAnimator,更新value时调用updateOverScrollDistance(),通过mInterpolator将mOverScrollDistance转化成scrollY并移动至该位置。

可以看到实现回弹效果的逻辑比较简单,有难度的点在于我们应该在什么时候触发回弹。有如下3种场景:
第1种场景:用户拖动RecyclerView至OverScrollDistance>0后松手。
此时RecyclerViewACTION_UP,调用stopNestedScroll(),回调至BounceLayout中的onStopNestedScroll()方法,在该方法中即可进行回弹。

    @Override
    public void onStopNestedScroll(@NonNull View target, int type) {
        mNestedScrollingParentHelper.onStopNestedScroll(target);
        if (mOverScrollDistance != 0) {
            startScrollBackAnimator();
        }
    }

第2种场景:用户拖动RecyclerView至OverScrollDistance>0后,再触发fling后松手。
此时BounceLayout应该再顺着fling滑动很小一段距离后开始回弹,我们来看一下代码实现。

    @Override
    public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) {
        if (mOrientation == ViewCompat.SCROLL_AXIS_VERTICAL && dyUnconsumed != 0) {
            if (type == ViewCompat.TYPE_TOUCH) { // 用户在拖动RV
                startOverScroll(dyUnconsumed);
            } else { // RV在fling状态
                if (mOverScrollDistance == 0) { // Bounce,下节说明
                    startOverScroll(dyUnconsumed);
                    mScroller.computeScrollOffset();
                    startBounceAnimator(mScroller.getCurrVelocity() * mLastSign);
                } else { // 当前场景
                    // 顺着当前fling的方向再滑动一小段距离
                    startOverScroll(dyUnconsumed);
                }
                // 让RecyclerView主动停止嵌套滑动
                ViewCompat.stopNestedScroll(target, type);
            }
        }
    }

可以看到在当前场景下,BounceLayout会再移动一小段距离,随后主动调用ViewCompat.stopNestedScroll(target, type),此时会回调至BounceLayout的onStopNestedScroll(...)开始回弹。

第3种场景:RecyclerView惯性滑动至边界,BounceLayout根据当前速率外弹出一段距离,直到速率为0时回弹,这种行为就被称为Bounce。上一段代码中,当滑动到边界且mOverScrollDistance == 0时触发Bounce,具体的逻辑来看下一节。

2.3.3 Bounce

在上一节的代码中我们看到触发Bounce的代码如下:

startOverScroll(dyUnconsumed);
mScroller.computeScrollOffset();
startBounceAnimator(mScroller.getCurrVelocity() * mLastSign);

通过startBounceAnimator()触发Bounce需要初速度和方向,我们可以在onNestedPreFling()中得到RecyclerView惯性滑动时的初速度velocityY和方向mLastSign。

    @Override
    public boolean onNestedPreFling(View target, float velocityX, float velocityY) {
        mLastSign = velocityY < 0 ? -1 : 1;
        mScroller.forceFinished(true);
        mScroller.fling(0, 0, 0, (int) velocityY, 0, Integer.MAX_VALUE, 0, Integer.MAX_VALUE);
        return false;
    }

但是RecyclerView惯性滑动的初速度很显然不等于触发Bounce时的初速度,因此我们通过mScroller.fling()计算速度,在滑动至边界时调用mScroller.computeScrollOffset()计算当前时间点的速度,再通过mScroller.getCurrVelocity()即可得到触发Bounce时的初速度。

得到初速度和方向后我们来看看startBounceAnimator(...)做了什么。

    private void startBounceAnimator(float velocity) {
        if (mBounceRunnable != null) {
            mBounceRunnable.cancel();
        }
        mBounceRunnable = new BounceAnimRunnable(velocity, mOverScrollDistance);
        mBounceRunnable.start();
    }

该方法启动了BounceAnimRunnable,来看一下它的代码。
在构造函数中,首先根据初速度mVelocity➗减速度mDeceleration计算duration。启动BounceAnimRunnable后每隔FRAME_TIME毫秒计算一次当前的mOverScrollDistance,当duration结束时通过startScrollBackAnimator ()回弹。

private class BounceAnimRunnable implements Runnable {

        private static final int FRAME_TIME = 14;

        private final float mDeceleration;
        private float mVelocity;
        private int mStartY;
        private int mRuntime = 0;
        private int mDuration = 0;
        private boolean cancel;

        public void cancel() {
            cancel = true;
            removeCallbacks(this);
        }

        public BounceAnimRunnable(float velocity, int startY) {
            // BOUNCE_BACK_DECELERATION为减速度
            mDeceleration = mVelocity < 0 ? BOUNCE_BACK_DECELERATION : -BOUNCE_BACK_DECELERATION;
            mVelocity = velocity;
            mStartY = startY;
            mDuration = (int) ((-mVelocity / mDeceleration) * 1000);
        }

        public void start() {
            postDelayed(this, FRAME_TIME);
        }

        @Override
        public void run() {
            if (cancel) {
                return;
            }
            mRuntime += FRAME_TIME;
            float t = (float) mRuntime / 1000;
            int distance = (int) (mStartY + mVelocity * t + 0.5 * mDeceleration * t * t);
            updateOverScrollDistance(distance);
            if (mRuntime < mDuration && Math.abs(distance) < mMaxOverScrollDistance * 2) {
                postDelayed(this, FRAME_TIME);
            } else {
                startScrollBackAnimator();
            }
        }
    }

至此,列表回弹的基本逻辑就讲完了,限于篇幅,有些细节并未全部列出。完整的源代码就不贴了,感兴趣的同学可以去文章开头的地址下载。

三、总结与推荐博客

读到这里是不是发现以前觉得很酷炫的功能实现起来也不是太难?其实嵌套滑动还能实现一些很实用的东西······
这里推荐一篇WebView和RecyclerView嵌套滑动实现详情页的博客:文章详情页,大家感兴趣可以研究一下。

四、参考

  1. Android Scroller解析和使用
  2. Scroller的使用及解析
  3. Android Scroller完全解析,关于Scroller你所需知道的一切
  4. Android嵌套滑动机制实战演练
上一篇下一篇

猜你喜欢

热点阅读