一个解决滑动冲突新思路,做到视图之间无缝地嵌套滑动

2,426 阅读24分钟

本文已投稿至郭霖公众号,由郭霖公众号独家发布。

在此文章开始之前,我想抛出一个问题:如何解决滑动冲突?

用传统的思路解决,你可能会从 View 的 onInterceptTouchEvent()onTouchEvent() 方法入手,根据业务的情况以及手指滑动的方向,按需拦截事件来解决视图之间的滑动冲突。

这种思路没有错,可以完美解决视图之间的滑动冲突。

但这种思路有个局限,它无法解决嵌套滑动问题。

为什么呢?因为目前绝大多数的滚动组件(RecyclerView,ScrollView,ListView等),我们翻看它们的源码,都可以看到它们在处理 move 事件时有一段这样的代码:

parent.requestDisallowInterceptTouchEvent(true);

这段代码会禁止父控件拦截 move、up 事件。意味着一旦滚动组件的内容被拖动了,事件就被滚动组件接管了,父控件无法再通过 onInterceptTouchEvent() 拦截同一事件序列中剩余的事件,因此更不会走到 onTouchEvent() 中,处理自己滚动的逻辑了。

因此

父控件只能等待下一个事件序列到来,才能调用 onInterceptTouchEvent() 拦截事件让自己滚动。所以用 onInterceptTouchEvent() 和 onTouchEvent() 实现的嵌套滑动不够连贯,需要两次滑动操作。

2_onInterceptTouchEvent_实现的嵌套滑动.gif

而真正的嵌套滑动应该是下面这样的效果,只需要一次滑动操作,滑动在父控件与子控件之间无缝的切换,一气呵成。

1_嵌套滑动展示.gif

要实现这样的效果,利用的是自 Android API 21 后新增的嵌套滑动 API,也是一种新的滑动冲突解决方案,本文就会为大家介绍实现过程中用到的嵌套滑动 API,以及如何一步步实现上面的效果。

简单介绍下嵌套滑动机制

嵌套滑动 API 自安卓 5.1 引入,从最初引入时的 NestedScrollParent,NestedScrollChild,到现在的 NestedScrollParent3,NestedScrollChild3,已经发展了3个版本,每次的更新基本都是方法参数的调整,没有新增什么方法,所以嵌套滑动的执行流程也没啥变化。

嵌套滑动机制它不像事件分发机制那般复杂,有各种分发、拦截过程。它更像一套接口,需要支持嵌套滑动的 View 则去实现,不需要的话,不用管就行了。

看一下接口中都有哪些方法

NestedScrollChild
public interface NestedScrollingChild {
    /**
     * @param enabled 开启或关闭嵌套滑动
     */
    void setNestedScrollingEnabled(boolean enabled);

    /**
     * @return 返回是否开启嵌套滑动
     */    
    boolean isNestedScrollingEnabled();

    /**
     * 沿着指定的方向开始滑动嵌套滑动
     * @param axes 滑动方向
     * @return 返回是否找到NestedScrollingParent配合滑动
     */
    boolean startNestedScroll(@ScrollAxis int axes);

    /**
     * 停止嵌套滑动
     */
    void stopNestedScroll();

    /**
     * @return 返回是否有配合滑动NestedScrollingParent
     */
    boolean hasNestedScrollingParent();

    /**
     * 滑动完成后,将已经消费、剩余的滑动值分发给NestedScrollingParent
     * @param dxConsumed 水平方向消费的距离
     * @param dyConsumed 垂直方向消费的距离
     * @param dxUnconsumed 水平方向剩余的距离
     * @param dyUnconsumed 垂直方向剩余的距离
     * @param offsetInWindow 含有View从此方法调用之前到调用完成后的屏幕坐标偏移量,
     * 可以使用这个偏移量来调整预期的输入坐标(即上面4个消费、剩余的距离)跟踪,此参数可空。
     * @return 返回该事件是否被成功分发
     */
    boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);

    /**
     * 在滑动之前,将滑动值分发给NestedScrollingParent
     * @param dx 水平方向消费的距离
     * @param dy 垂直方向消费的距离
     * @param consumed 输出坐标数组,consumed[0]为NestedScrollingParent消耗的水平距离、
     * consumed[1]为NestedScrollingParent消耗的垂直距离,此参数可空。
     * @param offsetInWindow 同上dispatchNestedScroll
     * @return 返回NestedScrollingParent是否消费部分或全部滑动值
     */
    boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
            @Nullable int[] offsetInWindow);

    /**
     * 将惯性滑动的速度和NestedScrollingChild自身是否需要消费此惯性滑动分发给NestedScrollingParent
     * @param velocityX 水平方向的速度
     * @param velocityY 垂直方向的速度
     * @param consumed NestedScrollingChild自身是否需要消费此惯性滑动
     * @return 返回NestedScrollingParent是否消费全部惯性滑动
     */
    boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);

    /**
     * 在惯性滑动之前,将惯性滑动值分发给NestedScrollingParent
     * @param velocityX 水平方向的速度
     * @param velocityY 垂直方向的速度
     * @return 返回NestedScrollingParent是否消费全部惯性滑动
     */
    boolean dispatchNestedPreFling(float velocityX, float velocityY);
}
NestedScrollParent
public interface NestedScrollingParent {
    /**
     * 对NestedScrollingChild发起嵌套滑动作出应答
     * @param child 布局中包含下面target的直接父View
     * @param target 发起嵌套滑动的NestedScrollingChild的View
     * @param axes 滑动方向
     * @return 返回NestedScrollingParent是否配合处理嵌套滑动
     */
    boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes);

    /**
     * NestedScrollingParent配合处理嵌套滑动回调此方法
     * @param child 同上
     * @param target 同上
     * @param axes 同上
     */
    void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes);
   
    /**
     * 嵌套滑动结束
     * @param target 同上
     */
    void onStopNestedScroll(@NonNull View target);

    /**
     * NestedScrollingChild滑动完成后将滑动值分发给NestedScrollingParent回调此方法
     * @param target 同上
     * @param dxConsumed 水平方向消费的距离
     * @param dyConsumed 垂直方向消费的距离
     * @param dxUnconsumed 水平方向剩余的距离
     * @param dyUnconsumed 垂直方向剩余的距离
     */
    void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed);

    /**
     * NestedScrollingChild滑动完之前将滑动值分发给NestedScrollingParent回调此方法
     * @param target 同上
     * @param dx 水平方向的距离
     * @param dy 水平方向的距离
     * @param consumed 返回NestedScrollingParent是否消费部分或全部滑动值
     */
    void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed);

    /**
     * NestedScrollingChild在惯性滑动之前,将惯性滑动的速度和NestedScrollingChild自身是否需要消费此惯性滑动分
     * 发给NestedScrollingParent回调此方法
     * @param target 同上
     * @param velocityX 水平方向的速度
     * @param velocityY 垂直方向的速度
     * @param consumed NestedScrollingChild自身是否需要消费此惯性滑动
     * @return 返回NestedScrollingParent是否消费全部惯性滑动
     */
    boolean onNestedFling(@NonNull View target, float velocityX, float velocityY, boolean consumed);
    
    /**
     * NestedScrollingChild在惯性滑动之前,将惯性滑动的速度分发给NestedScrollingParent
     * @param target 同上
     * @param velocityX 同上
     * @param velocityY 同上
     * @return 返回NestedScrollingParent是否消费全部惯性滑动
     */
    boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY);

    /**
     * @return 返回当前嵌套滑动的方向
     */
    int getNestedScrollAxes();
}

