嵌套滑动的简单应用——仿京东淘宝首页

11 篇文章 0 订阅

今天要实现一个购物 app 首页的嵌套滑动效果,像京东、淘宝、闲鱼的首页都采用了类似的效果,如图:

请添加图片描述

其布局结构大致如下:

请添加图片描述
我们要实现的主要功能有:

  1. 嵌套滑动与惯性滑动时,嵌套滑动的父视图先滑动,然后子视图再滑
  2. 向上滑动时,TabLayout 会固定在屏幕顶部

请添加图片描述
实际上这种效果直接用 CoordinatorLayout 那一套就可以轻松实现,但是抱着了解嵌套滑动机制的目的,我们自己动手来实现一个简单的 Demo。

1、布局结构

详细的布局结构示意图:

请添加图片描述
解释一下部分布局:

  • NestedScrollView:注意 NestedScrollView 与 ScrollView 的区别,ScrollView 是 FrameLayout 的子类,它不具备嵌套滑动的基础条件 —— 实现 NestedScrollingParent 或 NestedScrollingChild 接口族的接口之一;而同样是 FrameLayout 子类的 NestedScrollView 同时实现了 NestedScrollingParent3 与 NestedScrollingChild3 接口,既可以作为嵌套滑动中的“父亲”,也可以作为嵌套滑动中的“孩子”。因此这个位置用不了 ScrollView,而是要用 NestedScrollView
  • HeaderView:理解为包含 Banner 在内的、自己内部不进行上下滑动的 View
  • RecyclerView:RecyclerView 实际上是 ViewPager 每个 Fragment 的根布局,它实现了 NestedScrollingChild2、NestedScrollingChild3 接口,在嵌套滑动中扮演“孩子”的角色

相信你已经从上述解释中看出,嵌套滑动中有两个角色——“孩子”与“父亲”,分别表示嵌套滑动的子视图与父视图。子视图需实现 NestedScrollingChild、NestedScrollingChild2 或 NestedScrollingChild3 接口之一(后者继承前者),父视图需要实现 NestedScrollingParent、NestedScrollingParent2 或 NestedScrollingParent3 接口之一(后者继承前者),这是实现嵌套滑动的先决条件。

如果真的使用了 ScrollView,由于其没有实现 NestedScrollingParent 接口,不会对 RecyclerView 传递过来的嵌套滑动事件进行处理,会导致嵌套滑动完全由 RecyclerView 消费,无法将整个视图向上滑动:

请添加图片描述

而使用 NestedScrollView 能避免以上问题:

请添加图片描述

2、实现 TabLayout 顶置

接下来再想如何让 TabLayout 在滑动到顶部时被顶置,一种实现方案是,自定义一个 NestedScrollView 的子类 NestedScrollLayout,在测量时强行让 TabLayout 与 ViewPager 所在的 LinearLayout 的高度占满屏幕,这样当 TabLayout 滑动到屏幕顶部时,LinearLayout 完全展现出来,再向上滑动时,由于嵌套滑动的父视图 NestedScrollLayout 由于已经滑到底,因此它不再继续消费嵌套滑动事件,而是由 RecyclerView 消费,使得其向上滑动,从而造成 TabLayout “吸顶”的假象。

参考代码:

class NestedScrollLayout(context: Context, attrs: AttributeSet?, defStyleAttr: Int) :
    NestedScrollView(context, attrs, defStyleAttr) {

    private lateinit var mTabAndViewPagerLayout: LinearLayout
    private lateinit var mHeaderView: View

    constructor(context: Context) : this(context, null, 0)
    constructor(context: Context, attributeSet: AttributeSet) : this(context, attributeSet, 0)

    override fun onFinishInflate() {
        super.onFinishInflate()

        // 根据布局文件,目标 LinearLayout 是 NestedScrollLayout 的第 0 个孩子的第 1 个孩子
        // 或者通过 findViewById() 通过 id 直接找这些 View 也可以
        mTabAndViewPagerLayout = (getChildAt(0) as ViewGroup).getChildAt(1) as LinearLayout
        mHeaderView = (getChildAt(0) as ViewGroup).getChildAt(0)
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        // 设置 TabLayout + ViewPager 所在的 LinearLayout 的高度为页面可显示区域高度
        val layoutParams = mTabAndViewPagerLayout.layoutParams
        layoutParams?.let {
            // 由于布局中 SwipeRefreshLayout 和 NestedScrollLayout 的高度都是 match_parent,
            // 所以 getMeasuredHeight() 拿到的就是整个 activity 去掉 ActionBar 后的高度
            layoutParams.height = measuredHeight
            mTabAndViewPagerLayout.layoutParams = layoutParams
        }

        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    }
}

