[Android]触摸、滑动与嵌套滑动(四)事件传递机制与嵌套滑动

895 阅读9分钟


如何实现舒服的嵌套滑动

当我们ScrollView套着ScrollView会出现一个问题,假设手指一次性滑动了500pix,内层ScrollView消耗了300pix,即使还余下200pix也不能继续滑动了,而是必须抬起手指,再滑动一次才可以让外层的ScrollView被滑动:

而我们希望的是,当ScrollView滑动了300pix之后,将余下的200pix交给父View,也就是外层的ScrollView进行滑动:

这按照传统的事件传递机制似乎是不可行的,因为现行的事件传递机制规定了:一组事件(DOWN/MOVE/UP)必须由一个View消费完成,除非外层的视图主动拦截,才会出现外层视图不接受ACTION_DOWN只接受ACTION_MOVE的情况。

而现在的问题是,ScrollView嵌套ScrollView的时候,内层的ScrollView根本无法滑动:

所以在之前的内容中,我们的处理方式是,在外层的ScrollView的onInterceptTouchEvent()根据一定情况返回false,旨在告诉外层ScollView如果内层视图可以滑动则不拦截事件,但是我们难以控制什么时候可以让外层View重新获得事件。

假设,我们希望在内层ScrollView无法再向上滑动的时候,即滑动量达到300pix的时候,将事件交给父View处理,我们可以有两种解决方案:

  1. 父View在收到子View某种信号的时候主动去拦截子View的事件。就和之前ScrollView和Button嵌套的时候,Button收到ACTION_DOWN的时候那样。
  2. 不上交事件,只把滑动量交给父View来处理,余下的200pix通过某种方法让父View换一种方式来消费这个滑动量,而不是事件。

显然,2是更为合理的,我们看看之前的这一种效果图,我们可以发现在父View接收了余下的滑动量之后,我们重新下滑,此时立即响应的是子View下滑,而不是父View下滑。而1中,如果我们去拦截子View事件后,再重新下发,就目前的滑动模型来说,是非常困难的。

1. NestedScrollView是怎么做的

我们可以先监听一下官方NestedScrollView嵌套时,它们的事件是如何传递的,就可以知道在派发剩余滑动量时,消费事件的究竟是子View还是父View。

还是比较明显的,即使嵌套滑动发生的时候,消费事件的View仍然是内层的NestedScrollView:

Out::dispatchTouchEvent,true
// 以下为一组(因为是在super.onTouchEvent之后调用的打印,所以看着是反着的)
Inner::onTouchEvent,true
Inner::dispatchTouchEvent,true
Out::dispatchTouchEvent,true
//
D/rEd: Inner::onTouchEvent,true
D/rEd: Inner::dispatchTouchEvent,true
Out::dispatchTouchEvent,true

因为是先调用的super,再打印的日志,所以看着是反着的。但是关键是在Inner::onTouchEvent中返回的true,这就说明了,嵌套滑动子View只是将多余的滑动量派发到父View,并没有放弃事件

如果是子View滑到底,然后抬起手指,再在子View上滑动呢?此时接受事件的是谁?

此时子View仍然是无法向上滑动的(因为到底了),滚动的是父View,但是接受事件的仍是子View,也就是说,父View在任何情况下都就将事件派发给子View了,而不是自己去滑动(当然点击事件得发生在父View和子View重合的区域)。

这就说明了,嵌套滑动相关的工具类:NestedScrollParent、NestedScrollChild等等,要解决的核心问题,就是:NestedScrollChild向NestedScrollParent派发多余的滑动量,而Child需要处理:

  • 什么时候产生多余滑动量(到底/顶之后多余的滑动量);
  • 如何派发、怎么派发;

Parent需要处理:

  • 如何接受多余的滑动量;
  • 在事件传递机制大框架之外额外进行滑动;

2. NestedScrollChild与NestedScrollParent

我们看看这两个接口对应的一些方法,首先是NestedScrollingChild3,如果你直接继承它,你要重写如下的方法:

override fun startNestedScroll(axes: Int, type: Int): Boolean
override fun stopNestedScroll(type: Int)
override fun hasNestedScrollingParent(type: Int): Boolean

这三个看着还算正常,也比较好理解。下面前两个显然是用来派发滑动量的,第三个dispatchNestedPreScroll,则为嵌套滑动操作中的父View提供了,在子View使用滑动操作之前使用部分或全部滚动操作的机会

