Android嵌套滑动机制实战演练

作者: Android开发架构 | 来源:发表于2019-05-28 16:17 被阅读20次

    前言

    最近产品提了个需求,要把商品列表做成类似淘宝的样式

    淘宝

    一般遇到这种需求,我们首先会想到的是,拦截TouchEvent,然后自己来处理滑动,这种方法虽然行得通,但是代码写起来非常恶心,且滑动冲突会比较多,使用NestedScrolling API会简单优雅很多。

    先上效果图

    Touch嵌套 fling嵌套

    API分析

    https://developer.android.com/reference/android/support/v4/view/NestedScrollingParent

    Parent接口共有以下几个方法

    public interface NestedScrollingParent {
        //当子View开始滑动时,会触发这个方法,判断接下来是否进行嵌套滑动,
        //返回false,则表示不使用嵌套滑动
        boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes);
    
        //onStartNestedScroll如果返回true,那么接下来就会调用这个方法,用来做一些初始化操作,一般可以忽略
        void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes);
    
        //嵌套滑动结束时会触发这个方法
        void onStopNestedScroll(@NonNull View target);
    
        //子View滑动时会触发这个方法,dyConsumed代表子View滑动的距离,dyUnconsumed代表子View本次滑动未消耗的距离,比如RecyclerView滑到了边界,那么会有一部分y未消耗掉
        void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
                int dxUnconsumed, int dyUnconsumed);
    
        //子View开始滑动时,会触发这个回调,dy表示滑动的y距离,consumed数组代表父View要消耗的距离,假如consumed[1] = dy,那么子View就不会滑动了
        void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed);
    
        //当子View fling时,会触发这个回调,consumed代表速度是否被子View消耗掉,比如RecyclerView滑动到了边界,那么它显然没法消耗本次的fling
        boolean onNestedFling(@NonNull View target, float velocityX, float velocityY, boolean consumed);
    
        //当子View要开始fling时,会先询问父View是否要拦截本次fling,返回true表示要拦截,那么子View就不会惯性滑动了
        boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY);
    
        //表示目前正在进行的嵌套滑动的方向,值有ViewCompat.SCROLL_AXIS_HORIZONTAL 或者ViewCompat.SCROLL_AXIS_VERTICAL或者SCROLL_AXIS_NONE
        @ScrollAxis
        int getNestedScrollAxes();
    }
    

    https://developer.android.com/reference/android/support/v4/view/NestedScrollingChild

    public interface NestedScrollingChild {
        //设置当前子View是否支持嵌套滑动
        void setNestedScrollingEnabled(boolean enabled);
    
        //当前子View是否支持嵌套滑动
        boolean isNestedScrollingEnabled();
    
        //开始嵌套滑动,对应Parent的onStartNestedScroll
        boolean startNestedScroll(@ScrollAxis int axes);
    
        //停止本次嵌套滑动,对应Parent的onStopNestedScroll
        void stopNestedScroll();
    
        //true表示这个子View有一个支持嵌套滑动的父View
        boolean hasNestedScrollingParent();
    
        //通知父View子View开始滑动了,对应父View的onNestedScroll方法
        boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
                int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);
    
        //通知父View即将开始滑动了,对应父View的onNestedPreScroll方法
        boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
                @Nullable int[] offsetInWindow);
    
        //通知父View开始Fling了,对应Parent的onNestedFling方法
        boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);
    
        //通知父View要开始fling了,对应Parent的onNestedPreFling方法
        boolean dispatchNestedPreFling(float velocityX, float velocityY);
    }
    

    整体流程描述如下(以RecyclerView为例):

    child.ACTION_DOWN
    -> child.startNestedScroll
    -> parent.onStartNestedScroll (如果返回false,则流程终止)
    -> parent.onNestedScrollAccepted
    -> child.ACTION_MOVE
    -> child.dispatchNestedPreScroll
    -> parent.onNestedPreScroll
    -> child.ACTION_UP
    -> chid.stopNestedScroll
    -> parent.onStopNestedScroll
    -> child.fling
    -> child.dispatchNestedPreFling
    -> parent.onNestedPreScroll
    -> child.dispatchNestedFling
    -> parent.onNestedFling

    有兴趣的朋友可以直接查看 RecyclerView 的源码

    子View向上传递事件时,是循环向上的,即 Parent 不需要是 Child 的直接 ViewParent,具体可以看代码,以startNestedScroll为例

       public boolean startNestedScroll(int axes) {
            if (hasNestedScrollingParent()) {
                // Already in progress
                return true;
            }
            if (isNestedScrollingEnabled()) {
                ViewParent p = getParent();
                View child = this;
                while (p != null) {
                    try {
                        if (p.onStartNestedScroll(child, this, axes)) {
                            mNestedScrollingParent = p;
                            p.onNestedScrollAccepted(child, this, axes);
                            return true;
                        }
                    } catch (AbstractMethodError e) {
                        Log.e(VIEW_LOG_TAG, "ViewParent " + p + " does not implement interface " +
                                "method onStartNestedScroll", e);
                        // Allow the search upward to continue
                    }
                    if (p instanceof View) {
                        child = (View) p;
                    }
                    p = p.getParent();
                }
            }
            return false;
        }
    

    具体实现

    页面结构

    页面结构

    事件拦截

    RV 嵌套 RV 时,内层 RV 是无法滑动的,然而,当外层RV在Fling时,如果我们触摸到子RV,那么会有一定概率导致子RV接收到Touch事件并开始滚动,所以我们需要同时拦截内层和外层的RV的事件。大概思路如下:

    • 当向下滑动时,判断TabLayout是否置顶,如果未置顶,则滑动外层RV;如果TabLayout已经置顶,则滑动子RV

    • 当向上滑动时,判断TabLayout是否置顶,如果未置顶,则滑动外层RV;如果TabLayout已经置顶,则判断子RV能否向上滑动,如果可以,则滑动子RV,否则滑动外层RV

    具体处理为,我们在外层RV之上嵌套一层自定义的FrameLayout,并开启外层RV和内层RV的嵌套滑动功能,那么我们就能在FrameLayout中接收到RV传递上来的scroll和fling事件

    滚动处理

    public class NestedScrollLayout extends FrameLayout {
        private View mChildView;
        /**
         * 最外层的RecyclerView
         */
        private RecyclerView mRootList;
        /**
         * 子RecyclerView
         */
        private RecyclerView mChildList;
        @Override
        public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int nestedScrollAxes) {
            //这里表示只有在纵向滑动时,我们才拦截事件
            return nestedScrollAxes == ViewCompat.SCROLL_AXIS_VERTICAL;
        }
        @Override
        public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed) {
            stopScroller();
            //mChildView表示TabLayout和ViewPager的父View,比如说我们用一个LinearLayout包裹住TabLayout和ViewPager
            if (mChildView == null) {
                return;
            }
            if (target == mRootList) {
                onParentScrolling(mChildView.getTop(), dy, consumed);
            } else {
                onChildScrolling(mChildView.getTop(), dy, consumed);
            }
        }
        /**
         * 父列表在滑动
         *
         * @param childTop
         * @param dy
         * @param consumed
         */
        private void onParentScrolling(int childTop, int dy, int[] consumed) {
            //列表已经置顶
            if (childTop == 0) {
                if (dy > 0 && mChildList != null) {
                    //还在向下滑动,此时滑动子列表
                    mChildList.scrollBy(0, dy);
                    consumed[1] = dy;
                } else {
                    if (mChildList != null && mChildList.canScrollVertically(dy)) {
                        consumed[1] = dy;
                        mChildList.scrollBy(0, dy);
                    }
                }
            } else {
                if (childTop < dy) {
                    consumed[1] = dy - childTop;
                }
            }
        }
    
        private void onChildScrolling(int childTop, int dy, int[] consumed) {
            if (childTop == 0) {
                if (dy < 0) {
                    //向上滑动
                    if (!mChildList.canScrollVertically(dy)) {
                        consumed[1] = dy;
                        mRootList.scrollBy(0, dy);
                    }
                }
            } else {
                if (dy < 0 || childTop > dy) {
                    consumed[1] = dy;
                    mRootList.scrollBy(0, dy);
                } else {
                    //dy大于0
                    consumed[1] = dy;
                    mRootList.scrollBy(0, childTop);
                }
            }
        }
        /**
         * 表示我们只接收纵向的事件
         * @return
         */
        @Override
        public int getNestedScrollAxes() {
            return ViewCompat.SCROLL_AXIS_VERTICAL;
        }
    }
    

    ViewGroup默认实现了Parent接口,这里我们不需要再implement一次

    Fling处理

    当列表开始 Fling 时,我们将会接收到相应的回调,这里我们需要自己处理惯性滑动,使用 OverScroller 来替我们模拟Fling

    public class NestedScrollLayout extends FrameLayout {
        /**
         * 用来处理Fling
         */
        private OverScroller mScroller;
    
        private int mLastY;
    
        @Override
        public boolean onNestedFling(@NonNull View target, float velocityX, float velocityY, boolean consumed) {
            return false;
        }
    
        @Override
        public boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY) {
            mLastY = 0;
            this.mScroller.fling(0, 0, (int) velocityX, (int) velocityY, Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE);
            invalidate();
            return true;
        }
        @Override
        public void computeScroll() {
            if (mScroller.computeScrollOffset()) {
                int currY = mScroller.getCurrY();
                int dy = currY - mLastY;
                mLastY = currY;
                if (dy != 0) {
                    onFling(dy);
                }
                invalidate();
            }
            super.computeScroll();
        }
        private void onFling(int dy) {
            if (mChildView != null) {
                //子列表有显示
                int top = mChildView.getTop();
                if (top == 0) {
                    if (dy > 0) {
                        if (mChildList != null &amp;&amp; mChildList.canScrollVertically(dy)) {
                            mChildList.scrollBy(0, dy);
                        } else {
                            stopScroller();
                        }
                    } else {
                        if (mChildList != null &amp;&amp; mChildList.canScrollVertically(dy)) {
                            mChildList.scrollBy(0, dy);
                        } else {
                            mRootList.scrollBy(0, dy);
                        }
                    }
                } else {
                    if (dy > 0) {
                        if (top > dy) {
                            mRootList.scrollBy(0, dy);
                        } else {
                            mRootList.scrollBy(0, top);
                        }
                    } else {
                        if (mRootList.canScrollVertically(dy)) {
                            mRootList.scrollBy(0, dy);
                        } else {
                            stopScroller();
                        }
                    }
                }
            } else {
                if (!mRootList.canScrollVertically(dy)) {
                    stopScroller();
                } else {
                    mRootList.scrollBy(0, dy);
                }
            }
        }
    }
    

    到这里为止,我们要的效果已经实现了,mChildView 和子RV何时赋值,参考Demo即可。

    新版API

    你以为这样就完了?

    谷歌在 26.1.0 的 support 包中加入了两个新的 API

    这两个接口各自继承了NestedScrollingParent和NestedScrollingChild

    public interface NestedScrollingParent2 extends NestedScrollingParent {
    
        boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes,
                @NestedScrollType int type);
    
        void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes,
                @NestedScrollType int type);
    
        void onStopNestedScroll(@NonNull View target, @NestedScrollType int type);
    
        void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
                int dxUnconsumed, int dyUnconsumed, @NestedScrollType int type);
    
    
        void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed,
                @NestedScrollType int type);
    
    }
    
    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);
    }
    

    在新的API中去掉了 fling 回调,并且增加了 type 参数,type分为两种

        //表示当前事件是由用户手指触摸产生的
        public static final int TYPE_TOUCH = 0;
    
        //表示当前事件不是用户手指触摸产生的,一般是fling
        public static final int TYPE_NON_TOUCH = 1;
    

    Parent2具体流程如下:

    child.ACTION_DOWN
    -> child.startNestedScroll (TYPE_TOUCH)
    -> parent.onStartNestedScroll (TYPE_TOUCH) (如果返回false,则流程终止)
    -> parent.onNestedScrollAccepted (TYPE_TOUCH)
    -> child.ACTION_MOVE
    -> child.dispatchNestedPreScroll (TYPE_TOUCH)
    -> parent.onNestedPreScroll (TYPE_TOUCH)
    -> child.ACTION_UP
    -> chid.stopNestedScroll (TYPE_TOUCH)
    -> parent.onStopNestedScroll (TYPE_TOUCH)
    -> child.fling
    -> child.startNestedScroll (TYPE_NON_TOUCH)
    -> parent.onStartNestedScroll (TYPE_NON_TOUCH) (如果返回false,则流程终止)
    -> parent.onNestedScrollAccepted (TYPE_NON_TOUCH)
    -> child.dispatchNestedPreScroll (TYPE_NON_TOUCH)
    -> parent.onNestedPreScroll (TYPE_NON_TOUCH)
    -> child.dispatchNestedScroll (TYPE_NON_TOUCH)
    -> parent.onNestedScroll (TYPE_NON_TOUCH)
    -> child.stopNestedScroll (TYPE_NON_TOUCH)
    -> parent.onStopNestedScroll (TYPE_NON_TOUCH)

    如上所示,当 RV 开始 Fling 时,每一帧 Fling 的距离,都会通知到 Parent2,由 Parent2 判断是否拦截处理,那么我们就不需要自己使用 OverScroller 来模拟惯性滑动了,代码可以更少。具体实现如下:

    public class NestedScrollLayout2 extends FrameLayout implements NestedScrollingParent2 {
    
        private View mChildView;
        /**
         * 最外层的RecyclerView
         */
        private RecyclerView mRootList;
        /**
         * 子RecyclerView
         */
        private RecyclerView mChildList;
    
        private NestedViewModel mScrollViewModel;
    
        private int mAxes;
    
        public NestedScrollLayout2(@NonNull Context context) {
            super(context);
        }
    
        public NestedScrollLayout2(@NonNull Context context, @Nullable AttributeSet attrs) {
            super(context, attrs);
        }
    
        public void setTarget(LifecycleOwner target) {
            if (target instanceof FragmentActivity) {
                mScrollViewModel = ViewModelProviders.of((FragmentActivity) target).get(NestedViewModel.class);
            } else if (target instanceof Fragment) {
                mScrollViewModel = ViewModelProviders.of((Fragment) target).get(NestedViewModel.class);
            } else {
                throw new IllegalArgumentException("target must be FragmentActivity or Fragment");
            }
            mScrollViewModel.getChildView().observe(target, new Observer<View>() {
                @Override
                public void onChanged(@Nullable View view) {
                    mChildView = view;
                }
            });
            mScrollViewModel.getChildList().observe(target, new Observer<View>() {
                @Override
                public void onChanged(@Nullable View view) {
                    mChildList = (RecyclerView) view;
                }
            });
        }
    
        public void setRootList(RecyclerView recyclerView) {
            mRootList = recyclerView;
        }
    
        @Override
        public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes, int type) {
            return axes == ViewCompat.SCROLL_AXIS_VERTICAL;
        }
    
        @Override
        public void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes, int type) {
            mAxes = axes;
        }
    
        @Override
        public void onStopNestedScroll(@NonNull View target, int type) {
            mAxes = SCROLL_AXIS_NONE;
        }
    
        @Override
        public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) {
    
        }
    
        @Override
        public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
            if (mChildView == null) {
                return;
            }
            if (target == mRootList) {
                onParentScrolling(mChildView.getTop(), dy, consumed);
            } else {
                onChildScrolling(mChildView.getTop(), dy, consumed);
            }
        }
    
        /**
         * 父列表在滑动
         *
         * @param childTop
         * @param dy
         * @param consumed
         */
        private void onParentScrolling(int childTop, int dy, int[] consumed) {
            //列表已经置顶
            if (childTop == 0) {
                if (dy > 0 &amp;&amp; mChildList != null) {
                    //还在向下滑动,此时滑动子列表
                    mChildList.scrollBy(0, dy);
                    consumed[1] = dy;
                } else {
                    if (mChildList != null &amp;&amp; mChildList.canScrollVertically(dy)) {
                        consumed[1] = dy;
                        mChildList.scrollBy(0, dy);
                    }
                }
            } else {
                if (childTop < dy) {
                    consumed[1] = dy - childTop;
                }
            }
        }
    
        private void onChildScrolling(int childTop, int dy, int[] consumed) {
            if (childTop == 0) {
                if (dy < 0) {
                    //向上滑动
                    if (!mChildList.canScrollVertically(dy)) {
                        consumed[1] = dy;
                        mRootList.scrollBy(0, dy);
                    }
                }
            } else {
                if (dy < 0 || childTop > dy) {
                    consumed[1] = dy;
                    mRootList.scrollBy(0, dy);
                } else {
                    //dy大于0
                    consumed[1] = dy;
                    mRootList.scrollBy(0, childTop);
                }
            }
        }
    
        @Override
        public int getNestedScrollAxes() {
            return mAxes;
        }
    
    }
    

    有人可能会问,既然有新 API,为啥还要用 OverScroller。

    因为,我们项目工程里的 RV 版本较低,没有实现 NestedScrollingChild2,而新版本的 RV 已经实现了Child2,所以,大家有空一定要多升级 Support,真的好用。

    最后献上Demo地址,欢迎大家参考。

    Demo地址:
    https://github.com/xue5455/NestedScrollingDemo

    更多资料分享欢迎Android工程师朋友们加入安卓开发技术进阶互助:856328774免费提供安卓开发架构的资料(包括Fultter、高级UI、性能优化、架构师课程、 NDK、Kotlin、混合式开发(ReactNative+Weex)和一线互联网公司关于Android面试的题目汇总。

    相关文章

      网友评论

        本文标题:Android嵌套滑动机制实战演练

        本文链接:https://www.haomeiwen.com/subject/gqpdtctx.html