在嵌套滑动机制中有两个角色:NestedScrollParent,NestedScrollChild。

为方便描述,在后文中 NestedScrollParent 用简称 NSP 代指,NestedScrollChild 用 NSC 代指。

这两个角色实际上是两个接口,接口定义了不少方法需要 View 来实现。

View 可以单独实现 NSP 或者 单独实现 NSC,也可以同时实现两者。

在下面的 demo 中,我们会自定义一个 Layout 并实现 NSP3,充当 Parent的角色,使用 RecyclerView 充当 Child 的角色,因为 RecyclerView 的默认实现中已经实现了 NSC3,所以我们不需要自己实现

所以本文着重讲述 NSP 接口执行流程和实现过程。

从上面的接口描述来看 NSP 接口的执行流程可能并不清晰,这里给张流程图方便理解:

10_NSP_API执行流程.png

从接口调用的时机来看,NSP 的接口由 NSC 调用,以 RecyclerView 为例,具体的实现过程主要包含在其 onTouchEvent() 方法中:

4_嵌套滑动实现流程.png

手指滑动的过程包含着一次 down 事件,一次 up 事件,和多个 move 事件。

当 down 事件传递给 Child 时,也即手指滑动的起始动作,手指按下屏幕的时候,Child 要询问一遍 Parent 是否一起处理本次事件,调用 Parent 的 onStartNestedScroll() 方法,如果 Parent 的 onStartNestedScroll() 方法返回 true,表示 Parent 要一起处理本次滑动事件序列,紧接着 Parent 的 onNestedScrollAccepted() 会被调用。反之,onStartNestedScroll() 方法返回 false,Parent 不需要一起处理本次事件序列,那么后续的事件 Parent 都不参与,直到下一个 down 事件的到来。

Parent 表示要一起处理滑动事件后,move 事件到达 Child 时,Child 会调用 Parent 的 onNestedPreScroll(),并将本次应该消费的滑动距离作为参数传入。Parent 可以在 onNestedPreScroll() 中消耗一定滑动距离,也可以完全不消耗。

Parent 执行完 onNestedPreScroll() 方法后,Child 会处理剩下的滑动距离,让自身滑动。

Child 执行完自己的滑动逻辑后,如果还有滑动距离没被消耗,那么会传入 Parent 的 onNestedScroll() 方法中,Parent 可以在该方法把剩下的滑动距离消耗。

最后 up 事件来时,手指已经抬起。如果此时手指滑动速度大于阈值,就会产生 fling 操作。

Child 进一步调用 Parent 的 onNestedPreFling() 和 onNestedFling() 方法,随后 Child 会模拟一般手指滑动的过程:顺序执行 onNestedStart(),onNestedScrollAccepted(),RecyclerView 自身滑动,onNestedPreScroll(),onNestedScroll() 过程,并向 Parent 的方法接口传入 type = TYPE_NON_TOUCH 表此时的嵌套滑动过程是通过 fling 模拟的。然后,Child 会调用 Parent 的 onStopNestedScroll(),告诉 Parent 本次滑动事件结束,嵌套滑动完毕。

如果手指滑动速度未超过阈值,不会产生 fling 操作,Child 直接调用 Parent 的 onStopNestedcroll() 嵌套滑动事件结束。

本文中 RecyclerView 就是那个 Child,自定义 Layout 则为 Parent。

本文不会详细介绍 RecyclerView 的源码,只会在源码的基础上,给出其嵌套滑动结论性的总结,有朋友对源码的具体实现有兴趣的话,欢迎自行阅读,本文基于 RecyclerView v1.2.1 版本的源码,你可以在工程中引入依赖阅读源码,具体实现的地方可参考其 onTouchEvent() 方法:

implementation "androidx.recyclerview:recyclerview:1.2.1"

以上就是实现本文效果需要知道的关于嵌套滑动的知识,有了这部分基础后,我们就可以用另一种方式来完美地是实现嵌套滑动。

接下来向大家一步步演示,如何实现开头时完美嵌套滑动的效果。

首先,先把布局的基础工作做了,让界面显示出来。

我们新建一个类,命名为 NestedOverScrollLayout,继承自 ViewGroup,NestedScrollingParent3,有许多未实现的接口,我们先放着,空实现。

open class NestedOverScrollLayout : ViewGroup, NestedScrollingParent3 {

    private var mVelocityTracker = VelocityTracker.obtain()
    private var mScroller = Scroller(context)

    private var mParentHelper: NestedScrollingParentHelper? = null

    private var mTouchSlop: Int = 0
    private var mMinimumVelocity: Float = 0f
    private var mMaximumVelocity: Float = 0f
    private var mCurrentVelocity: Float = 0f

    // 阻尼滑动参数
    private val mMaxDragRate = 2.5f
    private val mMaxDragHeight = 250
    private val mScreenHeightPixels = context.resources.displayMetrics.heightPixels

    private var mHandler: Handler? = null
    private var mNestedInProgress = false
    private var mIsAllowOverScroll = true           // 是否允许过渡滑动
    private var mPreConsumedNeeded = 0              // 在子 View 滑动前,此View需要滑动的距离
    private var mSpinner = 0f                       // 当前竖直方向上 translationY 的距离

    private var mReboundAnimator: ValueAnimator? = null
    private var mReboundInterpolator = ReboundInterpolator(INTERPOLATOR_VISCOUS_FLUID)

    private var mAnimationRunnable: Runnable? = null    // 用来实现fling时,先过度滑动再回弹的效果
    private var mVerticalPermit = false                 // 控制fling时等待contentView回到translation = 0 的位置

    private var mRefreshContent: View? = null

