Android—有趣的嵌套滑动

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

写在前面

博客中的demo上传到了github NestedScrollingProject,欢迎各位同学下载&star。

一、吸顶效果&RecyclerView源码简析

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

1.1 吸顶效果展示

效果展示.gif

1.2 嵌套滑动API介绍

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

布局文件.png

外部父Layout包裹ImageView、TextView和RecyclerView,如果我们希望滑动RecyclerView的时候能先将ImageView滑动上去,随后使TextView吸顶,我们该怎么做呢?

这里就用到嵌套滑动,假设当前用户手指在RecyclerView向上划动,我们需要将RecyclerView的滑动事件先传递给父布局,如果父布局发现头部的ImageView还在显示,那么先消耗该事件并将整个父布局中的所有内容向上移动;如果图片已经上滑至不显示,那么将滑动事件交给RecyclerView处理。

手指在RecyclerView上划时如图所示,此时LinearLayout中的所有内容都会向上滚动,直到TextView吸顶,再开始滑动RecyclerView。注意:RecyclerView的高度其实是界面的高度减去TexView的高度,比布局文件图中画的高度要高。

图片-移动示意.png

根据上面的流程不难发现,嵌套滑动由RecyclerView主动发起,父View被动接受,并且父View可以先于子View处理滑动事件。举个栗子,假设在一次事件中手指在RecyclerView向上滑动dy,那么大体的流程如下:
① RecyclerView判断是否有父View能接受嵌套滑动,如果有,则将事件传递给父View。
② 父View收到该滑动事件,此时父View判断当前图片是否还在展示,如果还在展示,则父View向上滑动。但是父View不一定会在每次事件中都将dy全部消耗掉(例如滑动到边缘的时候),这里通过一个值consumed来保存父Layout消耗的值。
③ 子View根据父View消耗的距离,计算出剩余的值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即将开始滑动了,由父View先处理,对应父View的onNestedPreScroll方法
    boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
            @Nullable int[] offsetInWindow);

    // 子View处理完事件再交给父View,对应父View的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);
}

想要实现嵌套滑动的父View需要实现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);

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

    // 父View处理子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主动触发嵌套滑动,父View被动接受触发回调,每一个嵌套滑动事件都会经历一个"父-子-父"的分发流程。以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主动开启嵌套滑动之后父View是怎么接收到的呢?
那就不得不提两个工具类NestedScrollingChildHelperNestedScrollingParentHelper了,这两个工具类的作用就是连接父View和子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的父View,直到ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)返回true,也就是此时的父View实现了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的直接父View,它们中间可能隔了好几层。仔细看一下上面的方法,你会发现除了NestedScrollingParent接口外还有NestedScrollingParent2接口,那么相比于第1代,NestedScrollingParent2升级了什么呢?

还记得上面提到的type参数吗?第2代嵌套滑动接口通过该参数区分当前触发嵌套滑动的是SCROLL事件还是FLING事件,父View可以统一在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()方法开始寻找可以进行嵌套滑动的父View,其实内部就是调用了NestedScrollingChildHelperstartNestedScroll()方法向上寻找最近的实现了NestedScrollingParent接口的父View并保存父View的引用。
ACTION_MOVE中执行了嵌套滑动关键的3步:一是由父View最先消耗滚动距离dxdy;二是子View消耗剩余距离dx - mReusableIntPair[0]dy - mReusableIntPair[1];三是如果还有滚动距离未消耗完,则再交给父Layout消耗。

onTouchEvent()中进行了第1步,第2和第3步的逻辑在scrollByInternal()方法中:即首先让RecyclerView自身滚动,再通过dispatchNestedScroll()将剩余的距离分发给父View,源码精简后如下。

    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事件给父View的。

    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的父View来说,对应的回调方法都不实现也可以。
我们重点来看下面的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对象post到Choreographer的执行队列中,等到下一帧到来的时候会执行该Runnnable对象的run()方法。也就是说,每一帧刷新的时候都会通过Scroller计算这一帧应该滑动的距离dxdy,然后开启嵌套滑动,只不过此时的type不是TYPE_TOUCH,而是TYPE_NON_TOUCH,对于60帧的手机,一秒会分发60次的嵌套滑动事件。

1.4 吸顶效果代码

