美文网首页实用Android-CoordinatorLayout.……高级UI
Android - 有趣的嵌套滑动(Demo不定期更新)

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

作者: 东方未曦 | 来源:发表于2020-01-06 00:18 被阅读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嵌套滑动机制实战演练

    相关文章

      网友评论

        本文标题:Android - 有趣的嵌套滑动(Demo不定期更新)

        本文链接:https://www.haomeiwen.com/subject/nfttnctx.html