    constructor(context: Context) : super(context) {
        init()
    }

    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
        init()
    }

    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
        init()
    }

    private fun init() {
        setWillNotDraw(false)
        mHandler = Handler(Looper.getMainLooper())
        mParentHelper = NestedScrollingParentHelper(this)
        ViewConfiguration.get(context).let {
            mTouchSlop = it.scaledTouchSlop
            mMinimumVelocity = it.scaledMinimumFlingVelocity.toFloat()
            mMaximumVelocity = it.scaledMaximumFlingVelocity.toFloat()
        }
    }

    override fun onFinishInflate() {
        super.onFinishInflate()
        val childCount = super.getChildCount()

        for (i in 0 until childCount) {
            val childView = super.getChildAt(i)
            if (SmartUtil.isContentView(childView)) {
                mRefreshContent = childView
                break
            }
        }
    }


    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        var minimumWidth = 0
        var minimumHeight = 0
        val thisView = this
        for (i in 0 until super.getChildCount()) {
            val childView = super.getChildAt(i)
            if (childView == null || childView.visibility == GONE) continue

            if (mRefreshContent == childView) {
                mRefreshContent?.let { contentView ->
                    val lp = contentView.layoutParams
                    val mlp = lp as? MarginLayoutParams
                    val leftMargin = mlp?.leftMargin ?: 0
                    val rightMargin = mlp?.rightMargin ?: 0
                    val bottomMargin = mlp?.bottomMargin ?: 0
                    val topMargin = mlp?.topMargin ?: 0
                    val widthSpec = getChildMeasureSpec(
                        widthMeasureSpec,
                        thisView.paddingLeft + thisView.paddingRight + leftMargin + rightMargin, lp.width
                    )
                    val heightSpec = getChildMeasureSpec(
                        heightMeasureSpec,
                        thisView.paddingTop + thisView.paddingBottom + topMargin + bottomMargin, lp.height
                    )
                    contentView.measure(widthSpec, heightSpec)
                    minimumWidth += contentView.measuredWidth
                    minimumHeight += contentView.measuredHeight
                }
            }
        }

        minimumWidth += thisView.paddingLeft + thisView.paddingRight
        minimumHeight += thisView.paddingTop + thisView.paddingBottom
        super.setMeasuredDimension(
            resolveSize(minimumWidth.coerceAtLeast(super.getSuggestedMinimumWidth()), widthMeasureSpec),
            resolveSize(minimumHeight.coerceAtLeast(super.getSuggestedMinimumHeight()), heightMeasureSpec)
        )
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        val thisView = this
        for (i in 0 until super.getChildCount()) {
            val childView = super.getChildAt(i)
            if (childView == null || childView.visibility == GONE) continue

            if (mRefreshContent == childView) {
                mRefreshContent?.let { contentView ->
                    val lp = contentView.layoutParams
                    val mlp = lp as? MarginLayoutParams
                    val leftMargin = mlp?.leftMargin ?: 0
                    val topMargin = mlp?.topMargin ?: 0

                    val left = leftMargin + thisView.paddingLeft
                    val top = topMargin + thisView.paddingTop
                    val right = left + contentView.measuredWidth
                    val bottom = top + contentView.measuredHeight

                    contentView.layout(left, top, right, bottom)
                }
            }

        }
    }

    override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams {
        return MarginLayoutParams(context, attrs)
    }

    override fun onStartNestedScroll(child: View, target: View, axes: Int, type: Int): Boolean {
        return false
    }

    override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
    }

    override fun onStopNestedScroll(target: View, type: Int) {
    }

    override fun onNestedScroll(
        target: View,
        dxConsumed: Int,
        dyConsumed: Int,
        dxUnconsumed: Int,
        dyUnconsumed: Int,
        type: Int,
        consumed: IntArray
    ) {
    }

    override fun onNestedScroll(
        target: View,
        dxConsumed: Int,
        dyConsumed: Int,
        dxUnconsumed: Int,
        dyUnconsumed: Int,
        type: Int
    ) {
    }

    override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
    }
}

可以看到,类里面声明了很多属性,大部分现在还没有用到,不过现在加上,后面用到这些属性时,会再提到。

init() 方法中,作必要的初始化工作:

private fun init() {
    mHandler = Handler(Looper.getMainLooper())
    mParentHelper = NestedScrollingParentHelper(this)
    ViewConfiguration.get(context).let {
        mTouchSlop = it.scaledTouchSlop
        mMinimumVelocity = it.scaledMinimumFlingVelocity.toFloat()
        mMaximumVelocity = it.scaledMaximumFlingVelocity.toFloat()
    }
}

比如初始化一个 Handler,这个 handler 主要是为了后文做动画更新 UI 用的,所以传入主线程的 looper 即可。

因为 NestedOverScrollLayout 需要支持嵌套滑动,并在嵌套滑动中扮演 Parent 的角色,所以还需要初始化一个 NestedScrollingParentHelper() 辅助完成嵌套滑动操作。

最后是初始化一些变量:mTouchSlop,mMinimumVelocity,mMaximumVelocity,得到最小滑动距离阈值和滑动速度阈值。

在布局加载结束时,找到可以滚动的 View 作为内容布局,并赋值给 mRefreshContent 属性。

override fun onFinishInflate() {
    super.onFinishInflate()
    val childCount = super.getChildCount()

    for (i in 0 until childCount) {
        val childView = super.getChildAt(i)
        if (SmartUtil.isContentView(childView)) {
            mRefreshContent = childView
            break
        }
    }
}

// SmartUtil
object SmartUtil {

    fun isScrollableView(view: View?): Boolean {
        return view is AbsListView
                || view is ScrollView
                || view is ScrollingView
                || view is WebView
                || view is NestedScrollingChild
    }

    fun isContentView(view: View?): Boolean {
        return isScrollableView(view)
                || view is ViewPager
                || view is NestedScrollingParent
    }

}

onMeasure() 和 onLayout() 就根据 mRefreshContent 进行测量和布局。

为了简单起见,NestedOverScrollLayout 只会包含一个 RecyclerView,所以把这个 RecyclerView 的大小、位置测量了就行了。大家看代码估计也能懂,测量的细节就略过了。

有了这些方法后呢,在布局中使用 NestedOverScrollLayout 就应该有内容显示出来了。

我们新建一个 Activity,修改布局文件,给 RecyclerView 一些假数据:

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:id="@+id/eventViewGroup"
        android:orientation="vertical"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

    <com.jamgu.home.viewevent.nested.NestedOverScrollLayout
            android:id="@+id/eventView"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:background="#f1227f">

        <androidx.recyclerview.widget.RecyclerView
                android:id="@+id/vRecycler1"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:gravity="center" />

    </com.jamgu.home.viewevent.nested.NestedOverScrollLayout>

</FrameLayout>

看看效果。

3_布局初始化.gif

接下来,我们希望在 RecyclerView 在内容滑动到边界时,将无法消耗的滑动距离,交给 NestedOverScrollLayout 处理,根据上文对 NSP 接口调用时机的分析,RecyclerView 处理完自身滑动后,剩下的距离会传入 NestedOverScrollLayout 的 onNestedScroll() 方法,因此接下来要实现这个方法。

实现 onNestedScroll():让 RecyclerView 处理完自身滑动逻辑后,将剩下无法消耗的滑动距离交给 NestedOverScrollLayout 处理

向 NestedOverScrollLayout 中实现或加如下方法:

// 此 Parent 正在执行嵌套滑动时,会调用此方法,在这里实现嵌套滑动的逻辑
override fun onNestedScroll(
    target: View,
    dxConsumed: Int,
    dyConsumed: Int,
    dxUnconsumed: Int,
    dyUnconsumed: Int,
    type: Int
) {
    if (type == ViewCompat.TYPE_TOUCH) {
        onNestedScrollInternal(dyUnconsumed, type, null)
    }
}

