Android 嵌套滑动

11 篇文章 0 订阅

在 Android 的事件分发机制当中,在同一个事件流中,如果由父控件拦截/消费了,那么子控件就没办法再获取到该事件流。这种传统的事件分发机制在嵌套滑动时会有明显不足,就是子控件无法消费父控件没有消费掉的滑动距离(因为起初是父控件接收了滑动事件,那么在同一个事件流中,所有事件都会给到父控件,子控件接收不到事件,也就无法滑动),从而造成嵌套滑动的不连贯(如果想滑动子控件就只能抬起手指结束本次事件,并在下一次事件中滑动子控件)。

使用嵌套滑动机制可以有效的解决上面的问题。它并没有改变事件分发机制,在发生嵌套滑动时,还是先进行事件分发,由父 View 将事件分发给子 View,由子 View 进行消费。只不过,子 View 在自己消费之前,会先去询问父 View,是否需要处理滑动事件,如果父 View 需要处理,就先交由父 View 进行滑动,父 View 滑完了子 View 才进行滑动。于是才有了“在嵌套滑动中,子 View 是主动的”这样的说法。

下面我们就结合源码看看嵌套滑动是如何实现的。

一、NestedScrollingChild 与 NestedScrollingParent

在嵌套滑动中有两个角色:Child 和 Parent,Child 需要实现 NestedScrollingChild/NestedScrollingChild2/NestedScrollingChild3 接口之一,而 Parent 需要实现 NestedScrollingParent/NestedScrollingParent2/NestedScrollingParent3 接口之一。

两组接口在各自内部都具有继承关系,先看 NestedScrollingChild:

public interface NestedScrollingChild {
    /**
    * 开关嵌套滑动功能的方法,设置为true,并且当前界面的View的布局结构支持嵌套滚动时
    * (也就是需要NestedScrollingParent嵌套NestedScrollingChild),才会触发嵌套滚动。
    */
    void setNestedScrollingEnabled(boolean enabled);
    
    /**
    * 判断当前View是否支持嵌套滑动。
    */
    boolean isNestedScrollingEnabled();
    
    /**
    * 表示View开始滑动了,一般是在ACTION_DOWN中调用,如果返回true则表示父布局支持嵌套滑动。
    * 这个时候正常情况会触发Parent的onStartNestedScroll()方法
    */
    boolean startNestedScroll(@ScrollAxis int axes);
    
    /**
    * 一般是在事件结束比如ACTION_UP或者ACTION_CANCEL中调用,告诉父布局滑动结束。
    */
    void stopNestedScroll();
    
    /**
    * 判断当前View是否有嵌套滑动的Parent。
    */
    boolean hasNestedScrollingParent();
    
    /**
    * 在当前View消费一定的滑动距离之后,可能没有消费完,可以通过调用该方法,把剩下的滚动距离
    * 分发给父布局,询问其是否可以再消费。
    * dxConsumed:被当前View消费了的水平方向滑动距离
    * dyConsumed:被当前View消费了的垂直方向滑动距离
    * dxUnconsumed:未被消费的水平滑动距离
    * dyUnconsumed:未被消费的垂直滑动距离
    * offsetInWindow:可选的输出参数。如果不是null,该方法返回时,会将该视图从该操作
    * 之前到该操作完成之后的本地视图坐标中的偏移量封装进该参数中,offsetInWindow[0]水平方向,
    * offsetInWindow[1]垂直方向
    * @return true:表示滚动事件分发成功,fasle: 分发失败
    */
    boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);
    
    /**
    * 在当前View消费滚动距离之前把滑动距离传给父布局。相当于把优先处理权交给Parent
	* dx:当前水平方向滑动的距离
	* dy:当前垂直方向滑动的距离
	* consumed:输出参数,会将Parent消费掉的距离封装进该参数,consumed[0]代表水平方向,consumed[1]代表垂直方向
	* @return true:代表Parent消费了滚动距离
    */
    boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
            @Nullable int[] offsetInWindow);
    
    /**
    * 将惯性滑动的速度分发给Parent。
	* velocityX:表示水平滑动速度
	* velocityY:垂直滑动速度
	* consumed:true:表示当前View消费了滑动事件,否则传入false
	* @return true:表示Parent处理了滑动事件
	*/
    boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);
    
    /**
    * 在当前View自己处理惯性滑动前,先将滑动事件分发给Parent,一般来说如果想自己处理惯性的滑动事件,
    * 就不应该调用该方法给Parent处理。如果给了Parent并且返回true,那表示Parent已经处理了,自己就不应该再做处理。
    * 返回false,代表Parent没有处理,但是不代表Parent后面就不用处理了
    * @return true:表示Parent处理了惯性滑动事件
    */
    boolean dispatchNestedPreFling(float velocityX, float velocityY);
}