override fun dispatchNestedScroll(
    dxConsumed: Int,
    dyConsumed: Int,
    dxUnconsumed: Int,
    dyUnconsumed: Int,
    offsetInWindow: IntArray?,
    type: Int,
    consumed: IntArray
) 

override fun dispatchNestedScroll(
    dxConsumed: Int,
    dyConsumed: Int,
    dxUnconsumed: Int,
    dyUnconsumed: Int,
    offsetInWindow: IntArray?,
    type: Int
): Boolean

override fun dispatchNestedPreScroll(
    dx: Int,
    dy: Int,
    consumed: IntArray?,
    offsetInWindow: IntArray?,
    type: Int
): Boolean

而Parent的也基本上能够从名称中看出大致的作用。

override fun onStartNestedScroll(child: View, target: View, axes: Int, type: Int): Boolean 
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)

常用的组件中,NestedScrollView分别实现了NestedScrollParent和NestedScrollChildren;RecyclerView则实现了NestedScrollChild,而CoordinatorLayout则实现了NestedScrollParent,后续的阅读可以以这三个组件的对这俩个接口的使用为主;

此外,还有两个重要的类,在上述两个接口的注释中,告诉我们:

Classes implementing this interface should create a final instance of a NestedScrollingParentHelper as a field and delegate any View or ViewGroup methods to the NestedScrollingParentHelper methods of the same signature.

大致意思是,我们如果要使用NestedScrollParent,我们需要创建一个final的NestedScrollingParentHelper的实例,使用其中的一些方法来代理掉原先View、ViewGroup中的同名方法,同样地,NestedScrollChild也有这么段话,只不过实例变成了:NestedScrollingChildHelper。

3. RecyclerView是如何实现NestedScrollChild的

RecyclerView在大多数的场景下以LinearLayoutManager的形式出现,偶尔也以表格、瀑布流的形式出现,具体的取决于LayoutManager中的自定义,它是支持嵌套滑动的,也就是说,它会将多余的偏移量下发给NestedScrollParent。

public class RecyclerView extends ViewGroup implements ScrollingView,
        NestedScrollingChild2, NestedScrollingChild3

NestedScrollingChild2和3一共有6个方法需要重写,但是重写的内容全部是使用NestedScrollingChildHelper中的同签名方法替换掉了,比如:

public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
        int dyUnconsumed, int[] offsetInWindow, int type) {
    return getScrollingChildHelper().dispatchNestedScroll(dxConsumed, dyConsumed,
            dxUnconsumed, dyUnconsumed, offsetInWindow, type);
}

@Override
public final void dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
        int dyUnconsumed, int[] offsetInWindow, int type, @NonNull int[] consumed) {
    getScrollingChildHelper().dispatchNestedScroll(dxConsumed, dyConsumed,
            dxUnconsumed, dyUnconsumed, offsetInWindow, type, consumed);
}

……

既然全部委托给了NestedScrollingChildHelper,那么我们滑动量的派发的关注点,又可以缩小一些了。

4. 一次嵌套滑动

NestedScrollingChildHelper中,主要是针对NestedScrollChild中的重写的方法,进行一些逻辑处理,以NestedcScrollChild开始嵌套滑动的startNestedScroll为例:

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;
}

实现判断一下是否已经在嵌套滑动的过程中,如果已经在了则直接返回。

否则,从View Hierarchy上的当前节点开始,去查找嵌套滑动的:NestedScrollParent,并试图让它进行消费事件。

这里又来了一个新的类:ViewParentCompat,其中声明了大量的静态方法,用于处理一些兼容性的滑动。以ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)的调用为例:

public static boolean onStartNestedScroll(@NonNull ViewParent parent, @NonNull View child,
        @NonNull View target, int nestedScrollAxes, int type) {
    if (parent instanceof NestedScrollingParent2) {
        // First try the NestedScrollingParent2 API
        return ((NestedScrollingParent2) parent).onStartNestedScroll(child, target,
                nestedScrollAxes, type);
    } else if (type == ViewCompat.TYPE_TOUCH) {
        // Else if the type is the default (touch), try the NestedScrollingParent API
        if (Build.VERSION.SDK_INT >= 21) {
            try {
                return Api21Impl.onStartNestedScroll(parent, child, target, nestedScrollAxes);
            } catch (AbstractMethodError e) {
                Log.e(TAG, "ViewParent " + parent + " does not implement interface "
                        + "method onStartNestedScroll", e);
            }
        } else if (parent instanceof NestedScrollingParent) {
            return ((NestedScrollingParent) parent).onStartNestedScroll(child, target,
                    nestedScrollAxes);
        }
    }
    return false;
}

