透过 NestedScrollView 源码解析嵌套滑动原理

NestedScrollView 是用于替代 ScrollView 来解决嵌套滑动过程中的滑动事件的冲突。作为开发者,你会发现很多地方会用到嵌套滑动的逻辑,比如下拉刷新页面,京东或者淘宝的各种商品页面。

那为什么要去了解 NestedScrollView 的源码呢?那是因为 NestedScrollView 是嵌套滑动实现的模板范例,通过研读它的源码,能够让你知道如何实现嵌套滑动,然后如果需求上 NestedScrollView 无法满足的时候,你可以自定义。

嵌套滑动

说到嵌套滑动,就得说说这两个类了:NestedScrollingParent3 和 NestedScrollingChild3 ,当然同时也存在后面不带数字的类。之所以后面带数字了,是为了解决之前的版本遗留的问题:fling 的时候涉及嵌套滑动,无法透传到另一个View 上继续 fling,导致滑动效果大打折扣 。

其实 NestedScrollingParent2 相比 NestedScrollingParent 在方法调用上多了一个参数 type,用于标记这个滑动是如何产生的。type 的取值如下:

    /**
* Indicates that the input type for the gesture is from a user touching the screen. 触摸产生的滑动
*/
public static final int TYPE_TOUCH = 0; /**
* Indicates that the input type for the gesture is caused by something which is not a user
* touching a screen. This is usually from a fling which is settling. 简单理解就是fling
*/
public static final int TYPE_NON_TOUCH = 1;

嵌套滑动,说得通俗点就是子 view 和 父 view 在滑动过程中,互相通信决定某个滑动是子view 处理合适,还是 父view 来处理。所以, Parent 和 Child 之间存在相互调用,遵循下面的调用关系:

透过 NestedScrollView 源码解析嵌套滑动原理

上图可以这么理解:

  • ACTION_DOWN 的时候子 view 就要调用 startNestedScroll( ) 方法来告诉父 view 自己要开始滑动了(实质上是寻找能够配合 child 进行嵌套滚动的 parent),parent 也会继续向上寻找能够配合自己滑动的 parent,可以理解为在做一些准备工作 。
  • 父 view 会收到 onStartNestedScroll 回调从而决定是不是要配合子 view 做出响应。如果需要配合,此方法会返回 true。继而 onStartNestedScroll()回调会被调用。
  • 在滑动事件产生但是子 view 还没处理前可以调用 dispatchNestedPreScroll(0,dy,consumed,offsetInWindow) 这个方法把事件传给父 view,这样父 view 就能在onNestedPreScroll 方法里面收到子 view 的滑动信息,然后做出相应的处理把处理完后的结果通过 consumed 传给子 view。

  • dispatchNestedPreScroll()之后,child可以进行自己的滚动操作。

  • 如果父 view 需要在子 view 滑动后处理相关事件的话可以在子 view 的事件处理完成之后调用 dispatchNestedScroll 然后父 view 会在 onNestedScroll 收到回调。

  • 最后,滑动结束,调用 onStopNestedScroll() 表示本次处理结束。

  • 但是,如果滑动速度比较大,会触发 fling, fling 也分为 preFling 和 fling 两个阶段,处理过程和 scroll 基本差不多。

NestedScrollView

首先是看类的名字

 class NestedScrollView extends FrameLayout implements NestedScrollingParent3,