NestedScrollingChild2 继承了 NestedScrollingChild,对 NestedScrollingChild 内的部分方法参数进行了扩展,添加了 @NestedScrollType int type 参数,这个 type 用来表示滑动类型,是 TYPE_TOUCH(触摸滑动)或 TYPE_NON_TOUCH(惯性滑动):

public interface NestedScrollingChild2 extends NestedScrollingChild {

    boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type);

    void stopNestedScroll(@NestedScrollType int type);

    boolean hasNestedScrollingParent(@NestedScrollType int type);

    boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow,
            @NestedScrollType int type);

    boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
            @Nullable int[] offsetInWindow, @NestedScrollType int type);
}

NestedScrollingChild3 又继承了 NestedScrollingChild2,又对 dispatchNestedScroll() 的方法参数进行了扩充:

public interface NestedScrollingChild3 extends NestedScrollingChild2 {

    void dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed,
            @Nullable int[] offsetInWindow, @ViewCompat.NestedScrollType int type,
            @NonNull int[] consumed);
}

再来看 NestedScrollingParent:

public interface NestedScrollingParent {
    /**
    * 当NestedScrollingChild调用startNestedScroll()时,会回调本方法,通过返回值告诉
    * 子View是当前View否需要对后续的滚动进行处理
    * child:该NestedScrollingParent的直接子View,且该子View要包含NestedScrollingChild
    * target:本次嵌套滚动的NestedScrollingChild
    * nestedScrollAxes:滚动方向
    * @return true 表示需要处理滑动事件,false 则表示不处理,并且后续的回调方法也不会再执行
    *
    * 关于child和target的关系,如果布局是NestedScrollingParent->NestedScrollingChild,
    * 那么child和target就都是NestedScrollingChild;如果布局是NestedScrollingParent->
    * LinearLayout1->LinearLayout2->NestedScrollingChild,那么child是LinearLayout1,
    * target还是NestedScrollingChild
    */
    boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes);

    /**
    * 如果onStartNestedScroll()方法返回的是true的话,那么紧接着就会调用该方法。
    * 它会在嵌套滑动开始之前,让Parent或其父容器进行初始化工作。
    */
    void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes);

    /**
    * 停止滚动了,当子View调用stopNestedScroll()时会回调本方法
    */
    void onStopNestedScroll(@NonNull View target);

    /**
    * 当子View调用dispatchNestedScroll()方法时,会回调本方法。也就是开始分发处理嵌套滑动了
    * dxConsumed:已经被target消费掉的水平方向的滑动距离
    * dyConsumed:已经被target消费掉的垂直方向的滑动距离
    * dxUnconsumed:未被tagert消费掉的水平方向的滑动距离
    * dyUnconsumed:未被tagert消费掉的垂直方向的滑动距离
    */
    void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed);

    /**
    * 当子View调用dispatchNestedPreScroll()方法时,会回调本方法。也就是在NestedScrollingChild处理滑动之前,
    * 会先将机会给Parent处理。如果Parent想先消费部分滚动距离,将消费的距离放入consumed
    * dx:水平滑动距离
    * dy:处置滑动距离
    * consumed:表示Parent要消费的滚动距离,consumed[0]和consumed[1]分别表示父布局在x和y方向上消费的距离
    */
    void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed);

    /**
    * 你可以捕获对内部NestedScrollingChild的fling事件
    * velocityX:水平方向的滑动速度
    * velocityY:垂直方向的滑动速度
    * consumed:是否被child消费了
    * @return true 表示消费了惯性滑动事件
    */
    boolean onNestedFling(@NonNull View target, float velocityX, float velocityY, boolean consumed);

    /**
    * 在惯性滑动距离处理之前,会调用该方法,同onNestedPreScroll()一样,也是给Parent优先处理的权利
    * target:本次嵌套滚动的NestedScrollingChild
    * velocityX:水平方向的滑动速度
    * velocityY:垂直方向的滑动速度
    * @return true:表示parent要处理本次惯性滑动事件,child就不要处理了
    */
    boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY);

    /**
    * 返回当前滑动的方向,一般直接通过NestedScrollingParentHelper.getNestedScrollAxes()返回即可
    */
    @ScrollAxis
    int getNestedScrollAxes();
}

