最新消息:欢迎访问Android开发中文站!商务联系微信:loading_in

NestedScrolling机制解析,自定义嵌套滑动你也可以

开发进阶 loading 60浏览 0评论

概述

如果在以前要实现嵌套滑动,比如ScrollView嵌套RecyclerView,这时候常用的方法就是重写onMeasure方法,进行重新测量。现在官方提供了两个神奇的接口,帮助我们实现复杂的嵌套以及更加炫酷的效果,那就是NestedScrollingChild2NestedScrollingParent2

我们先来看下要实现的效果:


其实这是一个RecyclerView中一个模版是带有RecyclerView的布局,也就是说 RecyclerView嵌套RecyclerView。

那么如何才能做到像上图的中效果呢,我们来简单分析一下:

假如我们用事件分发的思路去分析,首先当父RecylerView滑动到子RecyclerView时,父RecyclerView不应该再拦截滑动事件,当子RecyclerView从顶部往下滑动时,父RecyclerView要拦截子RecyclerView的滑动事件。但是如何判断分发的时机呢,又要确保如此的流畅。

NestedScrolling机制可以很好的帮我们去实现这样的效果。

原理

我们知道RecyclerView是实现了NestedScrollingChild2接口的,我们来看下它的几个方法:


  /**
     * 开启一个嵌套滑动
     *
     * @param axes 支持的嵌套滑动方法,分为水平方向,竖直方向,或不指定
     * @param type 滑动事件类型
     * @return 如果返回true, 表示当前子控件已经找了一起嵌套滑动的view
     */
    public boolean startNestedScroll(int axes, int type) {}

    /**
     * 在子控件滑动前,将事件分发给父控件,由父控件判断消耗多少
     *
     * @param dx             水平方向嵌套滑动的子控件想要变化的距离 dx<0 向右滑动 dx>0 向左滑动
     * @param dy             垂直方向嵌套滑动的子控件想要变化的距离 dy<0 向下滑动 dy>0 向上滑动
     * @param consumed       子控件传给父控件数组,用于存储父控件水平与竖直方向上消耗的距离,consumed[0] 水平消耗的距离,consumed[1] 垂直消耗的距离
     * @param offsetInWindow 子控件在当前window的偏移量
     * @return 如果返回true, 表示父控件已经消耗了
     */
    public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow, int type) {}

    /**
     * 当父控件消耗事件后,子控件处理后,又继续将事件分发给父控件,由父控件判断是否消耗剩下的距离。
     *
     * @param dxConsumed     水平方向嵌套滑动的子控件滑动的距离(消耗的距离)
     * @param dyConsumed     垂直方向嵌套滑动的子控件滑动的距离(消耗的距离)
     * @param dxUnconsumed   水平方向嵌套滑动的子控件未滑动的距离(未消耗的距离)
     * @param dyUnconsumed   垂直方向嵌套滑动的子控件未滑动的距离(未消耗的距离)
     * @param offsetInWindow 子控件在当前window的偏移量
     * @return 如果返回true, 表示父控件又继续消耗了
     */
    public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow, int type) {}

    /**
     * 子控件停止嵌套滑动
     */
    public void stopNestedScroll(int type) {}

    /**
     * 当子控件产生fling滑动时,判断父控件是否处拦截fling,如果父控件处理了fling,那子控件就没有办法处理fling了。
     *
     * @param velocityX 水平方向上的速度 velocityX > 0  向左滑动,反之向右滑动
     * @param velocityY 竖直方向上的速度 velocityY > 0  向上滑动,反之向下滑动
     * @return 如果返回true, 表示父控件拦截了fling
     */
    public boolean dispatchNestedPreFling(float velocityX, float velocityY) {}

    /**
     * 当父控件不拦截子控件的fling,那么子控件会调用该方法将fling,传给父控件进行处理
     *
     * @param velocityX 水平方向上的速度 velocityX > 0  向左滑动,反之向右滑动
     * @param velocityY 竖直方向上的速度 velocityY > 0  向上滑动,反之向下滑动
     * @param consumed  子控件是否可以消耗该fling,也可以说是子控件是否消耗掉了该fling
     * @return 父控件是否消耗了该fling
     */
    public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {}

    /**
     * 设置当前子控件是否支持嵌套滑动,如果不支持,那么父控件是不能够响应嵌套滑动的
     *
     * @param enabled true 支持
     */
    public void setNestedScrollingEnabled(boolean enabled) {}

    /**
     * 当前子控件是否支持嵌套滑动
     */
    public boolean isNestedScrollingEnabled() {}

    /**
     * 判断当前子控件是否拥有嵌套滑动的父控件
     */
    public boolean hasNestedScrollingParent(int type) {}