NestedScrollingChild3, ScrollingView {

可以发现它继承了 FrameLayout,相当于它就是一个 ViewGroup,可以添加子 view , 但是需要注意的事,它只接受一个子 view,否则会报错。

    @Override
public void addView(View child) {
if (getChildCount() > 0) {
throw new IllegalStateException("ScrollView can host only one direct child");
} super.addView(child);
} @Override
public void addView(View child, int index) {
if (getChildCount() > 0) {
throw new IllegalStateException("ScrollView can host only one direct child");
} super.addView(child, index);
} @Override
public void addView(View child, ViewGroup.LayoutParams params) {
if (getChildCount() > 0) {
throw new IllegalStateException("ScrollView can host only one direct child");
} super.addView(child, params);
} @Override
public void addView(View child, int index, ViewGroup.LayoutParams params) {
if (getChildCount() > 0) {
throw new IllegalStateException("ScrollView can host only one direct child");
} super.addView(child, index, params);
}

add view

对于 NestedScrollingParent3,NestedScrollingChild3 的作用,前文已经说了,如果还是不理解,后面再对源码的分析过程中也会分析到。

其实这里还可以提一下 RecyclerView:

public class RecyclerView extends ViewGroup implements ScrollingView,
NestedScrollingChild2, NestedScrollingChild3 {

这里没有继承 NestedScrollingParent3 是因为开发者觉得 RecyclerView 适合做一个子类。并且它的功能作为一个列表去展示,也就是不适合再 RecyclerView 内部去做一些复杂的嵌套滑动之类的。这样 RecycylerView 外层就可以再嵌套一个 NestedScrollView 进行嵌套滑动了。后面再分析嵌套滑动的时候,也会把 RecycylerView 当作子类来进行分析,这样能更好的理解源码。

内部有个接口,使用者需要对滑动变化进行监听的,可以添加这个回调:

    public interface OnScrollChangeListener {
/**
* Called when the scroll position of a view changes.
*
* @param v The view whose scroll position has changed.
* @param scrollX Current horizontal scroll origin.
* @param scrollY Current vertical scroll origin.
* @param oldScrollX Previous horizontal scroll origin.
* @param oldScrollY Previous vertical scroll origin.
*/
void onScrollChange(NestedScrollView v, int scrollX, int scrollY,
int oldScrollX, int oldScrollY);
}

构造函数

下面来看下构造函数:

    public NestedScrollView(@NonNull Context context, @Nullable AttributeSet attrs,
int defStyleAttr) {
super(context, attrs, defStyleAttr);
initScrollView(); final TypedArray a = context.obtainStyledAttributes(
attrs, SCROLLVIEW_STYLEABLE, defStyleAttr, 0);
// 是否要铺满全屏
setFillViewport(a.getBoolean(0, false)); a.recycle();
// 即是子类,又是父类
mParentHelper = new NestedScrollingParentHelper(this);
mChildHelper = new NestedScrollingChildHelper(this); // ...because why else would you be using this widget? 默认是滚动,不然你使用它就没有意义了
setNestedScrollingEnabled(true); ViewCompat.setAccessibilityDelegate(this, ACCESSIBILITY_DELEGATE);
}

这里我们用了两个辅助类来帮忙处理嵌套滚动时候的一些逻辑处理,NestedScrollingParentHelper,NestedScrollingChildHelper。这个是和前面的你实现的接口 NestedScrollingParent3,NestedScrollingChild3 相对应的。

下面看下  initScrollView 方法里的具体逻辑:

    private void initScrollView() {
mScroller = new OverScroller(getContext());
setFocusable(true);
setDescendantFocusability(FOCUS_AFTER_DESCENDANTS);
     // 会调用 ViewGroup 的 onDraw
setWillNotDraw(false);
// 获取 ViewConfiguration 中一些配置,包括滑动距离,最大最小速率等等
final ViewConfiguration configuration = ViewConfiguration.get(getContext());
mTouchSlop = configuration.getScaledTouchSlop();
mMinimumVelocity = configuration.getScaledMinimumFlingVelocity();
mMaximumVelocity = configuration.getScaledMaximumFlingVelocity();
}

setFillViewport

在构造函数中,有这么一个设定:

setFillViewport(a.getBoolean(0, false));

与 setFillViewport 对应的属性是 android:fillViewport="true"。如果不设置这个属性为 true,可能会出现如下图一样的问题:

xml 布局:

<?xml version="1.0" encoding="utf-8"?>
<NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/activity_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
> <LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="#fff000">
<Button
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
</NestedScrollView>

效果:

透过 NestedScrollView 源码解析嵌套滑动原理

可以发现这个没有铺满全屏,可是 xml 明明已经设置了 match_parent 了。这是什么原因呢?

那为啥设置 true 就可以了呢?下面来看下它的 onMeasure 方法:

    @Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
// false 直接返回
if (!mFillViewport) {
return;
} final int heightMode = MeasureSpec.getMode(heightMeasureSpec);
if (heightMode == MeasureSpec.UNSPECIFIED) {
return;
} if (getChildCount() > 0) {
View child = getChildAt(0);
final NestedScrollView.LayoutParams lp = (LayoutParams) child.getLayoutParams(); int childSize = child.getMeasuredHeight();
int parentSpace = getMeasuredHeight()
- getPaddingTop()
- getPaddingBottom()
- lp.topMargin
- lp.bottomMargin;
// 如果子 view 高度小于 父 view 高度,那么需要重新设定高度
if (childSize < parentSpace) {
int childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec,
getPaddingLeft() + getPaddingRight() + lp.leftMargin + lp.rightMargin,
lp.width);
// 这里生成 MeasureSpec 传入的是 parentSpace,并且用的是 MeasureSpec.EXACTLY
int childHeightMeasureSpec =
MeasureSpec.makeMeasureSpec(parentSpace, MeasureSpec.EXACTLY);
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
}
}

当你将 mFillViewport 设置为 true 后,就会把父 View 高度给予子 view 。可是这个解释了设置 mFillViewport 可以解决不能铺满屏幕的问题,可是没有解决为啥 match_parent 无效的问题。

在回到类的继承关系上,NestedScrollView 继承的是 FrameLayout,也就是说,FrameLayout 应该和 NestedScrollView 拥有一样的问题。可是当你把 xml 中的布局换成 FrameLayout 后,你发现竟然没有问题。那么这是为啥呢?

原因是 NestedScrollView 又重写了 measureChildWithMargins 。子view 的 childHeightMeasureSpec 中的 mode 是 MeasureSpec.UNSPECIFIED 。当被设置为这个以后,子 view 的高度就完全是由自身的高度决定了。

    @Override
protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed,
int parentHeightMeasureSpec, int heightUsed) {
final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
getPaddingLeft() + getPaddingRight() + lp.leftMargin + lp.rightMargin
+ widthUsed, lp.width);
// 在生成子 view 的 MeasureSpec 时候,传入的是 MeasureSpec.UNSPECIFIED
final int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(
lp.topMargin + lp.bottomMargin, MeasureSpec.UNSPECIFIED); child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}