NestedScrollingParent2 和 NestedScrollingParent3 对 NestedScrollingParent 的扩展类似于 NestedScrollingChild,代码就不贴了。

二、滑动过程

以上接口中的众多方法是如何使用的,过程如何,先看下图:

将整个嵌套滑动过程分为四个阶段:初始阶段、预滚动阶段、滚动阶段和结束阶段。每个阶段都是子 View 主动回调父控件中的方法,询问父控件的处理状态。而父控件则是被动的接收到滑动事件,根据自己的状态和需求决定是否消费并返回结果。

初始阶段

源码将以既实现了 NestedScrollingChild3 又实现了 NestedScrollingParent3 的 NestedScrollView 为例。

在实现 NestedScrollingChild 和 NestedScrollingParent 接口内的方法时,具体的操作一般都是交给相应的帮助类 NestedScrollingChildHelper 和 NestedScrollingParentHelper 内的同名方法来处理的。比如说在初始阶段,先调用 NestedScrollingChild 中的 setNestedScrollingEnabled() 来开启嵌套滑动,实际上是调用的 NestedScrollingChildHelper 的同名方法:

#NestedScrollView:
 
	@Override
    public void setNestedScrollingEnabled(boolean enabled) {
        mChildHelper.setNestedScrollingEnabled(enabled);
    }

#NestedScrollingChildHelper:

	public void setNestedScrollingEnabled(boolean enabled) {
        if (mIsNestedScrollingEnabled) {
            ViewCompat.stopNestedScroll(mView);
        }
        mIsNestedScrollingEnabled = enabled;
    }

随后当发生 ACTION_DOWN 时,在 Child 的 onTouchEvent() 中调用 startNestedScroll():

	@Override
    public boolean onTouchEvent(MotionEvent ev) {
		final int actionMasked = ev.getActionMasked();
		switch (actionMasked) {
			case MotionEvent.ACTION_DOWN: {
				……
				startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH);
                break;
			}
		}
	}

还是直接把处理交给 Helper,开始嵌套滑动的首要工作就是找到一个能处理嵌套滑动事件的父容器:

#NestedScrollView:

	@Override
    public boolean startNestedScroll(int axes, int type) {
        return mChildHelper.startNestedScroll(axes, type);
    }

#NestedScrollingChildHelper:
	
	public boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type) {
		// 如果已经找到嵌套滑动的Parent了,直接返回
        if (hasNestedScrollingParent(type)) {
            return true;
        }
        // 在开启嵌套滑动的前提下,为当前Child循环向上寻找一个可以处理嵌套滑动的Parent
        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;
                }
               	// 如果p不能处理嵌套滑动,则让child指向p,而p指向p的parent开启下一次循环
                if (p instanceof View) {
                    child = (View) p;
                }
                p = p.getParent();
            }
        }
        return false;
    }

ViewParentCompat 做了兼容处理,onStartNestedScroll() 是去调用 NestedScrollingParent 中的同名方法,返回值表示 Parent 是否接受嵌套滑动事件:

#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 内是怎样决定是否处理嵌套滑动的:

	// NestedScrollingParent
    @Override
    public boolean onStartNestedScroll(
            @NonNull View child, @NonNull View target, int nestedScrollAxes) {
        return onStartNestedScroll(child, target, nestedScrollAxes, ViewCompat.TYPE_TOUCH);
    }
    
    // NestedScrollingParent2
    @Override
    public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes,
            int type) {
        return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
    }

可以看到垂直方向滑动就可以处理嵌套滑动。由于 NestedScrollingParent2 中的 onStartNestedScroll() 参数中提供了滑动方向 axes,所以这里不需要调用我们图中给出的 getNestedScrollAxes() 去获取滑动方向了。