// 此 Parent 正在执行嵌套滑动时,会调用此方法,在这里实现嵌套滑动的逻辑
// 与上面方法的区别,此方法多了个 consumed 参数,用于存放嵌套滑动执行完后,
// 被此 parent 消耗的滑动距离
override fun onNestedScroll(
    target: View,
    dxConsumed: Int,
    dyConsumed: Int,
    dxUnconsumed: Int,
    dyUnconsumed: Int,
    type: Int,
    consumed: IntArray
) {
    if (type == ViewCompat.TYPE_TOUCH) {
        onNestedScrollInternal(dyUnconsumed, type, consumed)
    } else {
        consumed[1] += dyUnconsumed
    }
}

@Synchronized
private fun onNestedScrollInternal(dyUnconsumed: Int, type: Int, consumed: IntArray?) {
    if (dyUnconsumed == 0) return
    // dy > 0 向上滚
    val dy = dyUnconsumed
    if (type == ViewCompat.TYPE_NON_TOUCH) {
        // fling 不处理,直接消耗
        if (consumed != null) {
            consumed[1] += dy
        }
    } else {
        if ((dy < 0 && mIsAllowOverScroll && WidgetUtil.canRefresh(mRefreshContent, null))
                || (dy > 0 && mIsAllowOverScroll && WidgetUtil.canLoadMore(
                    mRefreshContent,
                    null
                ))
        ) {
            mPreConsumedNeeded -= dy
            moveTranslation(computeDampedSlipDistance(mPreConsumedNeeded))
            if (consumed != null) {
                consumed[1] += dy
            }
        }
    }
}

private fun moveTranslation(dy: Float) {
    for (i in 0 until super.getChildCount()) {
        super.getChildAt(i).translationY = dy
    }
    mSpinner = dy
}

/**
 * 计算阻尼滑动距离
 * @param originTranslation 原始应该滑动的距离
 * @return Float, 计算结果
 */
private fun computeDampedSlipDistance(originTranslation: Int): Float {
    if (originTranslation >= 0) {
        /**
        final double M = mHeaderMaxDragRate < 10 ? mHeaderHeight * mHeaderMaxDragRate : mHeaderMaxDragRate;
        final double H = Math.max(mScreenHeightPixels / 2, thisView.getHeight());
        final double x = Math.max(0, spinner * mDragRate);
        final double y = Math.min(M * (1 - Math.pow(100, -x / (H == 0 ? 1 : H))), x);// 公式 y = M(1-100^(-x/H))
         */
        val dragRate = 0.5f
        val m = if (mMaxDragRate < 10) mMaxDragRate * mMaxDragHeight else mMaxDragRate
        val h = (mScreenHeightPixels / 2).coerceAtLeast(this.height)
        val x = (originTranslation * dragRate).coerceAtLeast(0f)
        val y = m * (1 - 100f.pow(-x / (if (h == 0) 1 else h)))
        return y
    } else {
        /**
        final float maxDragHeight = mFooterMaxDragRate < 10 ? mFooterHeight * mFooterMaxDragRate : mFooterMaxDragRate;
        final double M = maxDragHeight - mFooterHeight;
        final double H = Math.max(mScreenHeightPixels * 4 / 3, thisView.getHeight()) - mFooterHeight;
        final double x = -Math.min(0, (spinner + mFooterHeight) * mDragRate);
        final double y = -Math.min(M * (1 - Math.pow(100, -x / (H == 0 ? 1 : H))), x);// 公式 y = M(1-100^(-x/H))
         */
        val dragRate = 0.5f
        val m = if (mMaxDragRate < 10) mMaxDragRate * mMaxDragHeight else mMaxDragRate
        val h = (mScreenHeightPixels / 2).coerceAtLeast(this.height)
        val x = -(originTranslation * dragRate).coerceAtMost(0f)
        val y = -m * (1 - 100f.pow(-x / if (h == 0) 1 else h))
        return y
    }
}

从代码中可以发现有两个 onNestedScroll() 方法,它俩只有参数的区别,一个有 type 参数,一个有 consumed 参数,另一个没有。没有 consumed 参数的方法在 NestedScrollParent2 接口中定义,有 consumed 参数的方法在 NestedScrollParent3 接口被定义。

consumed 参数的意义在于,可以将 Parent 消耗的滑动距离记录下来,使 Child 能够知道 Parent 消耗了多少滑动距离。

这两个方法最终都会走到我们定义的 onNestedScrollInternal() 方法中:

@Synchronized
private fun onNestedScrollInternal(dyUnconsumed: Int, type: Int, consumed: IntArray?) {
    if (dyUnconsumed == 0) return
    // dy > 0 向上滚
    val dy = dyUnconsumed
    if (type == ViewCompat.TYPE_NON_TOUCH) {
        // fling 不处理,直接消耗
        if (consumed != null) {
            consumed[1] += dy
        }
    } else {
        if ((dy < 0 && mIsAllowOverScroll && WidgetUtil.canRefresh(mRefreshContent, null))
                || (dy > 0 && mIsAllowOverScroll && WidgetUtil.canLoadMore(
                    mRefreshContent,
                    null
                ))
        ) {
            mPreConsumedNeeded -= dy
            moveTranslation(computeDampedSlipDistance(mPreConsumedNeeded))
            if (consumed != null) {
                consumed[1] += dy
            }
        }
    }
}

因为我们是竖向的嵌套滑动,所以只需要关注竖向剩余的滑动距离 dyUnconsumed。

type = ViewCompat.TYPE_NON_TOUCH 时,表示当前嵌套滑动是因 fling 操作引起的,fling 引起的嵌套滑动我们不需要在此做太多处理,如果 fling 经过 Child 处理后,还有剩余的 fling 滑动距离,我们就直接消耗。

主要关注的还是 type = ViewCompat.TYPE_TOUCH 的情况,也即因手指拖动产生的嵌套滑动事件。

if ((dy < 0 && mIsAllowOverScroll && WidgetUtil.canRefresh(mRefreshContent, null))
        || (dy > 0 && mIsAllowOverScroll && WidgetUtil.canLoadMore(
            mRefreshContent,
            null
        ))
) {
    mPreConsumedNeeded -= dy
    moveTranslation(computeDampedSlipDistance(mPreConsumedNeeded))
    if (consumed != null) {
        consumed[1] += dy
    }
}

dy > 0 表示手指当前拖动方向是向上的,如果此时 mRefreshContent,也就是我们的 RecyclerView 的内容不能再往上滚动时,WidgetUtil.canLoadMore(mRefreshContent, null) 会返回 true。WidgetUtil 是我之前封装的一个方法,大家不必纠结它的具体实现,它的细节也不在本文讨论范围内,大家直接拿来用即可,WidgetUtil代码地址在这里。