比如子 view 是 LinearLayout ,这时候,它的高度就是子 view 的高度之和。而且,这个 MeasureSpec.UNSPECIFIED 会一直影响着后面的子子孙孙 view 。

我猜这么设计的目的是因为你既然使用了 NestedScrollView,就没必要在把子 View  搞得跟屏幕一样大了,它该多大就多大,不然你滑动的时候,看见一大片空白体验也不好啊。

而 ViewGroup 中,measureChildWithMargins 的方法是这样的:

    protected void measureChildWithMargins(View child,
int parentWidthMeasureSpec, int widthUsed,
int parentHeightMeasureSpec, int heightUsed) {
final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin
+ widthUsed, lp.width);
final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin
+ heightUsed, lp.height); child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}

由于一般使用 NestedScrollView 的时候,都是会超过屏幕高度的,所以不设置这个属性为 true 也没有关系。

绘制

既然前面已经把 onMeasure 讲完了,那索引把绘制这块都讲了把。下面是 draw 方法,这里主要是绘制边界的阴影:

    @Override
public void draw(Canvas canvas) {
super.draw(canvas);
if (mEdgeGlowTop != null) {
final int scrollY = getScrollY();
       // 上边界阴影绘制
if (!mEdgeGlowTop.isFinished()) {
final int restoreCount = canvas.save();
int width = getWidth();
int height = getHeight();
int xTranslation = 0;
int yTranslation = Math.min(0, scrollY);
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP || getClipToPadding()) {
width -= getPaddingLeft() + getPaddingRight();
xTranslation += getPaddingLeft();
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && getClipToPadding()) {
height -= getPaddingTop() + getPaddingBottom();
yTranslation += getPaddingTop();
}
canvas.translate(xTranslation, yTranslation);
mEdgeGlowTop.setSize(width, height);
if (mEdgeGlowTop.draw(canvas)) {
ViewCompat.postInvalidateOnAnimation(this);
}
canvas.restoreToCount(restoreCount);
}
       // 底部边界阴影绘制
if (!mEdgeGlowBottom.isFinished()) {
final int restoreCount = canvas.save();
int width = getWidth();
int height = getHeight();
int xTranslation = 0;
int yTranslation = Math.max(getScrollRange(), scrollY) + height;
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP || getClipToPadding()) {
width -= getPaddingLeft() + getPaddingRight();
xTranslation += getPaddingLeft();
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && getClipToPadding()) {
height -= getPaddingTop() + getPaddingBottom();
yTranslation -= getPaddingBottom();
}
canvas.translate(xTranslation - width, yTranslation);
canvas.rotate(180, width, 0);
mEdgeGlowBottom.setSize(width, height);
if (mEdgeGlowBottom.draw(canvas)) {
ViewCompat.postInvalidateOnAnimation(this);
}
canvas.restoreToCount(restoreCount);
}
}
}

onDraw 是直接用了父类的,这个没啥好讲的,下面看看 onLayout:

    @Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
mIsLayoutDirty = false;
// Give a child focus if it needs it
if (mChildToScrollTo != null && isViewDescendantOf(mChildToScrollTo, this)) {
scrollToChild(mChildToScrollTo);
}
mChildToScrollTo = null; if (!mIsLaidOut) { // 是否是第一次调用onLayout
// If there is a saved state, scroll to the position saved in that state.
if (mSavedState != null) {
scrollTo(getScrollX(), mSavedState.scrollPosition);
mSavedState = null;
} // mScrollY default value is "0" // Make sure current scrollY position falls into the scroll range. If it doesn't,
// scroll such that it does.
int childSize = 0;
if (getChildCount() > 0) {
View child = getChildAt(0);
NestedScrollView.LayoutParams lp = (LayoutParams) child.getLayoutParams();
childSize = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
}
int parentSpace = b - t - getPaddingTop() - getPaddingBottom();
int currentScrollY = getScrollY();
int newScrollY = clamp(currentScrollY, parentSpace, childSize);
if (newScrollY != currentScrollY) {
scrollTo(getScrollX(), newScrollY);
}
} // Calling this with the present values causes it to re-claim them
scrollTo(getScrollX(), getScrollY());
mIsLaidOut = true;
}

onLayout 方法也没什么说的,基本上是用了父类 FrameLayout 的布局方法,加入了一些 scrollTo 操作滑动到指定位置。

嵌套滑动分析