回到 startNestedScroll(),假如我们找到了一个 Parent,接下来就会立即调用 setNestedScrollingParentForType() 和 onNestedScrollAccepted():

#NestedScrollingChildHelper:

	private void setNestedScrollingParentForType(@NestedScrollType int type, ViewParent p) {
        switch (type) {
        	// 触摸滑动
            case TYPE_TOUCH:
                mNestedScrollingParentTouch = p;
                break;
            // 不是用户触摸屏幕造成的滑动,比如惯性滑动Fling
            case TYPE_NON_TOUCH:
                mNestedScrollingParentNonTouch = p;
                break;
        }
    }
   
#ViewParentCompat:

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

onNestedScrollAccepted() 主要是给 Parent 做初始化工作用的,比如调用 NestedScrollingParentHelper 中的同名方法可以记录是横向滑动还是纵向滑动:

#NestedScrollingParentHelper:

	public void onNestedScrollAccepted(@NonNull View child, @NonNull View target,
            @ScrollAxis int axes, @NestedScrollType int type) {
        if (type == ViewCompat.TYPE_NON_TOUCH) {
            mNestedScrollAxesNonTouch = axes;
        } else {
            mNestedScrollAxesTouch = axes;
        }
    }

总结一下,初始阶段主要做了两件事:

  1. 开启了嵌套滑动功能
  2. 为 Child 找到一个能处理嵌套滑动的 Parent,如果找到了,就初始化该 Parent

预滚动阶段、滚动阶段

预滚动阶段由 ACTION_MOVE 事件触发,先调用 dispatchNestedPreScroll() 询问 Parent 是否需要处理,同样是把具体操作交给 NestedScrollingChildHelper 的同名方法:

	@Override
    public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow,
            int type) {
        return mChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type);
    }
	
	public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
            @Nullable int[] offsetInWindow, @NestedScrollType int type) {
        if (isNestedScrollingEnabled()) {
        	// 拿到支持嵌套滑动的父容器
            final ViewParent parent = getNestedScrollingParentForType(type);
            if (parent == null) {
                return false;
            }

            if (dx != 0 || dy != 0) {
                int startX = 0;
                int startY = 0;
                if (offsetInWindow != null) {
                    mView.getLocationInWindow(offsetInWindow);
                    startX = offsetInWindow[0];
                    startY = offsetInWindow[1];
                }

				// 如果参数没传 consumed,就临时构造一个。容错处理
                if (consumed == null) {
                    consumed = getTempNestedScrollConsumed();
                }
                consumed[0] = 0;
                consumed[1] = 0;
                // 调用 Parent 的 onNestedPreScroll(),询问其是否需要处理滑动
                ViewParentCompat.onNestedPreScroll(parent, mView, dx, dy, consumed, type);

                if (offsetInWindow != null) {
                    mView.getLocationInWindow(offsetInWindow);
                    offsetInWindow[0] -= startX;
                    offsetInWindow[1] -= startY;
                }
                // 如果 Parent 有在xy的某一个方向进行了消费,就返回 true 表示 Parent 处理了嵌套滑动
                return consumed[0] != 0 || consumed[1] != 0;
            } else if (offsetInWindow != null) {
                offsetInWindow[0] = 0;
                offsetInWindow[1] = 0;
            }
        }
        return false;
    }

通过 ViewParentCompat 调用到 Parent 的 onNestedPreScroll(),这时就需要根据具体业务情况处理了,比如说 NestedScrollView 是将其继续向上层分发:

#NestedScrollView:

	@Override
    public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed,
            int type) {
        dispatchNestedPreScroll(dx, dy, consumed, null, type);
    }

而如果我们自定义时需要直接进行滑动处理的话,需要将消费掉的距离准确的写到 consumed 中:

	@Override
    public void onNestedPreScroll(View target, int dx, int dy, int[] consumed)
    {
        // 垂直方向上滑动 dy
        scrollBy(0, dy);
        // 把 Y 轴消费掉的距离写到 consumed[1] 中
        consumed[1] = dy;
    }