上面说了这么多,可以发现RecyclerView本身为嵌套滑动做了很多事情,如果以RecyclerView作为嵌套滑动的子View,父View实现onNestedPreScroll()就可以实现初步的嵌套滑动效果。想要实现吸顶效果的代码,我们自定义继承自LinearLayout的SimpleNestedLinearLayout作为父View并重写onNestedPreScroll()方法如下。

    override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
        if (dy > 0 && scrollY < imageViewHeight) {
            var actualDy = dy
            if (scrollY + dy >= imageViewHeight) {
                actualDy = imageViewHeight - scrollY
            }
            consumed[1] = actualDy
            scrollBy(0, actualDy)
        } else if (dy < 0 && recyclerView?.canScrollVertically(-1) == false && scrollY > 0) {
            var actualDy = dy
            if (scrollY + dy < 0) {
                actualDy = -scrollY
            }
            consumed[1] = actualDy
            scrollBy(0, actualDy)
        }
    }

这里还需要重写onMeasure()方法,因为LinearLayout本身的measure流程不符合吸顶效果的需求:LinearLayout会依次measure子View,然后将剩余的高度作为之后子View的最大高度,如果这里不重写onMeasure()方法,RecyclerView的高度就=(LinearLayout高度-ImageView高度-TextView高度),但是RecyclerView的高度应该=(父View的高度-TextView高度)。

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        setMeasuredDimension(
            getDefaultSize(suggestedMinimumWidth, widthMeasureSpec),
            getDefaultSize(suggestedMinimumHeight, heightMeasureSpec)
        )
        val heightMode = MeasureSpec.getMode(heightMeasureSpec)
        for (index in 0 until childCount) {
            val child = getChildAt(index) ?: continue
            val layoutParams = child.layoutParams
            if (layoutParams.height == LayoutParams.MATCH_PARENT) {
                val rvHeight = measuredHeight - resources
                                    .getDimensionPixelSize(R.dimen.simple_nested_title_height)
                val childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(rvHeight, heightMode)
                measureChild(child, widthMeasureSpec, childHeightMeasureSpec)
            } else {
                val childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(layoutParams.height, heightMode)
                measureChild(child, widthMeasureSpec, childHeightMeasureSpec)
            }
        }
    }

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

二、WebView与RecyclerView混合布局

2.1 效果展示

WebView与RecyclerView的混合布局经常用于新闻APP的新闻页,其中WebView展示的新闻本身的网页,下方RecyclerView负责展示相关推荐、广告、评论等内容。

WebView&RecyclerView.gif

2.2 实现原理

可以发现这个父View也是一个类似LinearLayout的垂直布局,WebView和RecyclerView的高度都与父View相等。
当用户划动WebView时,WebView本身并没有移动,而是调用WebView.scrollBy(...)移动WebView里面的内容。直到WebView的内容滑动到底部时,调用父View的scrollBy(...)将WebView和RecyclerView向上移动。此时的布局如下所示,黑色框表示父View,是用户的可见区域。

混合布局.png

WebView本身并不支持嵌套滑动,因此我们需要自定义继承自WebView的NestedScrollWebView,并重写它的onTouchEvent()方法,将它的滑动事件向外分发,这部分逻辑参照RecyclerView即可,这里不再赘述,可以参考本文开头的链接。值得注意的是,WebView需要判断自己的内容是否已经滑动到底部,因此在NestedScrollWebView添加如下方法。

    fun canWebViewScrollDown(): Boolean {
        val range = computeVerticalScrollRange()
        return ((scrollY + measuredHeight) < range)
    }

在WebView与RecyclerView混合布局中,主要关注父View的逻辑。我们定义一个继承自ViewGroup的MixedLayout,首先重写它的onMeasure()方法,把WebView和RecyclerView的高度都设置为父View的高度,再重写它的onLayout()方法,按照垂直线性布局的方式去排布。

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        setMeasuredDimension(
            getDefaultSize(suggestedMinimumWidth, widthMeasureSpec),
            getDefaultSize(suggestedMinimumHeight, heightMeasureSpec)
        )
        val heightMode = MeasureSpec.getMode(heightMeasureSpec)
        val height = measuredHeight
        for (i in 0 until childCount) {
            val child = getChildAt(i) ?: continue
            // 其实WebView的高度不一定为父View的高度, 因为有些短新闻的高度不足一屏
            // 这里为了方便, 假定新闻都是超过一屏的
            if (child == webView) {
                val childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(height, heightMode)
                measureChild(webView, widthMeasureSpec, childHeightMeasureSpec)
            } else if (child == recyclerView) {
                val childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(height, heightMode)
                measureChild(recyclerView, widthMeasureSpec, childHeightMeasureSpec)
            } else {
                measureChild(child, widthMeasureSpec, heightMeasureSpec)
            }
        }
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        for (i in 0 until childCount) {
            val child = getChildAt(i)
            if (child == webView) {
                child.layout(0, 0, measuredWidth, measuredHeight)
            } else if (child == recyclerView) {
                child.layout(0, measuredHeight, measuredWidth, measuredHeight * 2)
            }
        }
        layoutMaxScrollY = measuredHeight
    }

