玩转Android嵌套滚动

玩转Android嵌套滚动

在Android UI开发过程中,经常会遇到嵌套滚动的需求,所谓嵌套滚动,就是父view可以滚动的情况下子view也可以滚动,例如下拉刷新(PullToRefresh)。在微信读书之前的版本中,书籍讨论圈有一个比较复杂的嵌套滚动的例子,我把它抽取出来作为今天讲解的例子:  

玩转Android嵌套滚动

这个例子的嵌套比较复杂,上方的header为书籍封面,下方是一个ViewPager+TabLayout组成的容器(下文简称VT容器),ViewPager中的三个item为三个列表,也是可以滚动的。业务需求是:

  1. VT容器可以滚动;
  2. 书籍封面可以滚动,并且有视差;
  3. 当VT容器滚动到顶部时,滚动列表,并且滚动可以衔接。
  4. 当列表滚动到顶部时,可以滚动书籍封面以及VT容器,并且滚动可以衔接

逻辑清楚了,接下来就看如何实现了。在android5以前,对于这种滚动,我们只能选择自己去拦截事件并处理,但在后面的某个版本,android推出了NestingScroll机制,开发者的日子就好过多了,并且android提供了一个非常好的容器类:CoordinatorLayout,极大的简化了开发者的工作。当然我们也需要投入精力去学习并运用这些新的Api了。

当然,我们也要知道如果没有这些API,我们应当如何去实现这些效果。因此本文会用三种方式去实现这个效果:

  1. 纯事件拦截与派发方案
  2. 基于NestingScroll机制的实现方案
  3. 基于CoordinatorLayout与Behavior方案的实现

示例代码放在Github上,可以clone下来结合文章观看

纯事件拦截与派发方案

这是最为原始的方案,当然也灵活性最高的了。其它的方案原理上都是系统基于它提供的封装。使用这种方案时,我们需要解决以下几个问题:

  1. view的滚动(Scroller);
  2. view的速度追踪(VelocityTracker);
  3. 当VT容器滚动到顶部时,我们如何将事件传递给ListView?
  4. 当ListView滚动到顶部时,VT容器如何拦截到事件?

