解决多层嵌套滑动冲突

作者: lmz14 | 来源:发表于2018-08-19 20:52 被阅读144次

      CoordinatorLayout作为顶层布局与NestedScrollView配合使用,可以用来协调子View的嵌套滑动。但是,如果要在CoordinatorLayout的外层嵌套XRefreshView下拉刷新控件,并且NestedScrollView嵌套多种可滑动的控件,这时候就出现了滑动冲突,具体嵌套结构如下所示:


    1534665037068.jpg

    XML布局结构大致如下:

    <XRefreshView>
      <MyCoordinatorLayout>
        <AppBarLayout>
          <CollapsingToolbarLayout>
            ......
          </CollapsingToolbarLayout>
        </AppBarLayout>
      </MyCoordinatorLayout>
    
    <NestedScrollView>
      <VerticalLinearLayout>
        <LinearLayout/>
        <SmartTabLayout/>
        <ViewPager/>
      </VerticalLinearLayout>
    </NestedScrollView>
    </XRefreshView>
    
    <!--XRefreshView开源下拉刷新控件-->
    <!--SmartTabLayout开源分类轴控件-->
    <!--MyCoordinatorLayout:自定义控件,继承CoordinatorLayout-->
    <!--VerticalLinearLayout:自定义控件,继承LinearLayout-->
    
    <!--ViewPager下每个Fragment的布局是RecyclerView-->
    <RecyclerView/>
    

      对于这些滑动冲突,我们该如何解决呢?下面我们就来逐一分析,解决这些滑动冲突。

      1、XRefreshView嵌套CoordinatorLayout

      我们知道事件分发是从上往下传递的(从Activity->Window->DecorView->......View),所以我们是不是可以在需要下拉刷新的时候,将事件拦截,交给下拉刷新控件处理,其他时候都不拦截事件。那么我们要选择用内部拦截法还是外部拦截法来处理滑动冲突呢?

      外部拦截法,那我们就需要在XRefreshView的onInterceptTouchEvent进行处理。XRefreshView是一个开源控件,从源码中,我们可以看到XRefreshView并未覆写onInterceptTouchEvent,而是覆写了dispatchTouchEvent方法,并在方法中进行了一系列复杂的事件分发。若使用外部拦截法来处理,就需要理清XRefreshView原有的事件分发规则,根据我们的实际需求对源码进行修改,对于父容器需要的事件进行拦截。

      内部拦截法,自定义CoordinatorLayout,覆写dispatchTouchEvent方法,通过调用requestDisallowInterceptTouchEvent方法来干扰父容器对事件的拦截。从XRefreshView的源码中我们发现该控件提供了一个disallowInterceptTouchEvent方法,从方法注释可知,子View需要事件的时候,可设置为true,不允许父容器拦截触摸事件。

    XRefreshView.java

     /**
        //XRefreshView.java
    
         * if child need the touch event,pass true
         */
        public void disallowInterceptTouchEvent(boolean isIntercept) {
            mIsIntercept = isIntercept;
        }
    

      相比两种方法,这里内部拦截法更简单,所以最终选择了内部拦截法。

      触发下拉刷新的时机:垂直向下滑动,appBarLayout完全展开状态,允许XRefreshView拦截事件。

    //MyCoordinatorLayout.java
    private int mLastX,mLastY;
    @Override
        public boolean dispatchTouchEvent(MotionEvent ev) {
            //内部拦截法
            int x = (int) ev.getX();
            int y = (int) ev.getY();
    
            switch (ev.getAction()){
                case MotionEvent.ACTION_DOWN:
                    if(xRefreshView!=null){
                        xRefreshView.disallowInterceptTouchEvent(true);
                    }
                    break;
                case MotionEvent.ACTION_MOVE:
                    int deltaX = x - mLastX;
                    int deltaY = y - mLastY;
                    if(Math.abs(deltaY)>Math.abs(deltaX)){
    //                    if(observed!=null && observed.getAppBarLayoutStatus() == 1 && deltaY>0 && xRefreshView!=null && isTop()){
    //                        //垂直向下滑动,appBarLayout展开状态,列表第一个item可见,将事件交还给父容器XRefreshView
    //                        xRefreshView.disallowInterceptTouchEvent(false);
    //                    }
    
                        // 注意:若此时item位置不是第一个可见时,不能下拉刷新,若不需要item位置第一个可见时就可以下拉刷新,可以把isTop判断去掉,若下所示:
                        if(observed!=null && observed.getAppBarLayoutStatus() == 1 && deltaY>0 && xRefreshView!=null){
                            //垂直向下滑动,appBarLayout展开状态,将事件交还给父容器XRefreshView
                            xRefreshView.disallowInterceptTouchEvent(false);
                        }else{
                            //判断触摸点是否落在banner上
                            bannerView = getBannerView();
                            if(bannerView!=null){
                                isTouchPointInBannerView = Util.calcViewScreenLocation(bannerView).contains(ev.getRawX(),ev.getRawY());
                            }else{
                                isTouchPointInBannerView = false;
                            }
                        }
                    }
                    break;
                case MotionEvent.ACTION_UP:
                    isTouchPointInBannerView = false;
                    break;
                default:
                    break;
            }
            mLastX = x;
            mLastY = y;
            return super.dispatchTouchEvent(ev);
        }
    
    

      2、NestedScrollView嵌套多种布局控件(LinearLayout、SmartTabLayout、ViewPager,ViewPager中又嵌套了RecyclerView)

      这里主要解决的不是滑动冲突,而是NestedScrollView嵌套ViewPager无法显示的问题。网上的解决方案有2种,一是重写ViewPager的onMeasure方法,遍历每个页面,获取最高的页面高度来设置ViewPager的高度,二是给NestedScrollView设置android:fillViewport="true"允许 NestedScrollView中的组件去充满它。对于第一种方案,存在一个问题,每个页面的内容高度都一样,并且滑动其中一个页面的列表时,其他页面的列表也会滑动,所以这里采用了方案二。

      在SmartTablayout上方我们设置了一个LinearLayout,这个LinearLayout可以用来作为广告布局的一个父容器。当滑动NestedScrollView时,这个LinearLayout需要可以往屏幕外滑出,直到smartTabLayout保持在置顶位置。那要怎么让LinerLayout可以滑出屏幕直至不可见呢?答案是增大可滑动的空间,在原来内容高度的基础上增加广告布局父容器的高度。我们知道,NestedScrollView不能直接嵌套多个布局,只能有一个直接子类(允许直接嵌套的子类有RecyclerVIew、ViewPager、LinearLayout),这里选择在线性布局里嵌套多个布局,并且自定义这个直接子类,重新去计算它的高度(在系统计算的高度上,加上允许滑出屏幕的高度),具体如下所示:

    xml布局:

    ......
            <android.support.v4.widget.NestedScrollView
                    android:id="@+id/nestedScrollView"
                    android:layout_width="match_parent"
                    android:layout_height="match_parent"
                    app:layout_behavior="@string/appbar_scrolling_view_behaviorr"
                    android:fillViewport="true">
                    <com.lmz.viewdemo.view.VerticalLinearLayout
                        android:id="@+id/NestedVerLinearLayout"
                        android:layout_width="match_parent"
                        android:layout_height="match_parent"
                        android:orientation="vertical"
                        android:descendantFocusability="blocksDescendants">
                        <LinearLayout
                            android:id="@+id/linBanner"
                            android:layout_width="match_parent"
                            android:layout_height="150dp"
                            android:orientation="vertical"
                            android:background="@drawable/banner"/>
                        <com.ogaclejapan.smarttablayout.SmartTabLayout
                            android:id="@+id/smartTabLayout"
                            android:layout_width="match_parent"
                            android:layout_height="40dp"
                            android:layout_toLeftOf="@+id/ivCategoryBtn"
                            android:background="#d8d8d8"
                            android:overScrollMode="never"
                            app:stl_defaultTabTextHorizontalPadding="24dp"
                            app:stl_dividerColor="@android:color/transparent"
                            app:stl_dividerThickness="0dp"
                            app:stl_indicatorColor="#ff3444"
                            app:stl_indicatorInterpolation="linear"
                            app:stl_indicatorThickness="4dp"
                            app:stl_titleOffset="auto_center"
                            app:stl_underlineColor="@android:color/transparent"
                            app:stl_underlineThickness="0dp"/>
                        <android.support.v4.view.ViewPager
                            android:id="@+id/viewpager"
                            android:layout_width="match_parent"
                            android:layout_height="match_parent"/>
                    </com.lmz.viewdemo.view.VerticalLinearLayout>
                </android.support.v4.widget.NestedScrollView>
    ......
    

    VerticalLinearLayout.java

    public class VerticalLinearLayout extends LinearLayout{
    
        private int maxOffsetY;
    
        public VerticalLinearLayout(Context context) {
            super(context);
            init();
        }
    
        public VerticalLinearLayout(Context context, @Nullable AttributeSet attrs) {
            super(context, attrs);
            init();
        }
    
        public VerticalLinearLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
            init();
        }
    
        private void init(){
    
        }
    
        @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            int specSize = MeasureSpec.getSize(heightMeasureSpec);
            View child;
            int childCount = getChildCount();
            int offset = 0;
            for(int i=0;i<childCount;i++){
                child = getChildAt(i);
                if(child!=null && child.getVisibility()!=View.GONE
                        && !(child instanceof ViewPager)  && !(child instanceof SmartTabLayout)){
                    measureChildWithMargins(child,widthMeasureSpec,0,MeasureSpec.UNSPECIFIED,0);
                    offset = offset + child.getMeasuredHeight();
                }
            }
            this.maxOffsetY = offset;//可滑出屏幕的最大距离
            super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(specSize + offset,MeasureSpec.EXACTLY));
        }
    
        public int getMaxOffsetY(){
            return maxOffsetY;
        }
    
    }
    

      3、CoordnatorLayout嵌套NestedScrollView(NestedScrollView内嵌套多种控件)

      在进入正题之前,我们先来简单回顾下NestedScrolling滑动机制。关键接口NestedScrollingParent和NestedScrollingChild,以及他们所对应的Helper(NestedScrollingParentHelper、NestedScrollingChildHelper)。

      嵌套滑动首先由子view触发调用startNestedScroll方法,寻找能够配合子View嵌套滑动parent。在子View处理滑动事件之前调用dispatchNestedPreScroll,询问parent是否需要在子View之前处理滑动,通过回调onNestedPreScroll方法告知parent当前滑动的距离,若父类有消耗滑动距离,可通过onNestedPreScroll方法的consumed这个输出参数来告知子View。子View处理完滑动事件后调用dispatchNestedScroll方法通过回调onNestedScroll告知parent,关于子view消费的部分和子view没有消费的部分,parent可对未消费部分进行处理。

    CoordnatorLayout实现了NestedScrollingParent
    NestedScrollView实现了NestedScrollingParent和NestedScrollingChild
    RecyclerView实现了NestedScrollingChild

    以下是嵌套滑动child和parent对应关系

    1534679979957.jpg

      简单的回顾了NestedScrolling滑动机制,现在开始进入正题。

      当我们的触摸点在RecyclerView或在分类轴上方广告父容器时,配合他们滑动的parent是谁呢?我们可以自定义CoordinatorLayout和NestedScrollView,覆写 public void onNestedPreScroll(View target, int dx, int dy, int[] consumed, int type) 方法,打印target参数,发现只有CoordinatorLayout的onNestedPreScroll有打印日志,并且target的输出是RecyclerView或NestedScrollView,所以配合RecyclerView和NestedScrollView嵌套滑动的parent是CoordinatorLayout,除此之外,还发现调用super.onNestedPreScroll(target, dx, dy, consumed, type)方法,在AppbarLayout折叠或下滑时consumed[1]=0。也就是AppBarLayout折叠或下滑的时候,CoordinatorLayout告知子VIew,父View在Y轴距离上没有消耗,这就是滑动冲突的原因。

      比如,appbarLayout折叠时,滑动触摸点在RecycleView上,进行上滑操作,只能滑动列表,分类轴上方广告容器滑动不了,这是由于parent告知RecyclerView它在y轴上消耗0,将所有y轴距离都交给了RecycleView来消耗。

      既然知道了配合滑动的parent是CoordinatorLayout以及滑动冲突原因,那么就可以在onNestedPreScroll方法中按业务制定滑动规则,来分配dy的消耗。这里的处理规则可以看以下代码中的注释。

      注:分类轴上方广告容器(代码中都称banner)。

    //MyCoordinatorLayout.java
    @Override
        public void onNestedPreScroll(View target, int dx, int dy, int[] consumed, int type) {
            Log.e(TAG,"target:"+target);
    //        Log.e(TAG,"super before dy:"+dy);
            super.onNestedPreScroll(target, dx, dy, consumed, type);//必须放在前面调用,后面对父容器消耗的dy进行处理,来解决与子元素的滑动冲突
    //        Log.e(TAG,"super after dy:"+dy+",dx:"+dx+",consumed[1]:"+consumed[1]);
            if(consumed[1] == 0 && !isTouchPointInBannerView){
                //AppbarLayout折叠或下滑时,consumed[1]=0,并且触摸点不在Banner上
                nsvMaxOffsetY = getNestedScrollViewMaxOffset();
                if(nsvMaxOffsetY>0 && nestedScrollView !=null){
                    //NestedScrollView存在最大滑出屏幕的偏移量时,需要对dy消耗进行处理
                    if(dy>0){
                        //上滑
                        if(nsvMaxOffsetY == nestedScrollView.getScrollY()){
                            //banner隐藏时,不消耗dy,交给列表,列表滑动dy
                            consumed[1] = 0;
                        }else{
                            //banner可见
                            //触摸点在RecyclerView上时,设置父容器消耗dy,不让列表滑动,同时滚动NestedScrollView
                            consumed[1] = dy;
                            nestedScrollViewScrollBy(0,dy);
                        }
    
                    }else{
                        //下滑
                        if(nestedScrollView.getScrollY() == nsvMaxOffsetY){
                            //banner隐藏
                            if(isTop()){
                                //列表第一个item可见,设置父容器消耗dy,不让列表滑动,同时滚动NestedScrollView
                                consumed[1] = dy;
                                nestedScrollViewScrollBy(0,dy);
                            }else{
                                //列表第一个item不可见,父容器不消耗dy,交给RecyclerView消耗dy
                                consumed[1] = 0;
                            }
                        }else if(nestedScrollView.getScrollY()>0){
                            //banner可见未完全展开
                            //触摸点在RecyclerView上时,设置父容器消耗dy,不让列表滑动,同时滚动NestedScrollView
                            consumed[1] = dy;
                            nestedScrollViewScrollBy(0,dy);
                        }
                    }
                }
            }
        }
    

    那么多层嵌套滑动冲突的解决到这里就结束了。源码地址:https://github.com/lmz14/NestedScrollDemo

    相关文章

      网友评论

      本文标题:解决多层嵌套滑动冲突

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