这样 Child 会通过 consumed 得知 Parent 消费的距离,然后决定后面如何处理,以 NestedScrollView 为例:

	@Override
    public boolean onTouchEvent(MotionEvent ev) {
		switch (actionMasked) {
			if (mIsBeingDragged) {
				case MotionEvent.ACTION_MOVE:
                // 省略非重点部分……
                if (mIsBeingDragged) {
                    // 1.先询问 Parent 是否消费
                    if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset,
                            ViewCompat.TYPE_TOUCH)) {
                        // 如果 Parent 消费了,就从 deltaY 中减去已经消费掉的部分
                        deltaY -= mScrollConsumed[1];
                        mNestedYOffset += mScrollOffset[1];
                    }

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

                    // 2.自己消费一部分
                    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;

                    mScrollConsumed[1] = 0;
					// 3.再次询问 Parent 是否消费
                    dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset,
                            ViewCompat.TYPE_TOUCH, mScrollConsumed);

                    mLastMotionY -= mScrollOffset[1];
                    mNestedYOffset += mScrollOffset[1];

                    // 省略……
                }
                break;
		}
	}

关注主流程,第 2 步计算出还未消费的 Y 轴距离后,在第 3 步再次询问 Parent 是否消费,通过 NestedScrollingChildHelper 还会再回调到 Parent 的 onNestedScroll():

#NestedScrollingChildHelper:

	public void dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
            int dyUnconsumed, @Nullable int[] offsetInWindow, @NestedScrollType int type,
            @Nullable int[] consumed) {
        dispatchNestedScrollInternal(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
                offsetInWindow, type, consumed);
    }

	private boolean dispatchNestedScrollInternal(int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow,
            @NestedScrollType int type, @Nullable int[] consumed) {
        if (isNestedScrollingEnabled()) {
            final ViewParent parent = getNestedScrollingParentForType(type);
            if (parent == null) {
                return false;
            }

            if (dxConsumed != 0 || dyConsumed != 0 || dxUnconsumed != 0 || dyUnconsumed != 0) {
                int startX = 0;
                int startY = 0;
                if (offsetInWindow != null) {
                    mView.getLocationInWindow(offsetInWindow);
                    startX = offsetInWindow[0];
                    startY = offsetInWindow[1];
                }

                if (consumed == null) {
                    consumed = getTempNestedScrollConsumed();
                    consumed[0] = 0;
                    consumed[1] = 0;
                }
				// 回调 Parent 的 onNestedScroll()
                ViewParentCompat.onNestedScroll(parent, mView,
                        dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type, consumed);

                if (offsetInWindow != null) {
                    mView.getLocationInWindow(offsetInWindow);
                    offsetInWindow[0] -= startX;
                    offsetInWindow[1] -= startY;
                }
                return true;
            } else if (offsetInWindow != null) {
                // No motion, no dispatch. Keep offsetInWindow up to date.
                offsetInWindow[0] = 0;
                offsetInWindow[1] = 0;
            }
        }
        return false;
    }

onNestedScroll() 的具体处理还是要看业务逻辑,这里我们仍以 NestedScrollView 为例:

	// NestedScrollingParent3
    @Override
    public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, int type, @NonNull int[] consumed) {
        onNestedScrollInternal(dyUnconsumed, type, consumed);
    }

    private void onNestedScrollInternal(int dyUnconsumed, int type, @Nullable int[] consumed) {
        final int oldScrollY = getScrollY();
        // 垂直方向上滑动 dyUnconsumed
        scrollBy(0, dyUnconsumed);
        // 计算出本次垂直方向上消费的距离
        final int myConsumed = getScrollY() - oldScrollY;

		// 把消费距离 myConsumed 累加到 consumed[1],并从为消费距离中减掉
        if (consumed != null) {
            consumed[1] += myConsumed;
        }
        final int myUnconsumed = dyUnconsumed - myConsumed;

		// 回调 Parent 的 dispatchNestedScroll() 把更新后的数据传进去
        mChildHelper.dispatchNestedScroll(0, myConsumed, 0, myUnconsumed, null, type, consumed);
    }

自己消费掉一部分,计算出未消费部分,继续向上分发,逻辑如下图:


其实总结一下,就是 Child 通过 dispatchNestedPreScroll() 询问 Parent 是否要处理滑动,如果 Parent 消费就把消费掉的部分写入方法参数 consumed,Child 根据 consumed 的值决定后续操作,如果仍有未消费部分,可以自己消费掉一部分,如果还有剩余,就再通过 dispatchNestedScroll() 二次询问 Parent 是否消费。