效果图:

请添加图片描述
能看到,虽然吸顶效果实现了,但是由于滑动事件都被 RecyclerView 消费,使得只有在滑动 RecyclerView 以外的部分时,整个 NestedScrollLayout 才会向上滑动。发生问题的原因是,孩子作为嵌套滑动中主动的一方,将滑动事件传递给父亲,但是父亲并没有处理该嵌套滑动事件,而是将其继续再向上层分发。

3、嵌套滑动原理

这一节我们一边梳理嵌套滑动的过程,一边实现我们想要的功能。

上面我们提到,嵌套滑动的父亲没有处理孩子传来的嵌套滑动事件,导致滑动冲突。为了了解嵌套滑动的完整过程,我们先来看时序图:

请添加图片描述
在我们的例子中,NestedScrollLayout 就是上图的 NestedScrollingParent,而 RecyclerView 既是接收事件的 View 也是嵌套滑动的孩子 NestedScrollingChild。

从时序图中也不难看出,嵌套滑动实际上没有改变事件分发的流程,嵌套滑动的子视图在接收到触摸事件时,会在 ACTION_DOWN、ACTION_MOVE 和 ACTION_UP 中分别触发不同的嵌套滑动事件,并且都是优先交给嵌套滑动父视图处理。只有在父视图不处理或没有完全处理的情况下,子视图才进行处理。在这个过程中,时序图没有体现出的两个角色 —— NestedScrollingChildHelper 与 NestedScrollingParentHelper 分别提供了与子视图和父视图同名的方法来实现嵌套滑动的功能。

下面进入源码,结合源码来找出问题的解决方案。

源码版本:
androidx.recyclerview:recyclerview:1.1.0
androidx.core:core:1.7.0 (NestedScrollView)

3.1 初始化过程

首先,RecyclerView 在构造方法中要设置是否开启嵌套滑动:

	// #1 RecyclerView 构造方法,通过 android.R.attr.nestedScrollingEnabled 属性设置嵌套滑动开启
    public RecyclerView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
    	...
        boolean nestedScrollingEnabled = true;
        if (Build.VERSION.SDK_INT >= 21) {
            a = context.obtainStyledAttributes(attrs, NESTED_SCROLLING_ATTRS,
                    defStyleAttr, 0);
            if (Build.VERSION.SDK_INT >= 29) {
                saveAttributeDataForStyleable(
                        context, NESTED_SCROLLING_ATTRS, attrs, a, defStyleAttr, 0);
            }
            nestedScrollingEnabled = a.getBoolean(0, true);
            a.recycle();
        }
        setNestedScrollingEnabled(nestedScrollingEnabled);
    }

setNestedScrollingEnabled() 是 NestedScrollingChild 的接口方法,它通过 NestedScrollingChildHelper 设置是否开启嵌套滑动的标记位:

#RecyclerView:
	@Override
    public void setNestedScrollingEnabled(boolean enabled) {
        getScrollingChildHelper().setNestedScrollingEnabled(enabled);
    }
    
#NestedScrollingChildHelper:
	// #2 设置是否开启嵌套滑动
	public void setNestedScrollingEnabled(boolean enabled) {
        if (mIsNestedScrollingEnabled) {
            ViewCompat.stopNestedScroll(mView);
        }
        mIsNestedScrollingEnabled = enabled;
    }

3.2 ACTION_DOWN

然后,滑动过程开始,当嵌套滑动的子视图 RecyclerView 接收到 ACTION_DOWN 事件时:

    @Override
    public boolean onTouchEvent(MotionEvent e) {
    	...
        switch (action) {
        	...
            case MotionEvent.ACTION_DOWN: {
                mScrollPointerId = e.getPointerId(0);
                mInitialTouchX = mLastTouchX = (int) (e.getX() + 0.5f);
                mInitialTouchY = mLastTouchY = (int) (e.getY() + 0.5f);
                
                // 确定是纵向滑动还是横向滑动
                int nestedScrollAxis = ViewCompat.SCROLL_AXIS_NONE;
                if (canScrollHorizontally) {
                    nestedScrollAxis |= ViewCompat.SCROLL_AXIS_HORIZONTAL;
                }
                if (canScrollVertically) {
                    nestedScrollAxis |= ViewCompat.SCROLL_AXIS_VERTICAL;
                }
                // 交由 NestedScrollingChild 处理,滑动类型为 TYPE_TOUCH,即触摸滚动
                startNestedScroll(nestedScrollAxis, TYPE_TOUCH);
            } break;
        }
    }