下面来看MixedLayout处理嵌套滑动的逻辑,首先来看onNestedPreScroll()方法。该方法使用actualDy表示实际移动的距离,用于处理边界滑动。主要逻辑为判断当前是哪个View在分发嵌套滑动事件,再根据滑动方向分别进行处理。

    override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
        var actualDy = dy
        if (target == webView) {
            if (dy > 0 && !webView.canWebViewScrollDown()) {
                // WebView内容向下滑动
                if (scrollY + actualDy > layoutMaxScrollY) {
                    actualDy = layoutMaxScrollY - scrollY
                }
                scrollBy(0, actualDy)
                consumed[1] = actualDy
            } else if (dy < 0 && scrollY > 0) {
                // WebView内容向上滑动
                if (scrollY + actualDy < 0) {
                    actualDy = -scrollY
                }
                scrollBy(0, actualDy)
                consumed[1] = actualDy
            }
        } else if (target == recyclerView) {
            if (dy > 0 && scrollY < layoutMaxScrollY) {
                if (scrollY + actualDy > layoutMaxScrollY) {
                    actualDy = layoutMaxScrollY - scrollY
                }
                scrollBy(0, actualDy)
                consumed[1] = actualDy
            } else if (dy < 0) {
                if (!recyclerView.canScrollVertically(-1)) {
                    if (scrollY + actualDy < 0) {
                        actualDy = -scrollY
                        webView.stopFling()
                    }
                    scrollBy(0, actualDy)
                    consumed[1] = actualDy
                }
            }
        }
    }

还有一种情况是在WebView触发一个速度较大的fling,这时WebView的内容会滑动到底部,随后MixedLayout也会滑动到底部,最后开始滑动RecyclerView。这种情况下的滑动事件会分发到onNestedScroll()中进行处理,具体如下所示。

    /**
     * RecyclerView的Fling事件传递到了WebView 或 WebView的Fling事件传递到了RecyclerView
     */
    override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int,
                                dxUnconsumed: Int, dyUnconsumed: Int, type: Int, consumed: IntArray) {
        if (dyUnconsumed == 0 || nestedScrollAxes != ViewCompat.SCROLL_AXIS_VERTICAL) {
            return
        }
        consumed[1] = dyUnconsumed
        if (target == webView && dyUnconsumed > 0) {
            if (scrollY >= layoutMaxScrollY) {
                if (scrollY > layoutMaxScrollY) {
                    scrollTo(0, layoutMaxScrollY)
                }
                if (recyclerView.canScrollVertically(1)) {
                    recyclerView.scrollBy(0, dyUnconsumed)
                } else {
                    webView.stopNestedScroll(type)
                }
            }
        } else if (target == recyclerView && dyUnconsumed < 0) {
            if (scrollY <= 0) {
                if (scrollY < 0) {
                    scrollTo(0, 0)
                }
                if (webView.scrollY + dyUnconsumed > 0) {
                    webView.scrollBy(0, dyUnconsumed)
                } else {
                    recyclerView.stopNestedScroll(ViewCompat.TYPE_NON_TOUCH)
                    webView.scrollTo(0, 0)
                }
            }
        }
    }

三、回弹布局

3.1 效果展示

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

gif-列表回弹.gif

3.2 实现原理

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

图片-回弹布局.png

RecyclerView本身实现了嵌套滑动,当它滑动到边界时,经常会产生未消耗的滑动距离,也就是dyUnconsumed,并通过dispatchNestedScroll(...)将这段距离分发给BounceLayout进行处理,BounceLayout即可通过scrollBy(...)滑动自己来达到OverScroll的效果。用户松手时RecyclerView会调用stopNestedScroll(),此时BounceLayout进行回弹即可。

上面说的是用户拖动RecyclerView时的情况,在惯性滑动下,如果fling到了边界,那么BounceLayout需要在RecyclerView fling到边界时计算当前的速率,根据速率向外弹出一段距离,最终在速度为0时回弹。

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

3.3 具体实现