同样,当 dy < 0,手指当前拖动方向是向下的,如果 RecyclerView 内容不能再向下滚动时,WidgetUtil.canRefresh(mRefreshContent, null)) 方法会返回 true。

这两个条件合起来的意思是,如果当前 RecyclerView 已经滚动到内容边界且即将过度滑动时,会满足条件,进入 if 语句。

在 if 语句中,我们需要让 NestedOverScrollLayout 消耗掉当前未被消耗的 y 距离,也就是 dy。

mPreConsumedNeeded -= dy

mPreConsumedNeeded 属性存储的是 NestedOverScrollLayout 内容需要偏移的原始距离,其值大于0时,内容向下偏移,反之向上偏移

因为进入到 if 语句中说明 RecyclerView 已经滑动到边界了,如果是达到 RecyclerView 的内容上边界,若此时手指是往下滑的动作,dy < 0,RecyclerView 内容无法向下滚动了,应该让 RecyclerView 整体向下移动 -dy 的距离。

怎么让 RecyclerView 整体向下移动呢?就是让 RecyclerView 的 translationY 属性减去一个 dy 的距离。但我们在偏移之前还需要作其它操作,因此先将这个距离存储在 mPreConsumedNeeded 属性中。

RecyclerView 滑动到内容下边界并且手指向上滑时的情况也是如此,dy > 0,应该让 RecyclerView 整体向上移动 dy 的距离,让 RecyclerView 的 translationY 属性减去一个 dy 的距离。

mPreConsumedNeeded 的意义搞清楚后,下一步就要修改 translationY 值让 RecyclerView 整体移动。

moveTranslation(computeDampedSlipDistance(mPreConsumedNeeded))

为了构造过度滑动时的阻尼效果,在移动 RecyclerView 前还行要经过 computeDampedSlipDistance() 方法的计算,用公式 y = M(1-100^(-x/H)) ,让 mPreConsumedNeeded 转换为一个阻尼的滑动距离。

y = M(1-100^(-x/H) 意义

简单解释一下公式 y = M(1-100^(-x/H)) 的意义:

这个公式中 m 和 h 都是常数,当我们假定 m = 625,h = 2000 时,函数曲线为:

5_函数曲线展示.png

当 x 不断增加,y 先是缓慢上升,但随着 x 越来越大,y 的值趋向平稳,最后不变。

对应阻尼滑动的效果来说就是,阻尼滑动时,滑动的距离速度乘递减形式,渐渐的滑动速度越来越慢,最终怎么滑都滑不动了。

得出阻尼滑动距离后,传入 moveTranslation() 方法,让 RecyclerView 偏移:

private fun moveTranslation(dy: Float) {
    for (i in 0 until super.getChildCount()) {
        super.getChildAt(i).translationY = dy
    }
  	// mSpinner 存储的是真正偏移过后的距离
    mSpinner = dy
}

方法也很简单,值得说明的是 mSpinner 这个属性,它与 mPreConsumedNeeded 类似,都是 NestedOverScrollLayout 的内容偏移距离,不同的是,mPreConsumedNeeded 不会直接用来偏移,还会经过一步阻尼计算。而 mSpinner 是内容真正偏移后的距离,它俩意义一样,但值是有区别的,正常来说 mSpinner 的绝对值会比 mPreConsumedNeeded 的绝对值大。

到此,我们已经可以让 RecyclerView 内容无法滑动时,整体过度滑动一段距离了.

最后,别忘了在 NestedOverScrollLayout 中实现下面这段代码,这些是让嵌套滑动能够工作的基础代码:

// 嵌套滑动开始时调用,
// 方法返回 true 时,表示此Parent能够接收此次嵌套滑动事件
// 返回 false,不接收此次嵌套滑动事件,后续方法都不会调用
override fun onStartNestedScroll(child: View, target: View, axes: Int, type: Int): Boolean {
  	// 接受竖直方向的嵌套滑动
    return axes and ViewCompat.SCROLL_AXIS_VERTICAL != 0
}

// 当 onStartNestedScroll() 方法返回 true 后,此方法会立刻调用
// 可在此方法做每次嵌套滑动的初始化工作
override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
    mParentHelper?.onNestedScrollAccepted(child, target, axes, type)
}

// 当嵌套滑动即将结束时,会调用此方法
override fun onStopNestedScroll(target: View, type: Int) {
    mParentHelper?.onStopNestedScroll(target, type)
}

来看看效果。

6_过度阻尼滑动演示.gif

大家如果一步一步跟着来实现,自己操作下会发现确实有阻尼滑动的效果,但目前滑动体验还不好,比如 RecyclerView 已经整体向下偏移一段距离后,手指向上滑动,此时应该先让 RecyclerView 整体位置恢复到原位后,再让 RecyclerView 的内容向上滚动,现在实现的过程明显反了过来,比如下面这样:

7_过度滑动bug演示.gif

分析下为什么会产生这样的效果,我们此时实现的过度滑动处理时机是在 NestedOverScrollLayout 的 onNestedScroll() 方法中,这个方法在 RecyclerView 处理自身的滚动后才会被调用,如果在调用 onNestedScroll() 方法前,滑动距离已经被 RecyclerView 消耗完了,dyUnConsumed = 0,那么 onNestedScroll() 调用是不会有任何效果的,从我们的实现来看也确实如此:

@Synchronized
private fun onNestedScrollInternal(dyUnconsumed: Int, type: Int, consumed: IntArray?) {
    if (dyUnconsumed == 0) return
    // dy > 0 向上滚
    ...
}

而要实现 RecyclerView 整体位置恢复到原位后,再让 RecyclerView 的内容向上滚动,处理时机显然需要在 RecyclerView 消耗滑动距离前。

还记得上面的嵌套滑动实现流程图吗,在 RecyclerView 处理滑动前有一个方法:onNestedPreScroll(),这个方法会 RecyclerView 消耗滑动距离前调用,在这里实现完美符合我们的要求。

实现 onNestedPreScroll():RecyclerView 整体位置恢复到原位后,再让 RecyclerView 的内容向上滚动。

如果读了上文,你对 mPreConsumedNeeded 意义还是懵懂的状态,那么阅读下面的代码后,相信会加深你对该属性的理解。

override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
    if (dy == 0) return

    // 触摸事件的嵌套滑动才处理
    if (type == ViewCompat.TYPE_TOUCH) {
        val consumedY: Int
        // 两者异向,加剧过度滑动
        if (mPreConsumedNeeded * dy < 0) {
            consumedY = dy
            mPreConsumedNeeded -= dy
            moveTranslation(computeDampedSlipDistance(mPreConsumedNeeded))
        } else {
            // 两者同向,需先将 mPreConsumedNeeded 消耗掉
            val lastConsumedNeeded = mPreConsumedNeeded
            if (dy.absoluteValue > mPreConsumedNeeded.absoluteValue) {
                consumedY = mPreConsumedNeeded
                mPreConsumedNeeded = 0
            } else {
                consumedY = dy
                mPreConsumedNeeded -= dy
            }
            if (lastConsumedNeeded != mPreConsumedNeeded) {
                moveTranslation(computeDampedSlipDistance(mPreConsumedNeeded))
            }
        }
        consumed[1] = consumedY
    }
}