RecyclerView 通过 startNestedScroll() 将滑动事件的起始事件 ACTION_DOWN 交给 NestedScrollingChild,实际上它是 NestedScrollingChild2 定义的接口方法之一:

	/**
	* #4 根据给定的 type 沿着 axes 轴开始一个可嵌套的滚动操作,需遵守以下协议:
	* 视图在启动滚动操作时应调用 startNestedScroll()。对于触摸滚动类型,就是在初始的 
	* MotionEvent.ACTION_DOWN 事件中调用该方法。
	* 触摸滚动将以与 ViewParent.requestDisallowInterceptTouchEvent() 相同的方式自动
	* 终止;而程序化滚动必须显式调用 stopNestedScroll() 来指定嵌套滚动的结束。
	* 
	* 如果 startNestedScroll() 返回 true,表示已经找到合作的父视图;否则,调用者可以忽略
	* 接下来的协议,直到下一次滚动。在嵌套滚动正在进行时调用 startNestedScroll() 将返回 true
	* 
	* 在滚动的每个增量步骤中,调用者应在计算出请求的滚动增量后调用 dispatchNestedPreScroll(),
	* 如果该方法返回 true,则表示嵌套滚动的父视图已经部分消耗了该滚动,并且调用者应相应地调整滚动量。
	* 在应用剩余的滚动增量后,调用者应调用 dispatchNestedScroll(),将已消耗和未消耗的滚动增量都传递
	* 给该方法。嵌套滚动的父视图可能会以不同的方式处理这些值,具体参见 NestedScrollingParent2 的
	* onNestedScroll()
	* 
	* 返回值是 true 表示找到合作的父视图并已启用当前手势的嵌套滚动
	*/
	@Override
    public boolean startNestedScroll(int axes, int type) {
        return getScrollingChildHelper().startNestedScroll(axes, type);
    }

startNestedScroll() 会寻找能响应本次嵌套滑动的父视图,并通过 onNestedScrollAccepted() 将嵌套滑动事件交由父视图处理:

	public boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type) {
        // 若已经有了嵌套滑动的父视图直接返回 true
        if (hasNestedScrollingParent(type)) {
            // Already in progress
            return true;
        }
        // 如果开启了嵌套滑动,就递归寻找支持嵌套滑动的父视图,注意可能不是直接父视图
        if (isNestedScrollingEnabled()) {
        	// mView 就是嵌套滑动的子视图,本例中就是 RecyclerView
            ViewParent p = mView.getParent();
            View child = mView;
            while (p != null) {
            	// 寻找可以响应本次滑动的父视图
                if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)) {
                    setNestedScrollingParentForType(type, p);
                    // 如果找到了,就将滑动交由父视图处理
                    ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes, type);
                    return true;
                }
                if (p instanceof View) {
                    child = (View) p;
                }
                p = p.getParent();
            }
        }
        return false;
    }

ViewParentCompat 是 ViewParent 的兼容处理类,最终都会调用 NestedScrollingParent 的对应方法。先是通过 onStartNestedScroll() 寻找能接管嵌套滑动的父视图:

	/**
	* 响应子视图的嵌套滑动操作,条件满足时会接管嵌套滚动操作。
	* 每个父视图都将有机会响应并声明嵌套滚动操作,通过返回 true 实现。
	* ViewParent 的实现可以覆盖此方法,以指示视图何时愿意支持即将开始的嵌套滚动操作。
	* 如果返回 true,则此 ViewParent 将成为目标视图正在进行中的滚动操作的嵌套滚动父级。
	* 嵌套滚动完成时,此 ViewParent 将接收到 onStopNestedScroll(ViewParent, View, int) 
	* 的调用。
	* child 是此 ViewParent 的直接子视图,包含目标视图;target 是启动嵌套滚动的视图
	*/
	public static boolean onStartNestedScroll(ViewParent parent, View child, View target,
            int nestedScrollAxes, int type) {
        // 调用父亲接口的 onStartNestedScroll()
        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;
    }

不论怎样,都是要回调 NestedScrollingParent 的 onStartNestedScroll()。在本例中,只有 NestedScrollLayout 有可能作为嵌套滑动的父视图,由于其未重写该方法,因此会调用其父类 NestedScrollView 的:

	// #5 NestedScrollView 只有在纵向滑动时才会接收嵌套滑动事件
	@Override
    public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes,
            int type) {
        return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
    }