如果对滑动事件不是很清楚的小伙伴可以先看看这篇文章:Android View 的事件分发原理解析

在分析之前,先做一个假设,比如 RecyclerView 就是 NestedScrollView 的子类,这样去分析嵌套滑动更容易理解。这时候,用户点击 RecyclerView 触发滑动。需要分析整个滑动过程的事件传递。

dispatchTouchEvent

这里,NestedScrollView 用的是父类的处理,并没有添加自己的逻辑。

onInterceptTouchEvent

当事件进行分发前,ViewGroup 首先会调用 onInterceptTouchEvent 询问自己要不要进行拦截,不拦截,就会分发传递给子 view。一般来说,对于 ACTION_DOWN 都不会拦截,这样子类有机会获取事件,只有子类不处理,才会再次传给父 View 来处理。下面来看看其具体代码逻辑:

    @Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
/*
* This method JUST determines whether we want to intercept the motion.
* If we return true, onMotionEvent will be called and we do the actual
* scrolling there.
*/ /*
* Shortcut the most recurring case: the user is in the dragging
* state and he is moving his finger. We want to intercept this
* motion.
*/
final int action = ev.getAction();
     // 如果已经在拖动了,说明已经在滑动了,直接返回 true
if ((action == MotionEvent.ACTION_MOVE) && (mIsBeingDragged)) {
return true;
} switch (action & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_MOVE: {
/*
* mIsBeingDragged == false, otherwise the shortcut would have caught it. Check
* whether the user has moved far enough from his original down touch.
*/ /*
* Locally do absolute value. mLastMotionY is set to the y value
* of the down event.
*/
final int activePointerId = mActivePointerId;
if (activePointerId == INVALID_POINTER) {
// If we don't have a valid id, the touch down wasn't on content. 不是一个有效的id
break;
} final int pointerIndex = ev.findPointerIndex(activePointerId);
if (pointerIndex == -1) {
Log.e(TAG, "Invalid pointerId=" + activePointerId
+ " in onInterceptTouchEvent");
break;
} final int y = (int) ev.getY(pointerIndex);
          // 计算垂直方向上滑动的距离
final int yDiff = Math.abs(y - mLastMotionY);
          // 确定可以产生滚动了
if (yDiff > mTouchSlop
&& (getNestedScrollAxes() & ViewCompat.SCROLL_AXIS_VERTICAL) == 0) {
mIsBeingDragged = true;
mLastMotionY = y;
initVelocityTrackerIfNotExists();
            // 可以获取滑动速率
mVelocityTracker.addMovement(ev);
mNestedYOffset = 0;
final ViewParent parent = getParent();
if (parent != null) {
               // 让父 view 不要拦截,这里应该是为了保险起见,因为既然已经走进来了,只要你返回 true,父 view 就不会拦截了。
parent.requestDisallowInterceptTouchEvent(true);
}
}
break;
} case MotionEvent.ACTION_DOWN: {
final int y = (int) ev.getY();
          // 如果点击的范围不在子 view 上,直接break,比如自己设置了很大的 margin,此时用户点击这里,这个范围理论上是不参与滑动的
if (!inChild((int) ev.getX(), y)) {
mIsBeingDragged = false;
recycleVelocityTracker();
break;
} /*
* Remember location of down touch.
* ACTION_DOWN always refers to pointer index 0.
*/
mLastMotionY = y;
mActivePointerId = ev.getPointerId(0);
          // 在收到 DOWN 事件的时候,做一些初始化的工作
initOrResetVelocityTracker();
mVelocityTracker.addMovement(ev);
/*
* If being flinged and user touches the screen, initiate drag;
* otherwise don't. mScroller.isFinished should be false when
* being flinged. We need to call computeScrollOffset() first so that
* isFinished() is correct.
*/
mScroller.computeScrollOffset();
          // 如果此时正在fling, isFinished 会返回 flase
mIsBeingDragged = !mScroller.isFinished();
          // 开始滑动
startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH);
break;
} case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
/* Release the drag */
mIsBeingDragged = false;
mActivePointerId = INVALID_POINTER;
recycleVelocityTracker();
if (mScroller.springBack(getScrollX(), getScrollY(), 0, 0, 0, getScrollRange())) {
ViewCompat.postInvalidateOnAnimation(this);
}
          // 手抬起后,停止滑动
stopNestedScroll(ViewCompat.TYPE_TOUCH);
break;
case MotionEvent.ACTION_POINTER_UP:
onSecondaryPointerUp(ev);
break;
} /*
* The only time we want to intercept motion events is if we are in the
* drag mode.
*/
return mIsBeingDragged;
}

onInterceptTouchEvent 事件就是做一件事,决定事件是不是要继续交给自己的 onTouchEvent 处理。这里需要注意的一点是,如果子 view 在 dispatchTouchEvent 中调用了:

parent.requestDisallowInterceptTouchEvent(true)