直接看 if(mPreConsumedNeeded * dy < 0) 这段,如果 mPreConsumedNeeded 与 dy 相乘小于0,说明这两者的值一正一负。我们知道,dy > 0 时手指时向上滑动的,而 mPreConsumedNeeded < 0 时 RecyclerView 整体是有一个向上的偏移量的。

8_mPreConsumed属性展示.png

这时候应该顺着手指滑动的方向,将 dy 完整地消耗掉,加剧过度滑动。

consumedY = dy
mPreConsumedNeeded -= dy
moveTranslation(computeDampedSlipDistance(mPreConsumedNeeded))

反之,如果 mPreConsumedNeeded 与 dy 不同号,此时在竖直方向上有一个过度滑动的距离,就应该复原过度滑动。

举个例子,mPreConsumedNeeded < 0 && dy < 0,RecyclerView 整体向上偏移,手指向下滑动,应该先让 RecyclerView 的整体位置向下移动直至复原,然后再让 RecyclerView 内容滚动。

所以需要先将 mPreConsumedNeeded 消耗掉。

如果当前 dy 的值足够消耗掉 mPreConsumedNeeded,则让 consumedY 直接等于 mPreConsumedNeeded,如果不够的话,就让 consumedY 等于 dy。

最后调用 moveTranslation() 让 RecyclerView 整体偏移。

consumed[1] = consumed,将在 NestedOverScrollLayout(Parent)这里消耗的距离保存,以便 RecyclerView(Child) 后续能够知道被消耗了多少距离。

跑一下,看看效果:

9_onPreScroll实现展示.gif

可以看到,一个基本的嵌套滑动效果已经有了,但目前 NestedOverScrollLayout 在过度滑动后,手指脱离屏幕时无法自己回到初始状态。

接下来就完善这点体验,让 NestedOverScrollLayout 每次过度滑动后都能自己回弹到初始位置。

在 NestedOverScrollLayout 中新建一个方法 animSpinner()

/**
 * 通过动画模拟滑动到 translationY = [endSpinner] 处
 * @param endSpinner 最终要滑动到的距离
 * @param startDelay 动画延迟开始时间 ms
 * @param interpolator 动画插值器
 * @param duration 动画持续时间
 * @return ValueAnimator 执行动画的对象
 */
private fun animSpinner(
    endSpinner: Float,
    startDelay: Long,
    interpolator: Interpolator?,
    duration: Long
): ValueAnimator? {
    if (mSpinner != endSpinner) {
        JLog.d(TAG, "start anim")
        mReboundAnimator?.let {
            it.duration = 0
            it.cancel()
        }
        mAnimationRunnable = null
        val endListener = object : AnimatorListenerAdapter() {
            override fun onAnimationEnd(animation: Animator?) {
                // cancel() 会导致 onAnimationEnd,通过设置duration = 0 来标记动画被取消
                if (animation != null && animation.duration == 0L) {
                    return
                }

                mReboundAnimator?.let {
                    it.removeAllUpdateListeners()
                    it.removeAllListeners()
                }
                mReboundAnimator = null
            }
        }
        val updateListener = ValueAnimator.AnimatorUpdateListener {
            val spinner = it.animatedValue as? Int ?: 0
            moveTranslation(spinner.toFloat())
        }
        ValueAnimator.ofInt(mSpinner.roundToInt(), endSpinner.roundToInt())
                .also { mReboundAnimator = it }.let {
                    it.duration = duration
                    it.interpolator = interpolator
                    it.startDelay = startDelay
                    it.addListener(endListener)
                    it.addUpdateListener(updateListener)
                    it.start()
                }

        return mReboundAnimator
    }

    return null
}

通过方法注释也可以看出,这个方法是通过 ValueAnimator 模拟手指的滑动操作,使 NestedOverScrollLayout 内容移动到指定的偏移位置 endSpinner。

具体过程呢,就是通过 ValueAnimator.ofInt(mSpinner, endSpinner) 方法获得从 mSpinner 到 endSpinner 一个连续的插值,这个插值也就是 NestedOverScrollLayout 的内容偏移量,最后通过 moveTranslation() 让 NestedOverScrollLayout 移动内容。

我们可以通过传入不同的 interpolator 插值器来控制复原过程的速率变化,ValueAnimator 默认使用的 LinearInterpolator() 线性插值器,匀速地执行动画。

在 NestedOverScrollLayout 的开头,我们定义了一个自己实现的插值器,我们将用这个来模拟一个速度递减的动画过程。

private var mReboundInterpolator = ReboundInterpolator(INTERPOLATOR_VISCOUS_FLUID)

大家不必关心 ReboundInterpolator 类中具体是如何实现,只需要知道,通过这个插值器,能让 NestedOverScrollLayout 内容复原过程呈一个速度递减的状态。直接拿来用即可,具体源码地址

如果我们传入的 endSpinner 为 0,那么就是让 NestedOverScrollLayout 的内容偏移量恢复到 0 的位置,也即初始位置。

在 NestedOverScrollLayout 中定义一个方法:

private fun overSpinner() {
  	// 在 1000 ms 内,让内容恢复到偏移量为 0 的状态
    animSpinner(0f, 0, mReboundInterpolator, 1000)
}

并在 onStopNestedScroll() 方法中调用它,onStopNestedScroll() 方法在每次嵌套滑动结束时都会被调用,在这里让 NestedOverScrollLayout 内容复原正合适:

override fun onStopNestedScroll(target: View, type: Int) {
    mParentHelper?.onStopNestedScroll(target, type)
    overSpinner()
}

同样,运行,看看效果如何:

11_恢复初始状态展示.gif

这下体验好很多了,不过还是有 bug,如果我们在 NestedOverScrollLayout 内容还没有回滚动到初始化状态时,再次用手下滑或上滑,回滚动画会出现奇怪的效果。这是因为上一个回滚动画还没有结束,我们就进行了滑动的操作,导致 translationY 的数据错乱,出现连续滑动不连贯,内容甚至跳跃的现象。

13_快速滑动bug演示.gif

因此我们需要在下一次手指滑动时,终止掉上一次没完成的回滚动画,继续优化。

优化快速滑动时,内容跳跃的 bug

在 NestedOverScrollLayout 中维护一个属性 mNestedInProgress,如果 NestedOverScrollLayout 当前正在处理嵌套滑动,该属性为 true,反之为 false。

// 当 onStartNestedScroll() 方法返回 true 后,此方法会立刻调用
// 可在此方法做每次嵌套滑动的初始化工作
override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
    mParentHelper?.onNestedScrollAccepted(child, target, axes, type)
  	// 新增这两行
    mPreConsumedNeeded = reverseComputeFromDamped2Origin(mSpinner)
    mNestedInProgress = true
}

