问题分析
嵌套滑动一直是Android中比较棘手的问题,根本原因是Android的事件分发机制导致的:当子控件消费了事件, 那么父控件就不会再有机会处理这个事件了。 所以一旦内部的滑动控件消费了滑动操作, 外部的滑动控件就再也没机会响应这个滑动操作了。
如何解决?
不过这个问题终于在LOLLIPOP(SDK21)之后终于有了官方的解决方法,就是嵌套滑动机制。嵌套滑动的基本原理是在子控件接收到滑动一段距离的请求时,先询问父控件是否要滑动,如果需要滑动就通知子控件它消耗了一部分滑动距离,子控件就只处理剩下的滑动距离,然后子控件滑动完毕后再把剩余的滑动距离传给父控件。
关于兼容
SDK21之后,嵌套滑动的相关逻辑作为普通方法直接写进了最新的View和ViewGroup类中;
SDK21之前,官方在android.support.v4兼容包中提供了两个接口NestedScrollingChild和NestedScrollingParent,还有两个辅助类NestedScrollingChildHelper和NestedScrollingParentHelper来帮助控件实现嵌套滑动。简单来说就是,在接口方法内对应调用辅助类的方法就可以兼容嵌套滑动了。
所以为了兼容低版本, 处理嵌套滑动更常用到的是后者调用接口方法的方式。
相关方法
NestedScrollingChild
startNestedScroll : 起始方法,主要作用是找到接收滑动距离信息的外控件。
dispatchNestedPreScroll : 在内控件处理滑动前把滑动信息分发给外控件。
dispatchNestedScroll : 在内控件处理完滑动后把剩下的滑动距离信息分发给外控件。
stopNestedScroll : 结束方法, 主要作用就是清空嵌套滑动的相关状态。
setNestedScrollingEnabled和isNestedScrollingEnabled : 一对get&set方法,用来判断控件是否支持嵌套滑动。
dispatchNestedPreFling和dispatchNestedFling : 跟Scroll的对应方法作用类似,不过分发的不是滑动信息而是Fling信息。本文主要关注滑动的处理, 所以后续不分析这两个方法。
从上面方法可以看出,内控件是嵌套滑动的发起者.。
NestedScrollingParent
onStartNestedScroll : 对应startNestedScroll,内控件通过调用外控件的这个方法来确定外控件是否接收滑动信息。
onNestedScrollAccepted : 当外控件确定接收滑动信息后该方法被回调,可以让外控件针对嵌套滑动做一些前期工作。
onNestedPreScroll : 关键方法,接收内控件处理滑动前的滑动距离信息,在这里外控件可以优先响应滑动操作,消耗部分或者全部滑动距离。
onNestedScroll : 关键方法,接收内控件处理完滑动后的滑动距离信息,在这里外控件可以选择是否处理剩余的滑动距离。
onStopNestedScroll : 对应stopNestedScroll,用来做一些收尾工作。
getNestedScrollAxes : 返回嵌套滑动的方向,区分横向滑动和竖向滑动。
onNestedPreFling和onNestedFling : 同上略。
从上面方法可以看出,外控件的大部分方法都是被内控件的对应方法回调的。内控件是发起者,外控件是回调者。
通过CoordinatorLayout看嵌套机制
注意:下文所指的CoordinatorLayout(父控件)、RecyclerView(内控件)以及ImageView(子控件)均为Android 自定义CoordinatorLayout.Behavior 实现悬浮控件动画中控件。
CoordinatorLayout是android support design推出的新布局,主要作为视图根布局,用于协调子控件之间的交互。
这里将通过CoordinatorLayout、RecyclerView以及一个CoordinatorLayout的直接子控件ImageView实现的动画效果(Android 自定义CoordinatorLayout.Behavior 实现悬浮控件动画),对CoordinatorLayout进行源码分析的同时,探索嵌套滑动机制的实现原理。
上面已经说了嵌套滑动是从startNestedScroll开始,所以在RecyclerView找出调用这个方法的地方。
public boolean onTouchEvent(MotionEvent e) {
...
switch (action) {
case MotionEvent.ACTION_DOWN: {
...
startNestedScroll(nestedScrollAxis);
} break;
...
}
...
return true;
}
因为ACTION_DOWN是滑动操作的开始事件,所以当接收到这个事件的时候尝试找对应的父控件。只有找到了父控件才有后续的嵌套滑动的逻辑发生。
接着我们看startNestedScroll是如何找对应的父控件的,因为RecyclerView#startNestedScroll调用了辅助方法的startNestedScroll, 所以下面直接贴NestedScrollingChildHelper#startNestedScroll。
public boolean startNestedScroll(int axes) {
if (hasNestedScrollingParent()) {
// Already in progress
return true;
}
//是否支持嵌套滑动
if (isNestedScrollingEnabled()) {
ViewParent p = mView.getParent();
View child = mView;
//遍历寻找父控件
while (p != null) {
//调用外控件的onStartNestedScroll方法来确定外控件是否接收滑动信息
if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes)) {
mNestedScrollingParent = p;
//外控件确定接收滑动信息后onNestedScrollAccepted被回调
ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes);
return true;
}
if (p instanceof View) {
child = (View) p;
}
p = p.getParent();
}
}
return false;
}
遍历父控件,调用父控件的onStartNestedScroll,返回true表示找到了对应的父控件,找到父控件后马上调用onNestedScrollAccepted。那么问题来了,CoordinatorLayout作为父控件,它的onStartNestedScroll方法什么时候会返回true?
@Override
public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
boolean handled = false;
final int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
final View view = getChildAt(i);
if (view.getVisibility() == View.GONE) {
// If it's GONE, don't dispatch
continue;
}
final LayoutParams lp = (LayoutParams) view.getLayoutParams();
final Behavior viewBehavior = lp.getBehavior();
//如果子控件的Behavior不为空,则触发子控件Behavior的onStartNestedScroll方法
if (viewBehavior != null) {
final boolean accepted = viewBehavior.onStartNestedScroll(this, view, child, target,
nestedScrollAxes);
handled |= accepted;
lp.acceptNestedScroll(accepted);
} else {
lp.acceptNestedScroll(false);
}
}
return handled;
}
以上是CoordinatorLayout#onStartNestedScroll方法的源码。可以看到,只有当子控件Behavior的onStartNestedScroll方法返回为true时,CoordinatorLayout#onStartNestedScroll才会返回true。那么问题又来了,Behavior又是什么鬼?知之为知之,不知官网知:
可以看到Behavior 是针对 CoordinatorLayout 中 child 的交互插件。记住这个词:插件。插件也就代表如果一个 child 需要某种交互,它就需要加载对应的 Behavior,否则它就是不具备这种交互能力的。而 Behavior 本身是一个抽象类,它的实现类都是为了能够让用户作用在一个 View 上进行拖拽、滑动、快速滑动等手势。如果自己要定制某个交互动作,就需要自己实现一个 Behavior。再来看Behavior源码:
public static abstract class Behavior<V extends View> {
public Behavior() { }
public Behavior(Context context, AttributeSet attrs) {}
public boolean layoutDependsOn(CoordinatorLayout parent, V child, View dependency) { return false; }
public boolean onDependentViewChanged(CoordinatorLayout parent, V child, View dependency) { return false; }
public void onDependentViewRemoved(CoordinatorLayout parent, V child, View dependency) {}
public boolean onStartNestedScroll(CoordinatorLayout coordinatorLayout,
V child, View directTargetChild, View target, int nestedScrollAxes) {
return false;
}
public void onNestedScrollAccepted(CoordinatorLayout coordinatorLayout, V child,
View directTargetChild, View target, int nestedScrollAxes) {
// Do nothing
}
public void onStopNestedScroll(CoordinatorLayout coordinatorLayout, V child, View target) {
// Do nothing
}
public void onNestedScroll(CoordinatorLayout coordinatorLayout, V child, View target,
int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
// Do nothing
}
public void onNestedPreScroll(CoordinatorLayout coordinatorLayout, V child, View target,
int dx, int dy, int[] consumed) {
// Do nothing
}
public boolean onNestedFling(CoordinatorLayout coordinatorLayout, V child, View target,
float velocityX, float velocityY, boolean consumed) {
return false;
}
public boolean onNestedPreFling(CoordinatorLayout coordinatorLayout, V child, View target,
float velocityX, float velocityY) {
return false;
}
}
Behavior 其实是 CoordinatorLayout 中的一个静态内部类,并且是个泛型,接受任何 View 类型。
一般我们自定义一个 Behavior,目的有两个。
一是根据某些依赖的 View 的位置进行相应的操作(本文主要分析嵌套滑动的处理,所以View之间的依赖关系不再具体分析)。
相关方法:
layoutDependsOn
onDependentViewChanged
onDependentViewRemoved
另外一个就是响应 CoordinatorLayout 中某些组件的滑动事件。
相关方法:
onStartNestedScroll
onNestedScrollAccepted
onStopNestedScroll
onNestedScroll
onNestedPreScroll
onNestedFling
onNestedPreFling
有木有很眼熟的感觉?没错,和开始提到的NestedScrollingParent相关方法名字一模一样。所以这里就解决了刚才的疑问。当CoordinatorLayout子控件Behavior的onStartNestedScroll方法返回为true时,CoordinatorLayout的onStartNestedScroll方法才返回true。至于子控件Behavior的onStartNestedScroll方法返回true还是false,就要看你如何实现嵌套滑动的逻辑了。在Android 自定义CoordinatorLayout.Behavior 实现悬浮控件动画中,我对ImageView的Behavior#onStartNestedScroll方法返回值的定义是,只要竖直方向滑动就返回true。
再回到刚刚的研究中,这时候调用了父控件的onStartNestedScroll方法返回true,内控件RecyclerView找到父控件CoordinatorLayout后马上调用CoordinatorLayout#onNestedScrollAccepted方法,其源码为:
public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes) {
mNestedScrollingParentHelper.onNestedScrollAccepted(child, target, nestedScrollAxes);
mNestedScrollingDirectChild = child;
mNestedScrollingTarget = target;
final int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
final View view = getChildAt(i);
final LayoutParams lp = (LayoutParams) view.getLayoutParams();
if (!lp.isNestedScrollAccepted()) {
continue;
}
final Behavior viewBehavior = lp.getBehavior();
if (viewBehavior != null) {
viewBehavior.onNestedScrollAccepted(this, view, child, target, nestedScrollAxes);
}
}
}
这次就简单了,和onStartNestedScroll方法一个尿性,还是调用子控件Behavior#onNestedScrollAccepted呗,这里就不再过多分析,只需知道该方法是做一些前期的准备工作,可有可无。
找到了父控件后ACTION_DOWN事件就没嵌套滑动的事了,要滑动肯定会在onTouchEvent中处理ACTION_MOVE事件,接着我们看RecyclerView的ACTION_MOVE事件具体是怎样处理的。
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 (dispatchNestedPreScroll(dx, dy, mScrollConsumed, mScrollOffset)) {
dx -= mScrollConsumed[0];
dy -= mScrollConsumed[1];
vtev.offsetLocation(mScrollOffset[0], mScrollOffset[1]);
// Updated the nested offsets
mNestedOffsets[0] += mScrollOffset[0];
mNestedOffsets[1] += mScrollOffset[1];
}
...
if (mScrollState == SCROLL_STATE_DRAGGING) {
mLastTouchX = x - mScrollOffset[0];
mLastTouchY = y - mScrollOffset[1];
if (scrollByInternal(
canScrollHorizontally ? dx : 0,
canScrollVertically ? dy : 0,
vtev)) {
getParent().requestDisallowInterceptTouchEvent(true);
}
if (mGapWorker != null && (dx != 0 || dy != 0)) {
mGapWorker.postFromTraversal(this, dx, dy);
}
}
} break;
这部分是RecyclerView能够处理嵌套滑动的关键代码了,其他能够嵌套滑动的控件也应该在ACTION_MOVE中类似地处理滑动距离。
首先计算出本次滑动距离dy,得到滑动距离deltaY后, 先把它传给dispatchNestedPreScroll,然后在结果返回true的时候,dy 会减去mScrollConsumed[1],接着看dispatchNestedPreScroll干了什么。(由于本文实现的效果为上下嵌套滑动,所以关于x轴的横向滑动不再过多分析)
public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {
if (dx != 0 || dy != 0) {
...
consumed[0] = 0;
consumed[1] = 0;
ViewParentCompat.onNestedPreScroll(mNestedScrollingParent, mView, dx, dy, consumed);
if (offsetInWindow != null) {
mView.getLocationInWindow(offsetInWindow);
offsetInWindow[0] -= startX;
offsetInWindow[1] -= startY;
}
return consumed[0] != 0 || consumed[1] != 0;
} else if (offsetInWindow != null) {
offsetInWindow[0] = 0;
offsetInWindow[1] = 0;
}
}
return false;
}
因为dispatchNestedPreScroll的工作就是把滑动距离在内控件处理前分发给父控件,所以这里的关键代码也很简单,就是直接把相关的参数传给父控件的onNestedPreScroll,然后只要父控件消耗了滑动距离(不论横向还是竖向),就会返回true。而CoordinatorLayout#onNestedPreScroll和之前方法一样,最终调用的是子控件Behavior的onNestedPreScroll方法。所以,CoordinatorLayout消不消耗RecyclerView的滑动距离,完全取决于ImageView的Behavior#onNestedPreScroll方法中的具体实现逻辑。如果CoordinatorLayout想在RecyclerView之前消耗滑动距离,仅需要在ImageView的Behavior#onNestedPreScroll方法中把消耗的值放到数组中即可。
好了, 现在父控件已经比内控件先处理了滑动距离了,如果父控件没有完全消耗掉所有滑动距离,这时该内控件处理剩下的滑动距离了。在RecyclerView中通过RecyclerView#scrollByInternal来进行滑动,并且滑动结束后通过比对滑动前后的dy值得到了内控件消耗的滑动距离,然后得到剩下的滑动距离,最后传给dispatchNestedScroll。
dispatchNestedScroll的逻辑跟dispatchNestedPreScroll几乎一样,区别是RecyclerView调用了父控件CoordinatorLayout的onNestedScroll,CoordinatorLayout的onNestedScroll调用了子控件ImageView的Behavior的onNestedScroll方法。因为到这里已经是处理滑动距离最后的机会了, 所以onNestedScroll不会再影响RecyclerView的处理逻辑了.
到这里ACTION_MOVE事件就分析完毕了。
最后就是stopNestedScroll了,代码就不贴了,调用这个方法基本是新的滑动操作开始前,或者滑动操作结束/取消,代码逻辑就是进行一些变量的重置工作和调用onStopNestedScroll,而onStopNestedScroll也类似。
总结
- 如果要支持嵌套滑动,内控件和父控件要支持对应的方法,为了兼容低版本一般通过实现NestedScrollingChild和NestedScrollingParent接口以及使用NestedScrollingChildHelper和NestedScrollingParent辅助类。
- Behavior是用于CoordinatorLayout的直接子控件来协调自身CoordinatorLayout以及和其他子控件的交互。
- 具体嵌套滑动逻辑主要是在子控件Behavior的onNestedPreScroll和onNestedScroll方法中。
- 父控件通过子控件的Behavior给数组赋值来把消耗的滑动距离传递给内控件(可消耗也可不消耗)。
参考文章:
一点见解: Android嵌套滑动和NestedScrollView
针对 CoordinatorLayout 及 Behavior 的一次细节较真