这样看,NestedScrollLayout 可以作为嵌套滑动的父视图。那么接下来,在 startNestedScroll() 中就会再执行 ViewParentCompat.onNestedScrollAccepted():

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

可以看出是类似的处理方式,回调 NestedScrollingParent 的 onNestedScrollAccepted(),还是要看 NestedScrollView:

	/**
	* #6 响应嵌套滑动,在 onStartNestedScroll() 返回 true 之后调用,为视图及其超类提供了执
	* 行嵌套滚动的初始配置的机会。如果父类有实现此方法,则此方法的实现应始终调用其父类的实现
	*/
	@Override
    public void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes,
            int type) {
        mParentHelper.onNestedScrollAccepted(child, target, axes, type);
        startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, type);
    }

onNestedScrollAccepted() 做了两件事:

  1. NestedScrollingParentHelper 在 onNestedScrollAccepted() 中初始化:

    	/**
    	* 当由子视图初始化的嵌套滑动操作被此 ViewGroup 接收时调用此方法
    	*/
    	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;
            }
        }
    
  2. startNestedScroll() 内,自己作为嵌套滑动子视图,将嵌套滑动继续向父视图传递:

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

可以看到 NestedScrollingView 虽然作为嵌套滑动父视图接收了嵌套滑动事件,但是它在 onNestedScrollAccepted() 内做完所需的处理后,又转身作为子视图,通过 startNestedScroll() 将该事件向更高级的嵌套滑动父视图传递,即重复时序图中 4 ~6 步的动作。

3.3 ACTION_MOVE

ACTION_DOWN 事件处理完,下一步看如何处理 ACTION_MOVE。

RecyclerView 处理 ACTION_MOVE:

	@Override
    public boolean onTouchEvent(MotionEvent e) {
    	...
    	switch (action) {
    		...
    		case MotionEvent.ACTION_MOVE: {
                ...

                final int x = (int) (e.getX(index) + 0.5f);
                final int y = (int) (e.getY(index) + 0.5f);
                int dx = mLastTouchX - x;
                int dy = mLastTouchY - y;

				// mScrollState 有三个可选值:SCROLL_STATE_IDLE 表示还未开始
				// 滑动,SCROLL_STATE_DRAGGING 表示 RecyclerView 处于手指拖拽
				// 状态,SCROLL_STATE_SETTLING 表示未受外力作用但还在向终点滑动
				// 的状态,比如惯性滑动。这里是未开始滑动,为滑动做准备工作
                if (mScrollState != SCROLL_STATE_DRAGGING) {
                    final int dx = x - mInitialTouchX;
                    final int dy = y - mInitialTouchY;
                    boolean startScroll = false;
                    if (canScrollHorizontally && Math.abs(dx) > mTouchSlop) {
                        mLastTouchX = x;
                        startScroll = true;
                    }
                    if (canScrollVertically && Math.abs(dy) > mTouchSlop) {
                        mLastTouchY = y;
                        startScroll = true;
                    }
                    if (startScroll) {
                    	// 设置 mScrollState = SCROLL_STATE_DRAGGING
                        setScrollState(SCROLL_STATE_DRAGGING);
                    }
                }

				// 拖拽滑动
                if (mScrollState == SCROLL_STATE_DRAGGING) {
                    mReusableIntPair[0] = 0;
                    mReusableIntPair[1] = 0;
                    if (dispatchNestedPreScroll(
                            canScrollHorizontally ? dx : 0,
                            canScrollVertically ? dy : 0,
                            mReusableIntPair, mScrollOffset, TYPE_TOUCH
                    )) {
                        dx -= mReusableIntPair[0];
                        dy -= mReusableIntPair[1];
                        // Updated the nested offsets
                        mNestedOffsets[0] += mScrollOffset[0];
                        mNestedOffsets[1] += mScrollOffset[1];
                        // Scroll has initiated, prevent parents from intercepting
                        getParent().requestDisallowInterceptTouchEvent(true);
                    }

                    mLastTouchX = x - mScrollOffset[0];
                    mLastTouchY = y - mScrollOffset[1];

                    if (scrollByInternal(
                            canScrollHorizontally ? dx : 0,
                            canScrollVertically ? dy : 0,
                            e)) {
                        getParent().requestDisallowInterceptTouchEvent(true);
                    }
                    if (mGapWorker != null && (dx != 0 || dy != 0)) {
                        mGapWorker.postFromTraversal(this, dx, dy);
                    }
                }
            } break;
    	}
    }