我们看源码里会发现其实里面的实现都是通过NestedScrollingChildHelper去实现的。

那么它是怎么调用Child2中的方法以及怎么回调给NestedScrollingParent的各种方法呢?

我们来看下RecyclerView的onTouchEvent方法:

    public boolean onTouchEvent(MotionEvent e) {
    .....
                int nestedScrollAxis;
                switch(action) {
                case MotionEvent.ACTION_DOWN:
                    this.mScrollPointerId = e.getPointerId(0);
                    this.mInitialTouchX = this.mLastTouchX = (int)(e.getX() + 0.5F);
                    this.mInitialTouchY = this.mLastTouchY = (int)(e.getY() + 0.5F);
                    nestedScrollAxis = 0;
                    if (canScrollHorizontally) {
                        nestedScrollAxis |= 1;
                    }

                    if (canScrollVertically) {
                        nestedScrollAxis |= 2;
                    }

                    this.startNestedScroll(nestedScrollAxis, 0);
                    break;
                case MotionEvent.ACTION_UP:
                    .....
                    if (xvel == 0.0F && yvel == 0.0F || !this.fling((int)xvel, (int)yvel)) {
                        this.setScrollState(0);
                    }

                    this.resetTouch();
                    break;
                case MotionEvent.ACTION_MOVE:
                    nestedScrollAxis = e.findPointerIndex(this.mScrollPointerId);
                    if (nestedScrollAxis < 0) {
                        return false;
                    }

                    int x = (int)(e.getX(nestedScrollAxis) + 0.5F);
                    int y = (int)(e.getY(nestedScrollAxis) + 0.5F);
                    int dx = this.mLastTouchX - x;
                    int dy = this.mLastTouchY - y;
                    if (this.dispatchNestedPreScroll(dx, dy, this.mScrollConsumed, this.mScrollOffset, 0)) {
                        dx -= this.mScrollConsumed[0];
                        dy -= this.mScrollConsumed[1];
                        vtev.offsetLocation((float)this.mScrollOffset[0], (float)this.mScrollOffset[1]);
                        int[] var10000 = this.mNestedOffsets;
                        var10000[0] += this.mScrollOffset[0];
                        var10000 = this.mNestedOffsets;
                        var10000[1] += this.mScrollOffset[1];
                    }
                    ...
                    NestedScrollingChildHelperNestedScrollingChildHelperNestedScrollingChildHelperNestedScrollingChildHelperNestedScrollingChildHelper;
                }

                if (!eventAddedToVelocityTracker) {
                    this.mVelocityTracker.addMovement(vtev);
                }

                vtev.recycle();
                return true;
            }
        } else {
            return false;
        }
    }

从上面的代码中可以看出ACTION_DOWN调用了startNestedScroll方法;ACTION_MOVE调用了 dispatchNestedPreScroll;ACTION_UP调用了fling

我们再来看下NestedScrollingChildHelperstartNestedScroll的实现方法:

    public boolean startNestedScroll(int axes, int type) {
        if (this.hasNestedScrollingParent(type)) {
            // Already in progress
            return true;
        } else {
            if (this.isNestedScrollingEnabled()) {
                ViewParent p = this.mView.getParent();

                for(View child = this.mView; p != null; p = p.getParent()) {
                    if (ViewParentCompat.onStartNestedScroll(p, child, this.mView, axes, type)) {
                        this.setNestedScrollingParentForType(type, p);
                        ViewParentCompat.onNestedScrollAccepted(p, child, this.mView, axes, type);
                        return true;
                    }

                    if (p instanceof View) {
                        child = (View)p;
                    }
                }
            }

            return false;
        }
    }