// 当嵌套滑动即将结束时,会调用此方法
override fun onStopNestedScroll(target: View, type: Int) {
    mParentHelper?.onStopNestedScroll(target, type)
  	// 新增这一行
    mNestedInProgress = false
    overSpinner()
}

然后实现 NestedOverScrollLayout 的 dispatchTouchEvent() 方法:

override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
    ev ?: return false
    // 如果处于嵌套滑动状态,正常下发,以确保嵌套滑动的正常运行。
    if (mNestedInProgress) {
        return super.dispatchTouchEvent(ev)
    }

    return super.dispatchTouchEvent(ev)
}

mNestedInProgress 属性主要是为了确保正常的嵌套滑动逻辑得以运行。其为 true 时,调用 super.dispatchTouchEvent() 让 action 按照默认逻辑下发给 RecyclerView,按照默认逻辑 RecyclerView 会在 onTouchEvent() 中调用 NestedOverScrollLayout 的嵌套滑动方法。

mPreConsumedNeeded = reverseComputeFromDamped2Origin(mSpinner)

同时在下一次嵌套滑动开始时,需要重置 mPreConsumedNeeded 的值。

还记得 mPreConsumedNeeded 属性的意义吗,我们每次嵌套滑动 NestedOverScrollLayout 的内容时,是先将 mPreConsumedNeeded 经过计算得到阻尼滑动值,再进行内容的移动,然后将移动后偏移量 translationY 赋值给 mSpinner。所以从 mSpinner 转变成 mPerConsumedNeeded 需要一个逆阻尼计算的过程,reverseComputeFromDamped2Origin() 方法就是做这样一件事。

怎么逆阻尼计算呢?我们用公式 y = M(1-100^(-x/H) ,将 原始移动距离 x 转换成阻尼移动距离 y,只需将公式反过来,根据对数与指数的转换公式,已知 y,逆推导 x,得到另一条公式 X = -H * log((1 - y / m), 100),运用这条公式就可以进行逆阻尼计算了。

/**
 * 给出阻尼计算的距离,计算原始滑动距离
 * @param dampedDistance 阻尼计算过后的距离
 * @return Float, 计算结果
 */
private fun reverseComputeFromDamped2Origin(dampedDistance: Float): Int {
    return if (dampedDistance >= 0) {
        // X = -H * log((1 - y / m), 100)
        val dragRate = 0.5f
        val m = if (mMaxDragRate < 10) mMaxDragRate * mMaxDragHeight else mMaxDragRate
        val h = (mScreenHeightPixels / 2).coerceAtLeast(this.height)
        val y = dampedDistance
        JLog.d(TAG, "reverse ${(-h * log((1 - y / m), 100f))}")
        ((-h * log((1 - y / m), 100f)) / dragRate).roundToInt()
    } else {
        val dragRate = 0.5f
        val m = if (mMaxDragRate < 10) mMaxDragRate * mMaxDragHeight else mMaxDragRate
        val h = (mScreenHeightPixels / 2).coerceAtLeast(this.height)
        val y = -dampedDistance
        -((-h * log((1 - y / m), 100f)) / dragRate).roundToInt()
    }
}

随后我们在 NestedOverScrollLayout 中新实现一个方法:

/**
 * 根据条件,是否拦截事件
 * 如果是 down 事件,会终止回弹动画
 */
private fun interceptReboundByAction(action: Int): Boolean {
    if (action == MotionEvent.ACTION_DOWN) {
        mReboundAnimator?.let {
            it.duration = 0
            it.cancel()
        }
        mReboundAnimator = null
    }
    return mReboundAnimator != null
}

并在 dispatchTouchEvent() 和 onNestedScrollAccepted() 方法中调用它:

override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
    ev ?: return false
    // 如果处于嵌套滑动状态,正常下发,以确保嵌套滑动的正常运行。
    if (mNestedInProgress) {
        return super.dispatchTouchEvent(ev)
    }

  	// --------- 新增如下代码 ---------
    val action = ev.actionMasked
    if (interceptReboundByAction(action)) {
        return false
    }
		// --------------end--------------
    return super.dispatchTouchEvent(ev)
}

// 当 onStartNestedScroll() 方法返回 true 后,此方法会立刻调用
// 可在此方法做每次嵌套滑动的初始化工作
override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
    JLog.d(TAG, "onNestedScrollAccepted")
    mParentHelper?.onNestedScrollAccepted(child, target, axes, type)
    mPreConsumedNeeded = reverseComputeFromDamped2Origin(mSpinner)
    mNestedInProgress = true

  	// 新增下面一行代码
    interceptReboundByAction(MotionEvent.ACTION_DOWN)
}

interceptReboundByAction(action) 返回 true 时,表此时正在执行回弹动画,会让 dispatchTouchEvent() 方法直接返回 false,该事件也不会分发给其下级 RecyclerView,而是上抛给其上级 ViewGroup,意味着 NestedOverScrollLayout 不处理该事件,让回弹动画继续执行。

interceptReboundByAction(action) 返回 false 时,意味着此时 NestedOverScrollLayout 没有在执行回弹动画,让事件正常下发。没有执行回弹动画可能是因为本来之前也没有滑动操作触发该动画,也可能是因为新到来的事件是一个全新的滑动事件,需要终止之前未完成的回弹动画。

运行一下,看看效果:

12_快速滑动演示.gif

快速滑动很多下都没有问题,NestedOverScrollLayout 更加完善了。

但现在还有一个问题,我们的 NestedOverScrollLayout 不支持 Fling 操作,一般来说,当手指在屏幕上以很快的速度滑动时,手指离开后,内容应该按照手指滑动的惯性,再滑动一定距离,具体效果如下:

15_最终效果演示.gif

而目前的滑动体验是这样的,手指脱离屏幕后,内容就不动了:

14_不支持fling演示.gif

接下来的内容,就为了优化这一点,也是最后一段优化。

让 NestedOverScrollLayout 支持 Fling 操作

在 NestedOverScrollLayout 中添加如下方法:

override fun onNestedPreFling(target: View, velocityX: Float, velocityY: Float): Boolean {
    // 返回 true,会接管子 View 的 fling 事件,子 View 的 fling 代码不会执行。
    return startFlingIfNeed(-velocityY)
}

private fun startFlingIfNeed(flingVelocity: Float): Boolean {
    val velocity = if (flingVelocity == 0f) mCurrentVelocity else flingVelocity
    if (velocity.absoluteValue > mMinimumVelocity) {
        if (velocity < 0 && mIsAllowOverScroll && mSpinner == 0f
                || velocity > 0 && mIsAllowOverScroll && mSpinner == 0f
        ) {
            mScroller.fling(0, 0, 0, (-velocity).toInt(), 0, 0, -Int.MAX_VALUE, Int.MAX_VALUE)
            mScroller.computeScrollOffset()
            val thisView: View = this
            thisView.invalidate()
        }
    }

    return false
}