主要看拖拽状态处理,会调用 dispatchNestedPreScroll() 分发嵌套滑动事件:

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

还是借助 NestedScrollingChildHelper 中转给父视图:

	// #7 
	public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
            @Nullable int[] offsetInWindow, @NestedScrollType int type) {
        if (isNestedScrollingEnabled()) {
        	// 没有找到可以处理 type 滑动类型的父视图则直接返回 false
            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];
                }

                if (consumed == null) {
                    consumed = getTempNestedScrollConsumed();
                }
                consumed[0] = 0;
                consumed[1] = 0;
                // 回调嵌套滑动父视图
                ViewParentCompat.onNestedPreScroll(parent, mView, dx, dy, consumed, type);

                if (offsetInWindow != null) {
                    mView.getLocationInWindow(offsetInWindow);
                    offsetInWindow[0] -= startX;
                    offsetInWindow[1] -= startY;
                }
                // 只要父视图消费了(无须完全消费)位移距离就返回 true
                return consumed[0] != 0 || consumed[1] != 0;
            } else if (offsetInWindow != null) {
                offsetInWindow[0] = 0;
                offsetInWindow[1] = 0;
            }
        }
        return false;
    }

仍是 ViewParentCompat 做兼容处理,回调 NestedScrollingParent 的 onNestedPreScroll():

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

回调到 NestedScrollView 的 onNestedPreScroll(),发现并没有进行相应的滑动,而是作为嵌套滑动子视图将嵌套滑动事件向父视图传递,询问父视图是否进行处理:

	@Override
    public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed,
            int type) {
        // 没有进行滑动处理,而是向外分发了
        dispatchNestedPreScroll(dx, dy, consumed, null, type);
    }
    
	@Override
    public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow,
            int type) {
        // 借助 Helper 把滑动事件向上层容器分发,自己没处理滑动
        return mChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type);
    }

其实这就是问题所在,NestedScrollLayout 应该重写 onNestedPreScroll(),在向上滑动并且 Header 部分可见时,消费 Y 轴的滑动距离,而不是将滑动事件向自己的父视图传递:

	override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
        // 向上滑动,并且当前NestedScrollLayout可显示区域的顶部纵坐标小于Header高度,就消费
        if (dy > 0 && scrollY <= headerView.measuredHeight) {
            scrollBy(0, dy)
            consumed[1] = dy
        }
    }

RecyclerView 接收到滑动事件,要先问嵌套滑动的父视图 NestedScrollLayout 是否可以滑动,若父视图可以滑动,则应由父视图消费该滑动事件。只有当父视图无法滑动时,反馈给子视图,然后才由子视图 RecyclerView 进行滑动。

3.4 ACTION_UP

ACTION_UP 这部分我们主要看惯性滑动相关的代码。

惯性滑动也是先给嵌套滑动的父视图滑动,父视图滑完子视图才滑。在子视图 RecyclerView 的 onTouchEvent() 内:

	@Override
    public boolean onTouchEvent(MotionEvent e) {
    	...
    	switch (action) {
    		...
    		case MotionEvent.ACTION_UP: {
                mVelocityTracker.addMovement(vtev);
                eventAddedToVelocityTracker = true;
                mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
                final float xvel = canScrollHorizontally
                        ? -mVelocityTracker.getXVelocity(mScrollPointerId) : 0;
                final float yvel = canScrollVertically
                        ? -mVelocityTracker.getYVelocity(mScrollPointerId) : 0;
				// fling() 处理惯性滑动
                if (!((xvel != 0 || yvel != 0) && fling((int) xvel, (int) yvel))) {
                    setScrollState(SCROLL_STATE_IDLE);
                }
                resetScroll();
            } break;
    	}
    }

只要在 x 轴或 y 轴有速度才有机会执行到 fling():

	public boolean fling(int velocityX, int velocityY) {
        ...

		// canScrollHorizontal 与 canScrollVertical 默认实现返回的都是 false,后续详解
        final boolean canScrollHorizontal = mLayout.canScrollHorizontally();
        final boolean canScrollVertical = mLayout.canScrollVertically();

        if (!canScrollHorizontal || Math.abs(velocityX) < mMinFlingVelocity) {
            velocityX = 0;
        }
        if (!canScrollVertical || Math.abs(velocityY) < mMinFlingVelocity) {
            velocityY = 0;
        }
        if (velocityX == 0 && velocityY == 0) {
            // If we don't have any velocity, return false
            return false;
        }

		// 在 RecyclerView 处理惯性滑动之前,先问嵌套滚动的父视图是否处理惯性滑动
        if (!dispatchNestedPreFling(velocityX, velocityY)) {
        	// 如果父视图完全不处理,或者处理之后还有滑动余量,RecyclerView 才处理
            final boolean canScroll = canScrollHorizontal || canScrollVertical;
            dispatchNestedFling(velocityX, velocityY, canScroll);
            ...
        }
        return false;
    }