ViewParentCompat中的代码我就不贴了,其实这段代码就是去寻找父类的NestedScrollingParent,如果找到就会回调onStartNestedScroll和onNestedScrollAccepted

我们简单看下NestedScrollingParent2的实现方法:

public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes, int type);

public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes, int type);

public void onNestedScroll(View target, int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, int type);

public void onNestedPreScroll(View target, int dx, int dy, int[] consumed, int type);

public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed);

public boolean onNestedPreFling(View target, float velocityX, float velocityY);

public int getNestedScrollAxes();

同样的,我们也可以在NestedScrollingChildHelper看到其他方法的实现:dispatchNestedPreScroll中会回调onNestedPreScroll方法。

在RecyclerView中的scrollByInternal中调用了dispatchNestedScroll, 在dispatchNestedScroll中会回调onNestedScroll

fling方法中会回调onNestedPreFling和onNestedFling方法。

resetTouch方法中则会回调onStopNestedScroll。

我画了一个简单的示意图来帮助梳理下我上面说到的这些:


实现

看了上面的实现原理,我们来实现下gif图的效果。

首先,实现NestedScrollingParent2接口,创建一个NestedScrollingParentHelper实现类。

    public NestedContainerRecyclerView(
            @NonNull Context context, @Nullable AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        nestedScrollingParentHelper = new NestedScrollingParentHelper(this);
        setNestedScrollingEnabled(false);
        setOverScrollMode(OVER_SCROLL_NEVER);
    }

默认不开启嵌套滑动。

    @Override
    public boolean onStartNestedScroll(
            @NonNull View child, @NonNull View target, int axes, int type) {
        if (axes == ViewCompat.SCROLL_AXIS_HORIZONTAL) {
            return false;
        }
        this.mTouchType = type;
        targetChild = new WeakReference<View>(target);
        itemChild = new WeakReference<View>(getItemChild(child));
        return true;
    }

在开始嵌套滑动的时候,判断一下滑动的方向,如果是水平方向,不进行嵌套滑动。targetChild用于保存当前的可滑动View,itemChild 用于保存可滑动的View当前的布局,看下getItemChild的实现,就懂了。

    private View getItemChild(View target) {
        ViewParent parent = target.getParent();
        if (parent == null) {
            return null;
        }
        if (parent == this) {
            return target;
        } else if (parent instanceof View) {
            return getItemChild((View) parent);
        }
        return null;
    }

接下来就到了我们的重头戏登场的时候了,铛铛铛:

    public void onNestedScroll(
            @NonNull View target,
            int dxConsumed,
            int dyConsumed,
            int dxUnconsumed,
            int dyUnconsumed,
            int type) {
        if (dyUnconsumed == 0) {
            return;
        }
        if (type == ViewCompat.TYPE_TOUCH) {
            if (dyUnconsumed > 0) {
                if ((!target.canScrollVertically(1) || getItemChildY() != itemTargetY)
                        && canScrollVertically(1)) {
                    scrollBy(0, dyUnconsumed);
                }
            } else {
                if ((!target.canScrollVertically(-1) || getItemChildY() != itemTargetY)
                        && canScrollVertically(-1)) {
                    scrollBy(0, dyUnconsumed);
                }
            }
        } else if (type == ViewCompat.TYPE_NON_TOUCH) {
            if (dyUnconsumed > 0) {
                boolean canscroll = target.canScrollVertically(1);
                if ((!canscroll || getItemChildY() != itemTargetY) && canScrollVertically(1)) {
                    scrollBy(0, dyUnconsumed);
                }
            } else {
                boolean canscroll = target.canScrollVertically(-1);
                if ((!canscroll || getItemChildY() != itemTargetY) && canScrollVertically(-1)) {
                    scrollBy(0, dyUnconsumed);
                    if (getItemChildY() > getHeight() + dyUnconsumed) {
                        fling(0, dyUnconsumed * VELUE);
                    }
                }
            }
        }
    }

这里呢,补充一个小知识点,就是判断一个view是否到顶或者到底的方法:

canScrollVertically(1)的值表示是否能向上滚动,false表示已经滚动到底部
canScrollVertically(-1)的值表示是否能向下滚动,false表示已经滚动到顶部

在onNestedScroll时,如果是向上滑动,当前RecyclerView并未滑动到底部并且可滑动View(以后简称targetView)已经滑动到底部或者targe当前所有的View的getY(相对于父控件的y轴距离)不等于itemTargetY时,当前RecyclerView消费点剩余的距离。

itemTargetY是什么呢,我们在布局的时候,targetView所有的布局不一定充满RecyclerView,可能上方会有一定的距离,这个距离就是itemTargetY。

那么getItemChildY里是怎么实现的呢?看下面:

    private int getItemChildY() {
        if (itemChild == null || itemChild.get() == null) {
            return -10000;
        }
        return (int) (itemChild.get().getY() + 0.5f);
    }

同理,在向下滑动的时候,我们通过targetView是否到底以及当前RecyclerView是否到底来判断是否消费剩余的滑动距离。

   @Override
    public void onNestedPreScroll(@NonNull View target, int dx, int dy, int[] consumed, int type) {
        if (dy != 0) {
            if (type == ViewCompat.TYPE_TOUCH && itemChild != null && itemChild.get() != null) {
                int y = getItemChildY();
                if (dy > 0) {
                    if (y != itemTargetY) {
                        if (!canScrollVertically(1)) {
                            scrollBy(0, y - itemTargetY);
                            consumed[1] = y - itemTargetY;
                        } else {
                            scrollBy(0, dy);
                            y -= getItemChildY();
                            consumed[1] = y;
                        }
                    } else if (!target.canScrollVertically(1) && canScrollVertically(1)) {
                        scrollBy(0, dy);
                        y -= getItemChildY();
                        consumed[1] = y;
                    }

                } else {
                    if (target instanceof RecyclerView) {
                        RecyclerView tRecyclerView = (RecyclerView) target;
                        if ((tRecyclerView.computeVerticalScrollOffset() <= 0
                                        || y - itemTargetY > 1)
                                && canScrollVertically(-1)) {
                            scrollBy(0, dy);
                            y -= getItemChildY();
                            consumed[1] = y;
                        }
                    } else {
                        if ((!target.canScrollVertically(-1) || y - itemTargetY > 1)
                                && canScrollVertically(-1)) {
                            scrollBy(0, dy);
                            y -= getItemChildY();
                            consumed[1] = y;
                        }
                    }
                }
            }
            if (type == ViewCompat.TYPE_NON_TOUCH && itemChild != null && itemChild.get() != null) {
                int y = getItemChildY();
                if (dy > 0) {
                    if ((!target.canScrollVertically(1) || y != itemTargetY)
                            && canScrollVertically(1)) {
                        scrollBy(0, dy);
                        y -= getItemChildY();
                        consumed[1] = y;
                    }
                } else {
                    if ((!target.canScrollVertically(-1) || y != itemTargetY)
                            && canScrollVertically(-1)) {
                        scrollBy(0, dy);
                        y -= getItemChildY();
                        consumed[1] = y;
                    }
                }
            }
        }
    }

在onNestedPreScoll中,我们判断,如果是上滑时,判断targeView是否到底以及RecyclerView是否到底来判断是否消费dy,如果targetView到底,RecyclerView并没有到底,则消费dy,如果targetView所在View的getY不等于itemTargetY且RecyclerView到底,则消费dy-itemTargetY。

此外,还处理了onNestedFling以及dispatchNestedScroll,更详细的代码请见github地址。 AndroidHighlights

注意:要准确设置子RecyclerView的高度,也就是targetView的高度,推荐高度为

recyclerView.getHeight - itemTargetY

如有错误或者不同意见,欢迎指出、探讨。感谢阅读到这里的所有人。

 

作者:绫晓路
 链接:https://juejin.im/post/5d8c2aede51d45784d3f85e0

转载请注明:Android开发中文站 » NestedScrolling机制解析,自定义嵌套滑动你也可以

您必须 登录 才能发表评论!