详解NestedScrollView滑动监听中的一些判断技巧
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了详解NestedScrollView滑动监听中的一些判断技巧相关的知识,希望对你有一定的参考价值。
参考技术A众所周知, ScrollView 与 NestedScrollView 都是用来滑动的控件,但是 ScrollView 没有提供滑动监听的接口,需要我们自己去实现,而 NestedScrollView 提供了一个 setOnScrollChangeListener 方法用来监听我们的滑动事件,此接口一共有5个参数,作用如下:
有人说,这还不简单,直接调用 NestedScrollView.getMeasuredHeight() 不就可以了,然后与 scrollY 一除,滑动比就出来了,我只想说: too young too simple
事实上, NestedScrollView.getMeasuredHeight() 获取的只是当前显示出来的一屏的测量高度(不包括未滑动出来的距离),真正的总高度应该调用 NestedScrollView.getChildAt(0).getMeasuredHeight() 来获取, 需要注意,这是控件高度,不是滑动高度,不要傻白甜的用这个与scrollY 去除获取滑动比例 ,如果这样做的说明你对 scrollY 这个参数理解不够,这个参数是代表的滑动距离,在滑动之前已经是有一屏的距离了,所以它们之间的关系可以表示为:
需要注意的是,如果给 NestedScrollView 设置了padding,那么M还需要减去padding的数值
获取到了滑动百分比,我们就能用它来控制文字大小,位置,透明度等等,从而实现一些酷炫的效果
理解了滑动距离与总滑动长度之前的关系,这种判断还不是手到擒来
原理其实都一样
自动滑动到底部:
自动滑动到顶部:
滑动到某个位置
而位置的信息可以通过 view.getTop(),view.getBottom() 等方法获取控件的坐标信息,例如,滑动到指定view的上方:
PHP.DHN透过 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 之间存在相互调用,遵循下面的调用关系:
上图可以这么理解:
- 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,否则会报错。
对于 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>
效果:
可以发现这个没有铺满全屏,可是 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); } }
以上是关于详解NestedScrollView滑动监听中的一些判断技巧的主要内容,如果未能解决你的问题,请参考以下文章
android中NestedScrollView嵌套EditText,导致滑动冲突问题
PHP.DHN透过 NestedScrollView 源码解析嵌套滑动原理-附教程