- [Android]触摸、滑动与嵌套滑动(一)事件与滚动 - 掘金 (juejin.cn)
- [Android]触摸、滑动与嵌套滑动(二)几种场景下的事件处理分析和调试 - 掘金 (juejin.cn)
- [Android]触摸、滑动与嵌套滑动(三)ScrollView与滑动冲突 - 掘金 (juejin.cn)
如何实现舒服的嵌套滑动
当我们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处理,我们可以有两种解决方案:
- 父View在收到子View某种信号的时候主动去拦截子View的事件。就和之前ScrollView和Button嵌套的时候,Button收到ACTION_DOWN的时候那样。
- 不上交事件,只把滑动量交给父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,所以现在读起来会显得很『厚』,这是代码迭代的必然结果,所以如何从中提炼出一个具体的,嵌套滑动的模型才是关键的:
总结的几件事情是:
- 嵌套滑动的Child要处理滑动,同时需要将多余的滑动上发给Parent;
- Parent默认情况下将所有的事件都下发给Child处理;
- Parent要接收额外的滑动情况,利用scrollBy在onTouchEvent以外进行视图的滑动;
归根结底地,嵌套滑动用一句话总结,就是:父View不拦截ACTION_DOWN事件,子View全权消费事件,并且子View会根据一定的情况下发多余的滑动偏移量给父View,而父View则通过子View上发的「偏移量」而不是「事件」借助scrollBy方法对「偏移量」进行滑动。 这里的偏移量通常是子View触底未消费完的滑动量,亦或者是其它的业务交互下的偏移量,具体是什么,可以根据具体需要的UI实现来定夺。
~End