dispatchNestedPreFling() 在接口中的注释写道:

	/**
	* 在当前这个视图处理之前向嵌套滚动父视图分发一个 fling
	* 嵌套的 pre-fling 事件对于嵌套的 fling,就像嵌套的 pre-scroll 之于 scroll,intercept
	* 之于 touch。dispatchNestedPreFling() 为父视图在子视图消费 fling 之前完全消费掉 fling
	* 提供了机会。如果此方法返回 true,则嵌套父级视图已经消耗了 fling,因此此视图不应滚动。
	* 
	* 为了更好的用户体验,嵌套滚动链中只有一个视图应该消耗 fling。自定义 View 应以两种方式
	* 考虑此问题:
	* 1.如果自定义视图是分页的并且需要安定到固定页面点,请勿调用 dispatchNestedPreFling
	* 2.如果嵌套父级确实消耗了 fling,则此视图根本不应滚动,即使要回到一个有效的空闲位置也不行
	* 
	* 两个参数分别表示水平和垂直两个方向的 fling 速度(每秒像素数),返回值为 true 表示嵌套
	* 滚动的父视图消耗了 fling
	*/
	boolean dispatchNestedPreFling(float velocityX, float velocityY);

RecyclerView 的 fling() 也正是按照以上原则处理的,当 dispatchNestedPreFling() 返回 false 时才调用 dispatchNestedFling(),仍是通过 NestedScrollingChildHelper 分发,回调父视图 NestedScrollingView 的 onNestedFling():

	@Override
    public boolean onNestedFling(
            @NonNull View target, float velocityX, float velocityY, boolean consumed) {
        if (!consumed) {
        	// 这里在向父视图分发时,consumed 传了 true 表示子视图已经消费,这样一般
        	// 情况下父视图就不会再做惯性滑动处理
            dispatchNestedFling(0, velocityY, true);
            // 自己处理惯性滑动
            fling((int) velocityY);
            return true;
        }
        return false;
    }

consumed 参数,是在 RecyclerView 的 fling() 中是由 canScroll 变量决定的:

	public boolean fling(int velocityX, int velocityY) {
        ...

        final boolean canScrollHorizontal = mLayout.canScrollHorizontally();
        final boolean canScrollVertical = mLayout.canScrollVertically();

		...
		// 嵌套滑动父视图没有处理完惯性滑动才需要子视图处理
        if (!dispatchNestedPreFling(velocityX, velocityY)) {
            final boolean canScroll = canScrollHorizontal || canScrollVertical;
            // 子视图处理惯性滑动时,还是会先让父视图做处理
            dispatchNestedFling(velocityX, velocityY, canScroll);
            ...
        }
        return false;
    }

mLayout 是一个 LayoutManager,canScrollHorizontally() 的默认实现都返回 false。RecyclerView 内部设置 mLayout 的只有 setLayoutManager(),而该方法又只被 createLayoutManager() 调用。createLayoutManager() 会根据用户提供的 LayoutManager 的全类名以反射的方式创建出一个 LayoutManager 实例,这个全类名是在 RecyclerView 的构造方法中通过属性解析获取到的:

	public RecyclerView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
		...
		TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.RecyclerView,
                defStyleAttr, 0);
		...
		String layoutManagerName = a.getString(R.styleable.RecyclerView_layoutManager);
		...
		createLayoutManager(context, layoutManagerName, attrs, defStyleAttr, 0);
		...
	}

也就是说,如果你没有在布局文件中为 RecyclerView 配置 layoutManager 这个属性,那么父视图 NestedScrollingView 的 onNestedFling() 接收的 consumed 就是 false,进而执行 if 语句,先 dispatchNestedFling() 将惯性滑动分发给嵌套滑动父视图,再 fling() 自己执行惯性滑动并返回 true。由于 NestedScrollingLayout 的父视图不会处理惯性滑动,因此所有的滑动距离都由其 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);
        }
    }

因此我们无须自己实现惯性滑动,因此系统已经帮我们实现了。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值