那么,其实就不会再调用 onInterceptTouchEvent 方法。也就是说上面的逻辑就不会走了。但是可以发现,down 事件,一般是不会拦截的。但是如果正在 fling,此时就会返回 true,直接把事件全部拦截。

那看下 RecyclerView 的 dispatchTouchEvent 是父类的,没啥好分析的。而且它的 onInterceptTouchEvent 也是做了一些初始化的一些工作,和 NestedScrollView 一样没啥可说的。

onTouchEvent

再说 NestedScrollView 的 onTouchEvent。

对于 onTouchEvent 得分两类进行讨论,如果其子 view 不是 ViewGroup ,且是不可点击的,就会把事件直接交给 NestedScrollView 来处理。

但是如果点击的子 view 是 RecyclerView 的 ViewGroup 。当 down 事件来的时候,ViewGroup 的子 view 没有处理,那么就会交给 ViewGroup 来处理,你会发现ViewGroup 的 onTouchEvent 是默认返回 true 的。也就是说事件都是由  RecyclerView 来处理的。

这时候来看下 NestedScrollView 的 onTouchEvent 代码:

 public boolean onTouchEvent(MotionEvent ev) {
initVelocityTrackerIfNotExists(); MotionEvent vtev = MotionEvent.obtain(ev); final int actionMasked = ev.getActionMasked(); if (actionMasked == MotionEvent.ACTION_DOWN) {
mNestedYOffset = 0;
}
vtev.offsetLocation(0, mNestedYOffset); switch (actionMasked) {
case MotionEvent.ACTION_DOWN: {
          // 需要有一个子类才可以进行滑动
if (getChildCount() == 0) {
return false;
}
          // 前面提到如果用户在 fling 的时候,触碰,此时是直接拦截返回 true,自己来处理事件。
if ((mIsBeingDragged = !mScroller.isFinished())) {
final ViewParent parent = getParent();
if (parent != null) {
parent.requestDisallowInterceptTouchEvent(true);
}
} /*
* If being flinged and user touches, stop the fling. isFinished
* will be false if being flinged.处理结果就是停止 fling
*/
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
} // Remember where the motion event started
mLastMotionY = (int) ev.getY();
mActivePointerId = ev.getPointerId(0);
         // 寻找嵌套父View,告诉它准备在垂直方向上进行 TOUCH 类型的滑动
startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH);
break;
}
case MotionEvent.ACTION_MOVE:
final int activePointerIndex = ev.findPointerIndex(mActivePointerId);
if (activePointerIndex == -1) {
Log.e(TAG, "Invalid pointerId=" + mActivePointerId + " in onTouchEvent");
break;
} final int y = (int) ev.getY(activePointerIndex);
int deltaY = mLastMotionY - y;
          // 滑动前先把移动距离告诉嵌套父View,看看它要不要消耗,返回 true 代表消耗了部分距离
if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset,
ViewCompat.TYPE_TOUCH)) {
deltaY -= mScrollConsumed[1];
vtev.offsetLocation(0, mScrollOffset[1]);
mNestedYOffset += mScrollOffset[1];
}
          // 滑动距离大于最大最小触发距离
if (!mIsBeingDragged && Math.abs(deltaY) > mTouchSlop) {
final ViewParent parent = getParent();
if (parent != null) {
parent.requestDisallowInterceptTouchEvent(true);
}
            // 触发滑动
mIsBeingDragged = true;
if (deltaY > 0) {
deltaY -= mTouchSlop;
} else {
deltaY += mTouchSlop;
}
}
if (mIsBeingDragged) {
// Scroll to follow the motion event
mLastMotionY = y - mScrollOffset[1]; final int oldY = getScrollY();
final int range = getScrollRange();
final int overscrollMode = getOverScrollMode();
boolean canOverscroll = overscrollMode == View.OVER_SCROLL_ALWAYS
|| (overscrollMode == View.OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0); // Calling overScrollByCompat will call onOverScrolled, which
// calls onScrollChanged if applicable.
            // 该方法会触发自身内容的滚动
if (overScrollByCompat(0, deltaY, 0, getScrollY(), 0, range, 0,
0, true) && !hasNestedScrollingParent(ViewCompat.TYPE_TOUCH)) {
// Break our velocity if we hit a scroll barrier.
mVelocityTracker.clear();
} final int scrolledDeltaY = getScrollY() - oldY;
final int unconsumedY = deltaY - scrolledDeltaY;
            // 通知嵌套的父 View 我已经处理完滚动了,该你来处理了
if (dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset,
ViewCompat.TYPE_TOUCH)) {
              // 如果嵌套父View 消耗了滑动,那么需要更新
mLastMotionY -= mScrollOffset[1];
vtev.offsetLocation(0, mScrollOffset[1]);
mNestedYOffset += mScrollOffset[1];
} else if (canOverscroll) {
ensureGlows();
final int pulledToY = oldY + deltaY;
               // 触发边缘的阴影效果
if (pulledToY < 0) {
EdgeEffectCompat.onPull(mEdgeGlowTop, (float) deltaY / getHeight(),
ev.getX(activePointerIndex) / getWidth());
if (!mEdgeGlowBottom.isFinished()) {
mEdgeGlowBottom.onRelease();
}
} else if (pulledToY > range) {
EdgeEffectCompat.onPull(mEdgeGlowBottom, (float) deltaY / getHeight(),
1.f - ev.getX(activePointerIndex)
/ getWidth());
if (!mEdgeGlowTop.isFinished()) {
mEdgeGlowTop.onRelease();
}
}
if (mEdgeGlowTop != null
&& (!mEdgeGlowTop.isFinished() || !mEdgeGlowBottom.isFinished())) {
ViewCompat.postInvalidateOnAnimation(this);
}
}
}
break;
case MotionEvent.ACTION_UP:
final VelocityTracker velocityTracker = mVelocityTracker;
velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
          // 计算滑动速率