// 这个方法会被多次调用,直至满足过度滑动的条件:
// finalY < 0 && WidgetUtil.canRefresh(mRefreshContent, null)
// || finalY > 0 && WidgetUtil.canLoadMore(mRefreshContent, null
override fun computeScroll() {
    if (mScroller.computeScrollOffset()) {
        val finalY = mScroller.finalY
        if (finalY < 0 && WidgetUtil.canRefresh(mRefreshContent, null)
                || finalY > 0 && WidgetUtil.canLoadMore(mRefreshContent, null)
        ) {
            if (mVerticalPermit) {
                val velocity = if (finalY > 0) -mScroller.currVelocity else mScroller.currVelocity
                // 可以过度滑动后,通过动画模拟惯性滑动的过程
                animSpinnerBounce(velocity)
            }
            mScroller.forceFinished(true)
        } else {
            mVerticalPermit = true
            val thisView = this
            thisView.invalidate()
        }
    }
}

/**
 * 惯性滑动后回弹动画
 * @param velocity 速度
 */
protected fun animSpinnerBounce(velocity: Float) {
    // 模拟惯性滑动时,回弹动画必须已经停止
    if (mReboundAnimator == null) {
        JLog.d(TAG, "animSpinnerBounce = $mSpinner")
        if (mSpinner == 0f && mIsAllowOverScroll) {
            // 执行 BounceRunnable
            mAnimationRunnable = BounceRunnable(velocity, 0)
        }
    }
}

protected inner class BounceRunnable internal constructor(var mVelocity: Float, var mSmoothDistance: Int) :
    Runnable {
    var mFrame = 0
    var mFrameDelay = 10
    var mLastTime: Long
    var mOffset = 0f
    override fun run() {
        if (mAnimationRunnable === this) {
            mVelocity *= if (abs(mSpinner) >= abs(mSmoothDistance)) {
                if (mSmoothDistance != 0) {
                    0.45.pow((++mFrame * 2).toDouble()).toFloat() //刷新、加载时回弹滚动数度衰减
                } else {
                    0.85.pow((++mFrame * 2).toDouble()).toFloat() //回弹滚动数度衰减
                }
            } else {
                0.95.pow((++mFrame * 2).toDouble()).toFloat() //平滑滚动数度衰减
            }
            val now = AnimationUtils.currentAnimationTimeMillis()
            val t = 1f * (now - mLastTime) / 1000
            val velocity = mVelocity * t
            // 还有速度时,就加剧过度滑动
            if (abs(velocity) >= 1) {
                mLastTime = now
                mOffset += velocity
                moveTranslation(computeDampedSlipDistance(mOffset.roundToInt()))
                mHandler?.postDelayed(this, mFrameDelay.toLong())
            } else {
                // 没有速度后,通过 reboundAnimator,回弹至初始位置
                mAnimationRunnable = null
                if (abs(mSpinner) >= abs(mSmoothDistance)) {
                    val duration = 10L * (abs(mSpinner - mSmoothDistance).dp2px(context))
                            .coerceAtLeast(30).coerceAtMost(100)
                    animSpinner(mSmoothDistance.toFloat(), 0, mReboundInterpolator, duration)
                }
            }
        }
    }

    init {
        mLastTime = AnimationUtils.currentAnimationTimeMillis()
        mHandler?.postDelayed(this, mFrameDelay.toLong())
    }
}

手指快速滑动产生 fling 操作后,RecyclerView 在执行自身的 fling 逻辑前,会先调用 NestedOverScrollLayout 的 onNestedPreFling() 方法,我们可以在这里模拟 NestedOverScrollLayout 的 fling 操作。

具体实现过程是利用 Scroller.fling() 方法,通过将 fling 的 y 速度传入该方法,该方法会通过 y 速度得到 NestedOverScrollLayout fling 最终会到达的位置,然后调用 NestedOverScrollLayout 的 invalidate() 方法让 Layout 重绘,重绘过程中会调用 Layout 的 computeScroll() 方法。

在 computeScroll() 方法,有一个递归调用:

if (finalY < 0 && WidgetUtil.canRefresh(mRefreshContent, null)
        || finalY > 0 && WidgetUtil.canLoadMore(mRefreshContent, null)
) {
    if (mVerticalPermit) {
        val velocity = if (finalY > 0) -mScroller.currVelocity else mScroller.currVelocity
        // 可以过度滑动后,通过动画模拟惯性滑动的过程
        animSpinnerBounce(velocity)
    }
    mScroller.forceFinished(true)
} else {
    mVerticalPermit = true
    val thisView = this
    thisView.invalidate()
}

如果子 View,也就是 RecyclerView 在 fling 的过程中还没有到达内容边界,那么就会再调用一次 Layout 的 invalidate() 方法,invalidate() 方法最终又会调用 computeScroll()。

如此反复,直到 RecyclerView fling 到内容边界时,这个递归调用才会终止,并调用 animSpinnerBounce(velocity) 开始让 NestedOverScrollLayout 模拟过度滑动再回弹到初始位置的过程。

animSpinnerBounce(velocity) 方法里的内容我就不详细介绍了,相信大家根据代码中的注释,自己思考下,应该能够理解。

最后,你得到的就是文章开头的效果:

1_嵌套滑动展示.gif

DEMO 的拓展使用场景

在本文的 demo 中,NestedOverScrollLayout 的子 View 中只有一个 RecyclerView,实际上,你可以拓展它,让它同时支持更多的子 View,需要做的修改就是让 NestedOverScrollLayout 的 onMeasure() 和 onLayout() 方法能够适配多个子 View 的情况。

在日常开发中,我们经常会碰到列表上拉加载和下拉刷新的场景。这个 demo 就是这种场景的简化版,只需要让 NestedOverScrollLayout 最多能够支持三个子 View,分别是最上面的 HeaderView,最下面的 FooterView,以及中间的 ContentView(RecyclerView)。

通过监听当前内容的移动距离,是否达到上拉加载或下拉刷新的移动阈值来做进一步的 UI 变化和业务拉取,比如下面这样:

16_smartrefreshlayout演示.gif

上面的效果图来自 SmartRefreshLayout 刷新组件库,实际上,本篇实现的 DEMO 也是参考自该库,只不过我简化了很多东西,是这个库的究极简化版,也是这个库的核心内容之一。希望大家看完本篇内容后,都能够实现一个自己的刷新组件库,

源码地址在这,有需要的朋友自取。本篇内容就到此结束了,希望你对你有所帮助!

兄dei,如果觉得我写的还不错,麻烦帮个忙呗 :-)

  1. 给俺点个赞被,激励激励我,同时也能让这篇文章让更多人看见,(#^.^#)
  2. 不用点收藏,诶别点啊,你怎么点了?这多不好意思!
  3. 噢!还有,我维护了一个路由库。。没别的意思,就是提一下,我维护了一个路由库 =.= !!

拜托拜托,谢谢各位同学!