到这里预滚动阶段和滚动阶段的流程就说完了。

结束阶段

结束阶段一般在 ACTION_UP 事件中处理,先执行 dispatchNestedPreFling(),回调到 Parent 的 onNestedPreFling(),然后执行 dispatchNestedFling() 再回调 Parent 的 onNestedFling(),最后执行自己的 Fling 操作。

Fling 操作结束后,调用 stopNestedScroll(),同样是回调 Parent 的相关方法,过程与之前类似,就不再啰嗦的贴出全部代码了,可以去 NestedScrollView 中看一下(NestedScrollView 的 stopNestedScroll() 是在 ACTION_UP 的 endDrag() 中调用的)。

整个滑动过程的调用流程图如下:

请添加图片描述

三、举例

下面举个简单的实例吧,效果如下:

布局很简单,嵌套滑动的 Parent 中从上到下依次是 ImageView、TextView 和嵌套滑动的 Child,Child 也是一个容器,里面放一个 TextView:

<?xml version="1.0" encoding="utf-8"?>
<com.frank.nestedscroll.nested.NestedScrollParentLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <ImageView
        android:layout_width="match_parent"
        android:layout_height="200dp"
        android:scaleType="centerCrop"
        android:src="@drawable/image1" />

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@color/colorAccent"
        android:gravity="center"
        android:text="顶置的TextView" />

    <com.frank.nestedscroll.nested.NestedScrollChildLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <TextView
            android:id="@+id/text_view"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="@string/content"
            android:textSize="24sp" />
    </com.frank.nestedscroll.nested.NestedScrollChildLayout>

</com.frank.nestedscroll.nested.NestedScrollParentLayout>

自定义的控件 NestedScrollParentLayout、NestedScrollChildLayout 都继承了 LinearLayout 并分别实现了 NestedScrollingParent2、NestedScrollingChild2,需要先说明的是,这两个控件的实现不具有一般性,很多地方的实现都是基于已知某个子控件是 ImageView 或 TextView 的前提下,仅做加深对原理的理解之用。

先来看 NestedScrollChildLayout,初始化时创建 NestedScrollingChildHelper,所有嵌套滑动过程中用到的 NestedScrollingChild2 中的方法都可以交给 NestedScrollingChildHelper 处理:

public class NestedScrollChildLayout extends LinearLayout implements NestedScrollingChild2 {

    private static final String TAG = NestedScrollChildLayout.class.getSimpleName();

    private NestedScrollingChildHelper mChildHelper;

    public NestedScrollChildLayout(Context context) {
        this(context, null);
    }

    public NestedScrollChildLayout(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public NestedScrollChildLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mChildHelper = new NestedScrollingChildHelper(this);
        mChildHelper.setNestedScrollingEnabled(true);
    }

    @Override
    public boolean isNestedScrollingEnabled() {
        return mChildHelper.isNestedScrollingEnabled();
    }

    @Override
    public boolean startNestedScroll(int axes, int type) {
        return mChildHelper.startNestedScroll(axes, type);
    }

    @Override
    public void stopNestedScroll(int type) {
        mChildHelper.stopNestedScroll(type);
    }

    @Override
    public boolean hasNestedScrollingParent(int type) {
        return mChildHelper.hasNestedScrollingParent(type);
    }

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

    @Override
    public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow, int type) {
        return mChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type);
    }

    @Override
    public boolean dispatchNestedPreFling(float velocityX, float velocityY) {
        return mChildHelper.dispatchNestedPreFling(velocityX, velocityY);
    }

    @Override
    public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {
        return mChildHelper.dispatchNestedFling(velocityX, velocityY, consumed);
    }
    ......
}

NestedScrollChildLayout 本身的高度设置的是 match_parent,但是其内部可以滑动的 TextView 假如内容很多的话,它的高度可能会比 NestedScrollChildLayout 甚至整个屏幕的高度还要高,所以需要重写 onMeasure() 进行测量:

	int mRealHeight = 0;
	
	@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        mRealHeight = 0;
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        for (int i = 0; i < getChildCount(); i++) {
            View view = getChildAt(i);
            // 子 View 是 WRAP_CONTENT 的话,由于是在一个可滚动的父 View 中,
            // 其实际高度可能高于父 View 的高度,所以设置为 UNSPECIFIED。
            if (view.getLayoutParams().height == LayoutParams.WRAP_CONTENT) {
                heightMeasureSpec = MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(heightMeasureSpec), MeasureSpec.UNSPECIFIED);
            }

            measureChild(view, widthMeasureSpec, heightMeasureSpec);
            Log.i(TAG, "getMeasuredHeight: " + view.getMeasuredHeight());
            mRealHeight += view.getMeasuredHeight();
        }
        Log.i(TAG, "realHeight: " + mRealHeight);
        setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.getSize(heightMeasureSpec));
    }