int initialVelocity = (int) velocityTracker.getYVelocity(mActivePointerId);
          // 大于最小的设定的速率,触发fling
if ((Math.abs(initialVelocity) > mMinimumVelocity)) {
flingWithNestedDispatch(-initialVelocity);
} else if (mScroller.springBack(getScrollX(), getScrollY(), 0, 0, 0,
getScrollRange())) {
ViewCompat.postInvalidateOnAnimation(this);
}
mActivePointerId = INVALID_POINTER;
endDrag();
break;
case MotionEvent.ACTION_CANCEL:
if (mIsBeingDragged && getChildCount() > 0) {
if (mScroller.springBack(getScrollX(), getScrollY(), 0, 0, 0,
getScrollRange())) {
ViewCompat.postInvalidateOnAnimation(this);
}
}
mActivePointerId = INVALID_POINTER;
endDrag();
break;
case MotionEvent.ACTION_POINTER_DOWN: {
final int index = ev.getActionIndex();
mLastMotionY = (int) ev.getY(index);
mActivePointerId = ev.getPointerId(index);
break;
}
case MotionEvent.ACTION_POINTER_UP:
onSecondaryPointerUp(ev);
mLastMotionY = (int) ev.getY(ev.findPointerIndex(mActivePointerId));
break;
} if (mVelocityTracker != null) {
mVelocityTracker.addMovement(vtev);
}
vtev.recycle();
return true;
}

ACTION_DOWN

先看 down 事件,如果处于 fling 期间,那么直接停止 fling, 接着会调用 startNestedScroll,会让 NestedScrollView 作为子 view 去 通知嵌套父 view,那么就需要找到有没有可以嵌套滑动的父 view 。

    public boolean startNestedScroll(int axes, int type) {
// 交给 mChildHelper 代理来处理相关逻辑
return mChildHelper.startNestedScroll(axes, type);
} public boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type) {
// 找到嵌套父 view 了,就直接返回
if (hasNestedScrollingParent(type)) {
// Already in progress
return true;
}
// 是否支持嵌套滚动
if (isNestedScrollingEnabled()) {
ViewParent p = mView.getParent();
View child = mView;
while (p != null) { // while 循环,将支持嵌套滑动的父 View 找出来。
if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)) {
// 把父 view 设置进去
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。当 ViewParentCompat.onStartNestedScroll 返回 true,就表示已经找到嵌套滚动的父 View 了 。下面来看下这个方法的具体逻辑:

    // ViewParentCompat
public static boolean onStartNestedScroll(ViewParent parent, View child, 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 parent.onStartNestedScroll(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;
}

这里其实没啥好分析,就是告诉父类当前是什么类型的滚动,以及滚动方向。其实这里可以直接看下 NestedScrollView 的 onStartNestedScroll 的逻辑。

//  NestedScrollView
public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes,
int type) {
     // 确保触发的是垂直方向的滚动
return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
}

当确定了嵌套父 View 以后,又会调用父 view 的  onNestedScrollAccepted 方法,在这里可以做一些准备工作和配置。下面我们看到的 是 Ns 里面的方法,注意不是父 view 的,只是当作参考。

public void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes, int type) {
mParentHelper.onNestedScrollAccepted(child, target, axes, type);
   // 这里 Ns 作为子 view 调用 该方法去寻找嵌套父 view。注意这个方法会被调用是 NS 作为父 view 收到的。这样就
startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, type);
}

到这里,down 的作用就讲完了。

ACTION_MOVE

首先是会调用 dispatchNestedPreScroll,讲当前的滑动距离告诉嵌套父 View。

  public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow,
int type) {
     // Ns 作为子 view 去通知父View
return mChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type);