1、2两点属于滚动的基础知识,这里不会做细致的讲解。而第3点为何会出现呢?因为android系统在事件派发时,如果事件被拦截,那么之后的事件都将不会传递给子view了。其解决方案也很简单:在滚动到顶部时主动派发一次Down事件:


  1. if (mTargetCurrentOffset + dy <= mTargetEndOffset) { 
  2.     moveTargetView(dy); 
  3.     // 重新dispatch一次down事件,使得列表可以继续滚动 
  4.     int oldAction = ev.getAction(); 
  5.     ev.setAction(MotionEvent.ACTION_DOWN); 
  6.     dispatchTouchEvent(ev); 
  7.     ev.setAction(oldAction); 
  8. else { 
  9.     moveTargetView(dy); 
  10.  

那么第4点是什么问题呢?这里就需要清楚一个坑点了:不是所用的事件都会走入onInterceptTouchEvent。有一种情况是子View主动调用parent.requestDisallowInterceptTouchEvent(true)来告诉系统说:这个事件我要了,父View不要拦截了。这就是所谓的内部拦截法。在ListView的某些时刻它会去调用这个方法。因此一旦事件传递给了ListView,外部容器就拿不到这个事件了。因此我们要打破它的内部拦截:


  1. @Override 
  2. public void requestDisallowInterceptTouchEvent(boolean b) { 
  3.     // 去掉默认行为,使得每个事件都会经过这个Layout 
  4.  

方法如上,把requestDisallowInterceptTouchEvent的实现干掉就可以了。

主要的技术点已近提出来了。那么下面就看具体实现,首先看使用xml:


  1. <org.cgspine.nestscroll.one.EventDispatchPlanLayout 
  2.     android:id="@+id/scrollLayout" 
  3.     android:layout_marginTop="?attr/actionBarSize" 
  4.     android:layout_width="match_parent" 
  5.     android:layout_height="match_parent" 
  6.     app:header_view="@+id/book_header" 
  7.     app:target_view="@+id/scroll_view" 
  8.     app:header_init_offset="30dp" 
  9.     app:target_init_offset="70dp"
  10.     <View 
  11.         android:id="@id/book_header" 
  12.         android:layout_width="120dp" 
  13.         android:layout_height="150dp" 
  14.         android:background="@color/gray"/> 
  15.     <org.cgspine.nestscroll.one.EventDispatchTargetLayout 
  16.         android:id="@id/scroll_view" 
  17.         android:layout_width="match_parent" 
  18.         android:layout_height="match_parent" 
  19.         android:orientation="vertical" 
  20.         android:background="@color/white"
  21.         <android.support.design.widget.TabLayout 
  22.             android:id="@+id/tab_layout" 
  23.             android:background="@drawable/list_item_bg_with_border_top_bottom" 
  24.             android:layout_width="match_parent" 
  25.             android:layout_height="@dimen/tab_layout_height" 
  26.             android:fillViewport="true"/> 
  27.         <android.support.v4.view.ViewPager 
  28.             android:id="@+id/viewpager" 
  29.             android:layout_width="match_parent" 
  30.             android:layout_height="0dp" 
  31.             android:layout_weight="1"/> 
  32.     </org.cgspine.nestscroll.one.EventDispatchTargetLayout> 
  33. </org.cgspine.nestscroll.one.EventDispatchPlanLayout>  

EventDispatchTargetLayout实现了自定义接口ITargetView:


  1. public interface ITargetView { 
  2.     boolean canChildScrollUp(); 
  3.     void fling(float vy); 
  4.  

这是因为与具体业务抽离,我并不清楚内层盒子是怎样的(有可能就是ListView了,也有可能是ViewPager包裹ListView)

主要的实现在EventDispatchPlanLayout,使用时在xml中指定header_init_offset、target_init_offset等变量就可以了,基本上与业务逻辑独立。

其重点实现逻辑在onInterceptTouchEvent与onTouchEvent中了。个人不是很建议去动dispatchTouchEvent,虽然所有事件都会经过这里,但是这也明显会增加代码处理复杂度:


  1. public boolean onInterceptTouchEvent(MotionEvent ev) { 
  2.     ensureHeaderViewAndScrollView(); 
  3.     final int action = MotionEventCompat.getActionMasked(ev); 
  4.     int pointerIndex; 
  5.  
  6.     // 不阻断事件的快路径:如果目标view可以往上滚动或者`EventDispatchPlanLayout`不是enabled 
  7.     if (!isEnabled() || mTarget.canChildScrollUp()) { 
  8.         Log.d(TAG, "fast end onIntercept: isEnabled = " + isEnabled() + "; canChildScrollUp = " 
  9.                 + mTarget.canChildScrollUp()); 
  10.         return false
  11.     } 
  12.     switch (action) { 
  13.         case MotionEvent.ACTION_DOWN: 
  14.             mActivePointerId = ev.getPointerId(0); 
  15.             mIsDragging = false
  16.             pointerIndex = ev.findPointerIndex(mActivePointerId); 
  17.             if (pointerIndex < 0) { 
  18.                 return false
  19.             } 
  20.             // 在down的时候记录初始的y值 
  21.             mInitialDownY = ev.getY(pointerIndex); 
  22.             break; 
  23.  
  24.         case MotionEvent.ACTION_MOVE: 
  25.             pointerIndex = ev.findPointerIndex(mActivePointerId); 
  26.             if (pointerIndex < 0) { 
  27.                 Log.e(TAG, "Got ACTION_MOVE event but have an invalid active pointer id."); 
  28.                 return false
  29.             } 
  30.  
  31.             final float y = ev.getY(pointerIndex); 
  32.             // 判断是否dragging 
  33.             startDragging(y); 
  34.             break; 
  35.  
  36.         case MotionEventCompat.ACTION_POINTER_UP: 
  37.             // 双指逻辑处理 
  38.             onSecondaryPointerUp(ev); 
  39.             break; 
  40.  
  41.         case MotionEvent.ACTION_UP: 
  42.         case MotionEvent.ACTION_CANCEL: 
  43.             mIsDragging = false
  44.             mActivePointerId = INVALID_POINTER; 
  45.             break; 
  46.     } 
  47.  
  48.     return mIsDragging; 
  49.  

代码逻辑很清晰,应该不用多说。接下来看onTouchEvent的处理逻辑。


  1. public boolean onTouchEvent(MotionEvent ev) { 
  2.     final int action = MotionEventCompat.getActionMasked(ev); 
  3.     int pointerIndex; 
  4.  
  5.     if (!isEnabled() || mTarget.canChildScrollUp()) { 
  6.         Log.d(TAG, "fast end onTouchEvent: isEnabled = " + isEnabled() + "; canChildScrollUp = " 
  7.                 + mTarget.canChildScrollUp()); 
  8.         return false
  9.     } 
  10.    // 速度追踪 
  11.    acquireVelocityTracker(ev); 
  12.  
  13.     switch (action) { 
  14.         case MotionEvent.ACTION_DOWN: 
  15.             mActivePointerId = ev.getPointerId(0); 
  16.             mIsDragging = false
  17.             break; 
  18.  
  19.         case MotionEvent.ACTION_MOVE: { 
  20.             pointerIndex = ev.findPointerIndex(mActivePointerId); 
  21.             if (pointerIndex < 0) { 
  22.                 Log.e(TAG, "Got ACTION_MOVE event but have an invalid active pointer id."); 
  23.                 return false
  24.             } 
  25.             final float y = ev.getY(pointerIndex); 
  26.             startDragging(y); 
  27.  
  28.             if (mIsDragging) { 
  29.                 float dy = y - mLastMotionY; 
  30.                 if (dy >= 0) { 
  31.                     moveTargetView(dy); 
  32.                 } else { 
  33.                     if (mTargetCurrentOffset + dy <= mTargetEndOffset) { 
  34.                         moveTargetView(dy); 
  35.                         // 重新dispatch一次down事件,使得列表可以继续滚动 
  36.                         int oldAction = ev.getAction(); 
  37.                         ev.setAction(MotionEvent.ACTION_DOWN); 
  38.                         dispatchTouchEvent(ev); 
  39.                         ev.setAction(oldAction); 
  40.                     } else { 
  41.                         moveTargetView(dy); 
  42.                     } 
  43.                 } 
  44.                 mLastMotionY = y; 
  45.             } 
  46.             break; 
  47.         } 
  48.         case MotionEventCompat.ACTION_POINTER_DOWN: { 
  49.             pointerIndex = MotionEventCompat.getActionIndex(ev); 
  50.             if (pointerIndex < 0) { 
  51.                 Log.e(TAG, "Got ACTION_POINTER_DOWN event but have an invalid action index."); 
  52.                 return false
  53.             } 
  54.             mActivePointerId = ev.getPointerId(pointerIndex); 
  55.             break; 
  56.         } 
  57.  
  58.         case MotionEventCompat.ACTION_POINTER_UP: 
  59.             onSecondaryPointerUp(ev); 
  60.             break; 
  61.  
  62.         case MotionEvent.ACTION_UP: { 
  63.             pointerIndex = ev.findPointerIndex(mActivePointerId); 
  64.             if (pointerIndex < 0) { 
  65.                 Log.e(TAG, "Got ACTION_UP event but don't have an active pointer id."); 
  66.                 return false
  67.             } 
  68.  
  69.             if (mIsDragging) { 
  70.                 mIsDragging = false
  71.                 // 获取瞬时速度 
  72.                 mVelocityTracker.computeCurrentVelocity(1000, mMaxVelocity); 
  73.                 final float vy = mVelocityTracker.getYVelocity(mActivePointerId); 
  74.                 finishDrag((int) vy); 
  75.             } 
  76.             mActivePointerId = INVALID_POINTER; 
  77.             //释放速度追踪 
  78.             releaseVelocityTracker(); 
  79.             return false
  80.         } 
  81.         case MotionEvent.ACTION_CANCEL: 
  82.             releaseVelocityTracker(); 
  83.             return false
  84.     } 
  85.  
  86.     return mIsDragging; 
  87.  

或许有人会说:为何与onInterceptTouchEvent与有很多重复代码?这是因为如果事件不打断,并且子类不处理,就会走进onTouchEvent逻辑,所以这些重复处理是有意义的(其实是抄SwipeRefreshLayout的)。里面主要的逻辑就是两个:

  1. 滚动容器
  2. TouchUp时滚动到特定位置以及fling传递

滚动容器的逻辑:


  1. private void moveTargetViewTo(int target) { 
  2.     target = Math.max(target, mTargetEndOffset); 
  3.     // 用offsetTopAndBottom来偏移view 
  4.     ViewCompat.offsetTopAndBottom(mTargetView, target - mTargetCurrentOffset); 
  5.     mTargetCurrentOffset = target; 
  6.  
  7.     // 滚动书籍封面view,根据TargetView进行定位 
  8.     int headerTarget; 
  9.     if (mTargetCurrentOffset >= mTargetInitOffset) { 
  10.         headerTarget = mHeaderInitOffset; 
  11.     } else if (mTargetCurrentOffset <= mTargetEndOffset) { 
  12.         headerTarget = mHeaderEndOffset; 
  13.     } else { 
  14.         float percent = (mTargetCurrentOffset - mTargetEndOffset) * 1.0f / mTargetInitOffset - mTargetEndOffset; 
  15.         headerTarget = (int) (mHeaderEndOffset + percent * (mHeaderInitOffset - mHeaderEndOffset)); 
  16.     } 
  17.     ViewCompat.offsetTopAndBottom(mHeaderView, headerTarget - mHeaderCurrentOffset); 
  18.     mHeaderCurrentOffset = headerTarget; 
  19.  

TouchUp的滚动逻辑:


  1. private void finishDrag(int vy) { 
  2.     Log.i(TAG, "TouchUp: vy = " + vy); 
  3.     if (vy > 0) { 
  4.         // 向下触发fling,需要滚动到Init位置 
  5.         mNeedScrollToInitPos = true
  6.         mScroller.fling(0, mTargetCurrentOffset, 0, vy, 
  7.                 0, 0, mTargetEndOffset, Integer.MAX_VALUE); 
  8.         invalidate(); 
  9.     } else if (vy < 0) { 
  10.        // 向上触发fling,需要滚动到End位置 
  11.         mNeedScrollToEndPos = true
  12.         mScroller.fling(0, mTargetCurrentOffset, 0, vy, 
  13.                 0, 0, mTargetEndOffset, Integer.MAX_VALUE); 
  14.         invalidate(); 
  15.     } else { 
  16.         // 没有触发fling,就近原则 
  17.         if (mTargetCurrentOffset <= (mTargetEndOffset + mTargetInitOffset) / 2) { 
  18.             mNeedScrollToEndPos = true
  19.         } else { 
  20.             mNeedScrollToInitPos = true
  21.         } 
  22.         invalidate(); 
  23.     } 
  24.  

当然这里会打上一些标志位,具体实现是在computeScroll中,这属于Scroller的功能,这里就不展开了。

这样大体逻辑就讲述清楚了,其它细节就请看官直接看源码了。

基于NestingScroll机制的实现方案

NestingScroll机制是在某个版本support包加入的,不过外界极少有文章介绍,所以应该大多数人并不知道这个机制。NestingScroll主要有两个接口:

  • NestedScrollingParent
  • NestedScrollingChild

当我们需要使用NestingScroll特性时,我们去实现这两个接口就好了。NestingScroll本质是内部拦截发然后将相应的接口开给外界。因此实现NestedScrollingChild接口是有难度的,不过像RecyclerView这些控件,官方已经帮我们实现好了NestedScrollingChild,要完成我们的需求,我们直接拿来用就好了(ListView就没办法使用了,当然你也可以去实现NestedScrollingChild接口)。并且NestedScrollingChild与NestedScrollingParent只要有嵌套关系就行了,并不一定NestedScrollingChild是直接的子View。

我们来来看看NestedScrollingParent的定义:


  1. public interface NestedScrollingParent { 
  2.     // 是否接受NestingScroll 
  3.     public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes); 
  4.     // 接受NestingScroll的Hook钩子 
  5.     public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes); 
  6.     // NestingScroll结束 
  7.     public void onStopNestedScroll(View target); 
  8.     // NestingScroll进行中。重要参数dxUnconsumed, dyUnconsumed: 用于表示没有被消耗的滚动量,一般是列表滚动到头了,就会产生未消耗量 
  9.     public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed); 
  10.     // NestingScroll滚动之前。重要参数consumed: 是用于告诉子View我消耗了多少。如果位全部消耗dy,那么子view就可以消耗了。 
  11.     public void onNestedPreScroll(View target, int dx, int dy, int[] consumed); 
  12.     // fling时 
  13.     public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed); 
  14.     // fling之前:可以由父元素消耗这次fling事件 
  15.     public boolean onNestedPreFling(View target, float velocityX, float velocityY); 
  16.    // 获取滚动轴: x轴或y轴 
  17.    public int getNestedScrollAxes(); 
  18.  

接口是非常丰富的。有一个很重要的概念:消耗量。 比如我滑动了10dp,那么父元素先看看可以消耗多少(例如4dp),然后会把未消耗量传递给子View(6dp)。这就把嵌套滚动的问题转换为资源分配的问题了。非常机智。除此以外,官方提供了NestedScrollingParentHelper类帮我实现了一些公共方法并做好了低版本兼容,我们应当拿来用。

写在最后

虽然google提供了很多新颖好玩的接口。但这需要花费部分精力去实践这些新技术。这是非常有意义的投入。多看、多写,才能帮助我们用更少的时间写更好的代码。





本文作者:佚名
来源:51CTO
上一篇:4月24日云栖精选夜读:全球唯一! MySQL社区2018年度公司贡献奖颁给阿里云


下一篇:R语言合并data.frame