意思就是如果子 View 的高度是 WRAP_CONTENT 的话,考虑到其高度可能会远超父容器和屏幕,所以先不给它设置限制,即把 MeasureSpec 的 mode 改成 UNSPECIFIED,关于这一点的原理可以参考 每日一问:详细说一下 MeasureSpec.UNSPECIFIED

再有就是要控制滑动的范围,上不能超出 0 下不能越过整个控件的测量高度:

	/**
     * 控制纵向滑动时不能超出上下边界范围
     */
    @Override
    public void scrollTo(int x, int y) {
        Log.i(TAG, "y: " + y + ", getScrollY: " + getScrollY() + ", height: " + getHeight() + ", realHeight: " + mRealHeight + ", -- " + (mRealHeight - getHeight()));
        if (y < 0) {
            y = 0;
        }
        if (y > mRealHeight) {
            y = mRealHeight;
        }
        if (y != getScrollY()) {
            Log.e(TAG, "scrollTo: " + y);
            super.scrollTo(x, y);
        }
    }

最后就是作为嵌套滑动中“主动”的一方,在 ACTION_DOWN、ACTION_MOVE 事件发生时调用相应的流程方法:

	private int mLastTouchX;
    private int mLastTouchY;
    
	@Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: {
                mLastTouchY = (int) (event.getRawY() + .5f);
                startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_NON_TOUCH);
                break;
            }
            case MotionEvent.ACTION_MOVE: {
                int x = (int) (event.getRawX() + .5f);
                int y = (int) (event.getRawY() + .5f);
                int dx = mLastTouchX - x;
                int dy = mLastTouchY - y;
                mLastTouchX = x;
                mLastTouchY = y;

                int[] consumed = new int[2];
                // 先询问滑动 Parent 是否消费滑动事件,将其消费掉的部分减去,剩下的自己消费
                if (dispatchNestedPreScroll(dx, dy, consumed, null, ViewCompat.TYPE_NON_TOUCH)) {
                    Log.i(TAG, "dy: " + dy + ", consumed: " + consumed[1]);
                    dy -= consumed[1];
                    if (dy == 0) {
                        Log.i(TAG, "dy: " + dy);
                        return true;
                    } /*else {
                        // 有剩余的话,调用 dispatchNestedScroll() 再次分发给 Parent 消费。
                    }*/
                } else {
                    // 滑动 Parent 不处理滑动事件就全部由自己消费掉
                    Log.i(TAG, "scrollBy: " + dy);
                    scrollBy(0, dy);
                }
                break;
            }
        }
        return true;
    }

ACTION_MOVE 中先调用 dispatchNestedPreScroll() 把滑动事件交给 Parent 处理,如果 Parent 处理了就看它是否消费了全部的 dy,再做后续处理,否则就由自己消费全部的 dy。

Child 这边还重写了一个 canScrollVertically(),用来判断是否能在指定的方向上继续滑动:

	/**
     * @param direction 为负数时检查向上滑动,为正数时检查向下滑动
     */
    @Override
    public boolean canScrollVertically(int direction) {
        // 已经滑动的 Y 轴距离,范围为[0,mRealHeight]
        int scrollY = getScrollY();
        // 向下滑动时,只要 scrollY > 0 说明还没见顶,就还能滑
        if (direction > 0) {
            return scrollY > 0;
        } else if (direction < 0) {
            // 向上滑动时,只要 scrollY < mRealHeight 就说明还没到底部,就还能滑
            return scrollY < mRealHeight;
        }
        return super.canScrollVertically(direction);
    }

重写这个方法是因为 View 中默认的方法并不适合我们的滑动逻辑,它会在 Parent 中用到,到时候再说一下。