下面看下 mChildHelper 的代码逻辑:

    /**
* Dispatch one step of a nested pre-scrolling operation to the current nested scrolling parent.
*
* <p>This is a delegate method. Call it from your {@link android.view.View View} subclass
* method/{@link androidx.core.view.NestedScrollingChild2} interface method with the same
* signature to implement the standard policy.</p>
*
* @return true if the parent consumed any of the nested scroll
*/
public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
@Nullable int[] offsetInWindow, @NestedScrollType int type) {
if (isNestedScrollingEnabled()) {
       // 获取之前找到的嵌套滚动的父 View
final ViewParent parent = getNestedScrollingParentForType(type);
if (parent == null) {
return false;
}
       // 滑动距离肯定不为0 才有意义
if (dx != 0 || dy != 0) {
int startX = 0;
int startY = 0;
if (offsetInWindow != null) {
mView.getLocationInWindow(offsetInWindow);
startX = offsetInWindow[0];
startY = offsetInWindow[1];
} if (consumed == null) {
if (mTempNestedScrollConsumed == null) {
mTempNestedScrollConsumed = new int[2];
}
consumed = mTempNestedScrollConsumed;
}
consumed[0] = 0;
consumed[1] = 0;
          // 调用嵌套父 View 的对应的回调
ViewParentCompat.onNestedPreScroll(parent, mView, dx, dy, consumed, type); 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;
}

这里主要是将滑动距离告诉 父 view,有消耗就会返回 true 。

    // ViewParentCompat
public static void onNestedPreScroll(ViewParent parent, View target, int dx, int dy,
int[] consumed) {
onNestedPreScroll(parent, target, dx, dy, consumed, ViewCompat.TYPE_TOUCH);
}

其实下面的 onNestedPreScroll 跟前面的 onStartNestedScroll 逻辑很像,就是层层传递。

    public static void onNestedPreScroll(ViewParent parent, View target, int dx, int dy,
int[] consumed, int type) {
if (parent instanceof NestedScrollingParent2) {
// First try the NestedScrollingParent2 API
((NestedScrollingParent2) parent).onNestedPreScroll(target, dx, dy, consumed, 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 {
parent.onNestedPreScroll(target, dx, dy, consumed);
} catch (AbstractMethodError e) {
Log.e(TAG, "ViewParent " + parent + " does not implement interface "
+ "method onNestedPreScroll", e);
}
} else if (parent instanceof NestedScrollingParent) {
((NestedScrollingParent) parent).onNestedPreScroll(target, dx, dy, consumed);
}
}
}

下面为了方便,没法查看 NS 的嵌套父 View 的逻辑。直接看 Ns 中对应的方法。

    public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed,
int type) {
     // 最终也是 Ns 再传给其嵌套父 View
dispatchNestedPreScroll(dx, dy, consumed, null, type);
}

传递完了之后,就会调用  overScrollByCompat 来实现滚动。

    boolean overScrollByCompat(int deltaX, int deltaY,
int scrollX, int scrollY,
int scrollRangeX, int scrollRangeY,
int maxOverScrollX, int maxOverScrollY,
boolean isTouchEvent) {
final int overScrollMode = getOverScrollMode();
final boolean canScrollHorizontal =
computeHorizontalScrollRange() > computeHorizontalScrollExtent();
final boolean canScrollVertical =
computeVerticalScrollRange() > computeVerticalScrollExtent();
final boolean overScrollHorizontal = overScrollMode == View.OVER_SCROLL_ALWAYS
|| (overScrollMode == View.OVER_SCROLL_IF_CONTENT_SCROLLS && canScrollHorizontal);
final boolean overScrollVertical = overScrollMode == View.OVER_SCROLL_ALWAYS
|| (overScrollMode == View.OVER_SCROLL_IF_CONTENT_SCROLLS && canScrollVertical); int newScrollX = scrollX + deltaX;
if (!overScrollHorizontal) {
maxOverScrollX = 0;
} int newScrollY = scrollY + deltaY;
if (!overScrollVertical) {
maxOverScrollY = 0;
} // Clamp values if at the limits and record
final int left = -maxOverScrollX;
final int right = maxOverScrollX + scrollRangeX;
final int top = -maxOverScrollY;
final int bottom = maxOverScrollY + scrollRangeY; boolean clampedX = false;
if (newScrollX > right) {
newScrollX = right;
clampedX = true;
} else if (newScrollX < left) {
newScrollX = left;
clampedX = true;
} boolean clampedY = false;
if (newScrollY > bottom) {
newScrollY = bottom;
clampedY = true;
} else if (newScrollY < top) {
newScrollY = top;
clampedY = true;
} if (clampedY && !hasNestedScrollingParent(ViewCompat.TYPE_NON_TOUCH)) {
mScroller.springBack(newScrollX, newScrollY, 0, 0, 0, getScrollRange());
}
     
onOverScrolled(newScrollX, newScrollY, clampedX, clampedY); return clampedX || clampedY;
}

整块逻辑其实没啥好说的,然后主要是看 onOverScrolled 这个方法:

   protected void onOverScrolled(int scrollX, int scrollY,
boolean clampedX, boolean clampedY) {
super.scrollTo(scrollX, scrollY);
}

最终是调用 scrollTo 方法来实现了滚动。

当滚动完了后,会调用 dispatchNestedScroll 告诉父 view 当前还剩多少没消耗,如果是 0,那么就不会上传,如果没消耗完,就会传给父 View 。

如果是子 View 传给 NS 的,是会通过 scrollBy 来进行消耗的,然后继续向上层传递。

    public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed,
int dyUnconsumed, int type) {
final int oldScrollY = getScrollY();
scrollBy(0, dyUnconsumed);
final int myConsumed = getScrollY() - oldScrollY;
final int myUnconsumed = dyUnconsumed - myConsumed;
dispatchNestedScroll(0, myConsumed, 0, myUnconsumed, null,
type);
}