3.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。根据该公式,我们可以定义如下插值器:

    inner class OverScrollerInterpolator(private var factor: Float) : Interpolator {

        fun getInterpolationBack(input: Float) : Float {
            return (ln(1 - input) / ln(factor) / 2)
        }

        override fun getInterpolation(input: Float): Float {
            return (1 - factor.pow(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 val mInterpolator: OverScrollerInterpolator = OverScrollerInterpolator(0.6f)

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

    override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int, 
                dxUnconsumed: Int, dyUnconsumed: Int, type: Int) {
        if (mOrientation == ViewCompat.SCROLL_AXIS_VERTICAL && dyUnconsumed != 0) {
            if (type == ViewCompat.TYPE_TOUCH) {
                startOverScroll(dyUnconsumed)
            } else {
                ......
            }
        }
    }

    private fun startOverScroll(dy: Int) {
        updateOverScrollDistance(mOverScrollDistance + dy)
    }

    private fun updateOverScrollDistance(distance: Int) {
        mOverScrollDistance = distance
        if (mOverScrollDistance < 0) {
            scrollTo(
                0, (-mMaxOverScrollDistance * mInterpolator.getInterpolation(
                    abs(mOverScrollDistance.toFloat() / mOverScrollBorder)
                )).toInt()
            )
        } else {
            scrollTo(
                0, (mMaxOverScrollDistance * mInterpolator.getInterpolation(
                    abs(mOverScrollDistance.toFloat() / mOverScrollBorder)
                )).toInt()
            )
        }
    }
3.3.2 SpringBack

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

    private var mSpringBackAnimator: ValueAnimator? = null
    private val mMaxOverScrollDistance = 300
    // mOverScrollBorder为mMaxOverScrollDistance的n倍
    // 主要用于优化滑动体验,n越大,滑动阻力越大
   private val mOverScrollBorder = mMaxOverScrollDistance * 3

    fun startScrollBackAnimator() {
        mSpringBackAnimator?.cancel()
        mSpringBackAnimator = ValueAnimator.ofInt(mOverScrollDistance, 0)
        mSpringBackAnimator?.interpolator = DecelerateInterpolator()
        mSpringBackAnimator?.duration = SPRING_BACK_DURATION.toLong()
        mSpringBackAnimator?.addUpdateListener { animation ->
            updateOverScrollDistance(
                animation.animatedValue as Int
            )
        }
        mSpringBackAnimator?.start()
    }

    private fun updateOverScrollDistance(distance: Int) {
        ......
    }

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

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

    override fun onStopNestedScroll(target: View, type: Int) {
        mNestedScrollingParentHelper.onStopNestedScroll(target)
        if (mOverScrollDistance != 0) {
            startScrollBackAnimator()
        }
    }

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

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

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

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

3.3.3 Bounce

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

mScroller.computeScrollOffset()
startBounceAnimator(mScroller.currVelocity * mLastSign)

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

    override fun onNestedPreFling(target: View, velocityX: Float, velocityY: Float): Boolean {
        mLastSign = if (velocityY < 0) -1 else 1
        mScroller.forceFinished(true)
        mScroller.fling(0, 0, 0, velocityY.toInt(), 0,
            Int.MAX_VALUE, 0, Int.MAX_VALUE)
        return false
    }

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

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

    private fun startBounceAnimator(velocity: Float) {
        mBounceRunnable?.cancel()
        mBounceRunnable = BounceAnimRunnable(velocity, mOverScrollDistance)
        mBounceRunnable?.start()
    }

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

    inner class BounceAnimRunnable : Runnable {
        /**
         * 两帧之间的间隔
         */
        private var frameInternalMillis = 10

        private var mDeceleration = 0
        private var mVelocity = 0f
        private var mStartY = 0
        private var mRuntime = 0
        private var mDuration = 0
        private var mHasCanceled = false

        constructor(velocity: Float, startY: Int) {
            mDeceleration = if (velocity < 0) {
                BOUNCE_BACK_DECELERATION
            } else {-BOUNCE_BACK_DECELERATION
            }
            mVelocity = velocity
            mStartY = startY
            mDuration = ((-mVelocity / mDeceleration) * 1000).toInt()
        }

        fun start() {
            postDelayed(this, frameInternalMillis.toLong())
        }

        fun cancel() {
            mHasCanceled = true
            removeCallbacks(this)
        }

        override fun run() {
            if (mHasCanceled) {
                return
            }
            mRuntime += frameInternalMillis
            val t = mRuntime.toFloat() / 1000
            val distance = (mStartY + mVelocity * t + 0.5 * mDeceleration * t * t).toInt()
            updateOverScrollDistance(distance)
            if (mRuntime < mDuration && abs(distance) < mMaxOverScrollDistance * 2) {
                removeCallbacks(this)
                postDelayed(this, frameInternalMillis.toLong())
            } else {
                startScrollBackAnimator()
            }
        }

    }

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

四、参考

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

推荐阅读更多精彩内容