以上是 Child 部分,再看 Parent,套路基本是一样的,先创建 NestedScrollingParentHelper 把大部分任务交给它:

public class NestedScrollParentLayout extends LinearLayout implements NestedScrollingParent2 {

    private static final String TAG = NestedScrollParentLayout.class.getSimpleName();

    private NestedScrollingParentHelper mParentHelper;

    public NestedScrollParentLayout(Context context) {
        this(context, null);
    }

    public NestedScrollParentLayout(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public NestedScrollParentLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mParentHelper = new NestedScrollingParentHelper(this);
    }

    /**
     * 纵向滑动时才处理
     */
    @Override
    public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes, int type) {
        return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
    }

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

    @Override
    public void onStopNestedScroll(@NonNull View target, int type) {
        mParentHelper.onStopNestedScroll(target, type);
    }

    @Override
    public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) {

    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int realHeight = 0;
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        for (int i = 0; i < getChildCount(); i++) {
            View view = getChildAt(i);
            if (view.getLayoutParams().height == LayoutParams.WRAP_CONTENT) {
                heightMeasureSpec = MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(heightMeasureSpec), MeasureSpec.UNSPECIFIED);
            }

            measureChild(view, widthMeasureSpec, heightMeasureSpec);
            Log.i(TAG, "getMeasuredHeight: " + view.getClass().getSimpleName() + "," + view.getMeasuredHeight());
            realHeight += view.getMeasuredHeight();
        }
        Log.i(TAG, "realHeight: " + realHeight);
        setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.getSize(heightMeasureSpec));
    }

    @Override
    public void scrollTo(int x, int y) {
    	// child view 0 就是 ImageView,滑动下边界不能超过 ImageView 的底部
        View view = getChildAt(0);
        if (y < 0) {
            y = 0;
        }
        if (y > view.getHeight()) {
            y = view.getHeight();
        }
        if (y != getScrollY()) {
            super.scrollTo(x, y);
        }
    }
}

onMeasure() 代码和 Child 是一样的,scrollTo() 的下边界控制在 ImageView 的底边,这样滑动时即便传入了超出边界的 y 也不会执行滑动动作,从而实现 ImageView 下面的 TextView 的顶置效果。

嵌套滑动流程方法中唯一需要重写的就是 onNestedPreScroll(),当 Child 把嵌套滑动事件分发过来的时候,我们要根据当前状态决定是否处理,从效果图中能看出我们的处理原则是:

  1. 向上滑动时,Parent 优先,如果 ImageView 还在显示,那么就由 Parent 先消费,否则就由 Child 消费。
  2. 向下滑动时,Child 优先,如果 Child 还能向下滑动,就由 Child 消费,否则由 Parent 消费。

代码如下:

	/**
     * @param target 就是 Child 那边初始化 NestedScrollingChildHelper(view) 时传递的那个 view,
     *               其实就是 Child,即 NestedScrollChildLayout
     */
	@Override
    public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
        // 获取图片的 ImageView
        View view = getChildAt(0);
        // 向上滑但是 ImageView 仍然可见
        boolean hideTop = dy > 0 && getScrollY() < view.getHeight();
        // 向下滑但 Parent 尚未到顶部,并且 Child 不能再向下滑动了
        boolean showTop = dy < 0 && getScrollY() > 0 && !target.canScrollVertically(1);
        if (showTop || hideTop) {
        	// 可以消费的最大距离,就是 ImageView 的高度减去已经滑动了的距离
            int scrollDy = view.getHeight() - getScrollY();
            // 只需消费本 Layout 中可以滑动的部分,不要过度消费
            int consumedDy = Math.min(dy, scrollDy);
            scrollBy(0, consumedDy);
            consumed[1] = consumedDy;
        }

        Log.i(TAG, "onNestedPreScroll--getScrollY():" + getScrollY() + ",dx:" + dx + ",dy:" + dy + ",consumed:" + consumed[1]);
    }

参数 target 就是 NestedScrollChildLayout,计算 showTop 时判断 Child 不能下滑的条件时正好可以用 !target.canScrollVertically(1) 表示。再就是消费的时候不要过度消费,先计算出能够消费的最大距离 scrollDy,如果参数 dy 大于 scrollDy,那么就只能消费 scrollDy 并记录到 consumed[1] 中。

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值