假设当前已经滑动到顶部了,此时继续滑动的话,就会触发边缘的阴影效果。

ACTION_UP

当用户手指离开后,如果滑动速率超过最小的滑动速率,就会调用 flingWithNestedDispatch(-initialVelocity) ,下面来看看这个方法的具体逻辑:

    private void flingWithNestedDispatch(int velocityY) {
final int scrollY = getScrollY();
final boolean canFling = (scrollY > 0 || velocityY > 0)
&& (scrollY < getScrollRange() || velocityY < 0);
     // fling 前问问父View 要不要 fling, 一般是返回 false
if (!dispatchNestedPreFling(0, velocityY)) {
       // 这里主要是告诉父类打算自己消耗了
dispatchNestedFling(0, velocityY, canFling);
       // 自己处理
fling(velocityY);
}
}

下面继续看 fling 的实现。

    public void fling(int velocityY) {
if (getChildCount() > 0) { mScroller.fling(getScrollX(), getScrollY(), // start
0, velocityY, // velocities
0, 0, // x
Integer.MIN_VALUE, Integer.MAX_VALUE, // y
0, 0); // overscroll
runAnimatedScroll(true);
}
} private void runAnimatedScroll(boolean participateInNestedScrolling) {
if (participateInNestedScrolling) {
// fling 其实也是一种滚动,只不过是非接触的
startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_NON_TOUCH);
} else {
stopNestedScroll(ViewCompat.TYPE_NON_TOUCH);
}
mLastScrollerY = getScrollY();
ViewCompat.postInvalidateOnAnimation(this);
}

最终会触发重绘操作,重绘过程中会调用 computeScroll,下面看下其内部的代码逻辑。

    @Override
public void computeScroll() { if (mScroller.isFinished()) {
return;
} mScroller.computeScrollOffset();
final int y = mScroller.getCurrY();
int unconsumed = y - mLastScrollerY;
mLastScrollerY = y; // Nested Scrolling Pre Pass
mScrollConsumed[1] = 0;
     // 滚动的时候,依然会把当前的未消耗的滚动距离传给嵌套父View
dispatchNestedPreScroll(0, unconsumed, mScrollConsumed, null,
ViewCompat.TYPE_NON_TOUCH);
unconsumed -= mScrollConsumed[1]; final int range = getScrollRange(); if (unconsumed != 0) {
// Internal Scroll
final int oldScrollY = getScrollY();
       // 自己消耗
overScrollByCompat(0, unconsumed, getScrollX(), oldScrollY, 0, range, 0, 0, false);
final int scrolledByMe = getScrollY() - oldScrollY;
unconsumed -= scrolledByMe; // Nested Scrolling Post Pass
mScrollConsumed[1] = 0;
        // 继续上传给父View
dispatchNestedScroll(0, scrolledByMe, 0, unconsumed, mScrollOffset,
ViewCompat.TYPE_NON_TOUCH, mScrollConsumed);
unconsumed -= mScrollConsumed[1];
}
     // 如果到这里有未消耗的,说明已经滚动到边缘了
if (unconsumed != 0) {
final int mode = getOverScrollMode();
final boolean canOverscroll = mode == OVER_SCROLL_ALWAYS
|| (mode == OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0);
if (canOverscroll) {
ensureGlows();
if (unconsumed < 0) {
if (mEdgeGlowTop.isFinished()) {
mEdgeGlowTop.onAbsorb((int) mScroller.getCurrVelocity());
}
} else {
if (mEdgeGlowBottom.isFinished()) {
mEdgeGlowBottom.onAbsorb((int) mScroller.getCurrVelocity());
}
}
}
       // 停止滚动  
abortAnimatedScroll();
}
     // 如果此时滚动还未结束,并且当前的滑动距离都被消耗了,那么继续刷新滚动,直到停止为止
if (!mScroller.isFinished()) {
ViewCompat.postInvalidateOnAnimation(this);
}
}

到这里,关于 Ns 的嵌套滑动就讲完了。希望大家能够对嵌套滑动有个理解。

阅读 Ns 的源码,可以让你更好的理解嵌套滑动,以及事件分发的逻辑。

 
上一篇:Unity实现滑页嵌套(解决ScrollRect嵌套冲突问题)


下一篇:使用Android SwipeRefreshLayout了解Android的嵌套滑动机制