其中,以parent可能为NestedScrollParent2选择直接调用2中对应的parent的onStartNestedScroll进行嵌套滑动;或者是NestedScrollParent来处理嵌套滑动。Api21Impl中,因为后续的ViewParent做了兼容性的处理,直接调用ViewParent下的同名方法即可:

@DoNotInline
static boolean onStartNestedScroll(ViewParent viewParent, View view, View view1, int i) {
    return viewParent.onStartNestedScroll(view, view1, i);
}

而其他情况下,则去调用NestedScrollingParent接口实现类下的同名方法。

回到主干部分,ViewParentCompat.onNestedScrollAccepted中的调用和上面的onStartNestedScroll大同小异。

……
if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)) {
    setNestedScrollingParentForType(type, p);
    ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes, type);
    return true;
}
……

onNestedScrollAccepted方法为视图及其超类提供了为嵌套滚动执行初始配置的机会,比如在NestedScrollView这个NestedScrollParent中:

@Override
public void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes,
        int type) {
    mParentHelper.onNestedScrollAccepted(child, target, axes, type);
    startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, type);
}

注意,这里又调用到了最开始**startNestedScroll** ,(以A->B->C为例)因为嵌套滑动的过程,并不是只在BC之间进行的,也可能是C和A之间的嵌套。

多余滑动量的派发的过程如下:NestedScrollingChildHelper的dispatchNestedScrollInternal中:

// 有删减
private boolean dispatchNestedScrollInternal(
        int dxConsumed, 
        int dyConsumed,
        int dxUnconsumed, 
        int dyUnconsumed, 
        @Nullable int[] offsetInWindow,
        @NestedScrollType int type, 
        @Nullable int[] consumed
        ) {
        if(!滑动可用){
           return false;
        }
        final ViewParent parent = getNestedScrollingParentForType(type);
        // 记录View和Window的初始偏移量
        val startX = offsetInWindow[0]
        val startY = offsetInWindow[1];
        // 开始滑动
       ViewParentCompat.onNestedScroll(parent, mView,
dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type, consumed);
        
        // 重置滑动偏移量
        if (offsetInWindow != null) {
            mView.getLocationInWindow(offsetInWindow);
            offsetInWindow[0] -= startX;
            offsetInWindow[1] -= startY;
        }
        return true;
}

关键就在于**ViewParentCompat.onNestedScroll(parent, mView,dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type, consumed);** ,如果你认真看过之前的start是如何运作的,那么这里面的内容你应该能猜到,我们直接看NestedScrollParent如何处理的:

// NestedScrollView中:
private void onNestedScrollInternal(int dyUnconsumed, int type, @Nullable int[] consumed) {
    final int oldScrollY = getScrollY();
    scrollBy(0, dyUnconsumed);
    final int myConsumed = getScrollY() - oldScrollY;

    if (consumed != null) {
        consumed[1] += myConsumed;
    }
    final int myUnconsumed = dyUnconsumed - myConsumed;

    mChildHelper.dispatchNestedScroll(0, myConsumed, 0, myUnconsumed, null, type, consumed);
}

无非就是根据未消费的滑动量:dyUnconsumed,使用scrollBy对当前位置进行一个相对距离的滑动。

5. 总结

上面已经简单的分析过NestedScrollXXX组件是如何实现一次嵌套滑动的了,作为官方提供的组件,因为要兼容非常多种情况和api,所以现在读起来会显得很『厚』,这是代码迭代的必然结果,所以如何从中提炼出一个具体的,嵌套滑动的模型才是关键的:

总结的几件事情是:

  1. 嵌套滑动的Child要处理滑动,同时需要将多余的滑动上发给Parent;
  2. Parent默认情况下将所有的事件都下发给Child处理;
  3. Parent要接收额外的滑动情况,利用scrollBy在onTouchEvent以外进行视图的滑动;

归根结底地,嵌套滑动用一句话总结,就是:父View不拦截ACTION_DOWN事件,子View全权消费事件,并且子View会根据一定的情况下发多余的滑动偏移量给父View,而父View则通过子View上发的「偏移量」而不是「事件」借助scrollBy方法对「偏移量」进行滑动。 这里的偏移量通常是子View触底未消费完的滑动量,亦或者是其它的业务交互下的偏移量,具体是什么,可以根据具体需要的UI实现来定夺。

~End