在Android UI开发过程中,经常会遇到嵌套滚动的需求,所谓嵌套滚动,就是父view可以滚动的情况下子view也可以滚动,例如下拉刷新(PullToRefresh)。在微信读书之前的版本中,书籍讨论圈有一个比较复杂的嵌套滚动的例子,我把它抽取出来作为今天讲解的例子:
这个例子的嵌套比较复杂,上方的header为书籍封面,下方是一个ViewPager+TabLayout组成的容器(下文简称VT容器),ViewPager中的三个item为三个列表,也是可以滚动的。业务需求是:
- VT容器可以滚动;
- 书籍封面可以滚动,并且有视差;
- 当VT容器滚动到顶部时,滚动列表,并且滚动可以衔接。
- 当列表滚动到顶部时,可以滚动书籍封面以及VT容器,并且滚动可以衔接
逻辑清楚了,接下来就看如何实现了。在android5以前,对于这种滚动,我们只能选择自己去拦截事件并处理,但在后面的某个版本,android推出了NestingScroll机制,开发者的日子就好过多了,并且android提供了一个非常好的容器类:CoordinatorLayout,极大的简化了开发者的工作。当然我们也需要投入精力去学习并运用这些新的Api了。
当然,我们也要知道如果没有这些API,我们应当如何去实现这些效果。因此本文会用三种方式去实现这个效果:
- 纯事件拦截与派发方案
- 基于NestingScroll机制的实现方案
- 基于CoordinatorLayout与Behavior方案的实现
示例代码放在Github上,可以clone下来结合文章观看
纯事件拦截与派发方案
这是最为原始的方案,当然也灵活性最高的了。其它的方案原理上都是系统基于它提供的封装。使用这种方案时,我们需要解决以下几个问题:
- view的滚动(Scroller);
- view的速度追踪(VelocityTracker);
- 当VT容器滚动到顶部时,我们如何将事件传递给ListView?
- 当ListView滚动到顶部时,VT容器如何拦截到事件?
1、2两点属于滚动的基础知识,这里不会做细致的讲解。而第3点为何会出现呢?因为android系统在事件派发时,如果事件被拦截,那么之后的事件都将不会传递给子view了。其解决方案也很简单:在滚动到顶部时主动派发一次Down事件:
- if (mTargetCurrentOffset + dy <= mTargetEndOffset) {
- moveTargetView(dy);
- // 重新dispatch一次down事件,使得列表可以继续滚动
- int oldAction = ev.getAction();
- ev.setAction(MotionEvent.ACTION_DOWN);
- dispatchTouchEvent(ev);
- ev.setAction(oldAction);
- } else {
- moveTargetView(dy);
- }
那么第4点是什么问题呢?这里就需要清楚一个坑点了:不是所用的事件都会走入onInterceptTouchEvent。有一种情况是子View主动调用parent.requestDisallowInterceptTouchEvent(true)来告诉系统说:这个事件我要了,父View不要拦截了。这就是所谓的内部拦截法。在ListView的某些时刻它会去调用这个方法。因此一旦事件传递给了ListView,外部容器就拿不到这个事件了。因此我们要打破它的内部拦截:
- @Override
- public void requestDisallowInterceptTouchEvent(boolean b) {
- // 去掉默认行为,使得每个事件都会经过这个Layout
- }
方法如上,把requestDisallowInterceptTouchEvent的实现干掉就可以了。
主要的技术点已近提出来了。那么下面就看具体实现,首先看使用xml:
- <org.cgspine.nestscroll.one.EventDispatchPlanLayout
- android:id="@+id/scrollLayout"
- android:layout_marginTop="?attr/actionBarSize"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- app:header_view="@+id/book_header"
- app:target_view="@+id/scroll_view"
- app:header_init_offset="30dp"
- app:target_init_offset="70dp">
- <View
- android:id="@id/book_header"
- android:layout_width="120dp"
- android:layout_height="150dp"
- android:background="@color/gray"/>
- <org.cgspine.nestscroll.one.EventDispatchTargetLayout
- android:id="@id/scroll_view"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:orientation="vertical"
- android:background="@color/white">
- <android.support.design.widget.TabLayout
- android:id="@+id/tab_layout"
- android:background="@drawable/list_item_bg_with_border_top_bottom"
- android:layout_width="match_parent"
- android:layout_height="@dimen/tab_layout_height"
- android:fillViewport="true"/>
- <android.support.v4.view.ViewPager
- android:id="@+id/viewpager"
- android:layout_width="match_parent"
- android:layout_height="0dp"
- android:layout_weight="1"/>
- </org.cgspine.nestscroll.one.EventDispatchTargetLayout>
- </org.cgspine.nestscroll.one.EventDispatchPlanLayout>
EventDispatchTargetLayout实现了自定义接口ITargetView:
- public interface ITargetView {
- boolean canChildScrollUp();
- void fling(float vy);
- }
这是因为与具体业务抽离,我并不清楚内层盒子是怎样的(有可能就是ListView了,也有可能是ViewPager包裹ListView)
主要的实现在EventDispatchPlanLayout,使用时在xml中指定header_init_offset、target_init_offset等变量就可以了,基本上与业务逻辑独立。
其重点实现逻辑在onInterceptTouchEvent与onTouchEvent中了。个人不是很建议去动dispatchTouchEvent,虽然所有事件都会经过这里,但是这也明显会增加代码处理复杂度:
- public boolean onInterceptTouchEvent(MotionEvent ev) {
- ensureHeaderViewAndScrollView();
- final int action = MotionEventCompat.getActionMasked(ev);
- int pointerIndex;
- // 不阻断事件的快路径:如果目标view可以往上滚动或者`EventDispatchPlanLayout`不是enabled
- if (!isEnabled() || mTarget.canChildScrollUp()) {
- Log.d(TAG, "fast end onIntercept: isEnabled = " + isEnabled() + "; canChildScrollUp = "
- + mTarget.canChildScrollUp());
- return false;
- }
- switch (action) {
- case MotionEvent.ACTION_DOWN:
- mActivePointerId = ev.getPointerId(0);
- mIsDragging = false;
- pointerIndex = ev.findPointerIndex(mActivePointerId);
- if (pointerIndex < 0) {
- return false;
- }
- // 在down的时候记录初始的y值
- mInitialDownY = ev.getY(pointerIndex);
- break;
- case MotionEvent.ACTION_MOVE:
- pointerIndex = ev.findPointerIndex(mActivePointerId);
- if (pointerIndex < 0) {
- Log.e(TAG, "Got ACTION_MOVE event but have an invalid active pointer id.");
- return false;
- }
- final float y = ev.getY(pointerIndex);
- // 判断是否dragging
- startDragging(y);
- break;
- case MotionEventCompat.ACTION_POINTER_UP:
- // 双指逻辑处理
- onSecondaryPointerUp(ev);
- break;
- case MotionEvent.ACTION_UP:
- case MotionEvent.ACTION_CANCEL:
- mIsDragging = false;
- mActivePointerId = INVALID_POINTER;
- break;
- }
- return mIsDragging;
- }
代码逻辑很清晰,应该不用多说。接下来看onTouchEvent的处理逻辑。
- public boolean onTouchEvent(MotionEvent ev) {
- final int action = MotionEventCompat.getActionMasked(ev);
- int pointerIndex;
- if (!isEnabled() || mTarget.canChildScrollUp()) {
- Log.d(TAG, "fast end onTouchEvent: isEnabled = " + isEnabled() + "; canChildScrollUp = "
- + mTarget.canChildScrollUp());
- return false;
- }
- // 速度追踪
- acquireVelocityTracker(ev);
- switch (action) {
- case MotionEvent.ACTION_DOWN:
- mActivePointerId = ev.getPointerId(0);
- mIsDragging = false;
- break;
- case MotionEvent.ACTION_MOVE: {
- pointerIndex = ev.findPointerIndex(mActivePointerId);
- if (pointerIndex < 0) {
- Log.e(TAG, "Got ACTION_MOVE event but have an invalid active pointer id.");
- return false;
- }
- final float y = ev.getY(pointerIndex);
- startDragging(y);
- if (mIsDragging) {
- float dy = y - mLastMotionY;
- if (dy >= 0) {
- moveTargetView(dy);
- } else {
- if (mTargetCurrentOffset + dy <= mTargetEndOffset) {
- moveTargetView(dy);
- // 重新dispatch一次down事件,使得列表可以继续滚动
- int oldAction = ev.getAction();
- ev.setAction(MotionEvent.ACTION_DOWN);
- dispatchTouchEvent(ev);
- ev.setAction(oldAction);
- } else {
- moveTargetView(dy);
- }
- }
- mLastMotionY = y;
- }
- break;
- }
- case MotionEventCompat.ACTION_POINTER_DOWN: {
- pointerIndex = MotionEventCompat.getActionIndex(ev);
- if (pointerIndex < 0) {
- Log.e(TAG, "Got ACTION_POINTER_DOWN event but have an invalid action index.");
- return false;
- }
- mActivePointerId = ev.getPointerId(pointerIndex);
- break;
- }
- case MotionEventCompat.ACTION_POINTER_UP:
- onSecondaryPointerUp(ev);
- break;
- case MotionEvent.ACTION_UP: {
- pointerIndex = ev.findPointerIndex(mActivePointerId);
- if (pointerIndex < 0) {
- Log.e(TAG, "Got ACTION_UP event but don't have an active pointer id.");
- return false;
- }
- if (mIsDragging) {
- mIsDragging = false;
- // 获取瞬时速度
- mVelocityTracker.computeCurrentVelocity(1000, mMaxVelocity);
- final float vy = mVelocityTracker.getYVelocity(mActivePointerId);
- finishDrag((int) vy);
- }
- mActivePointerId = INVALID_POINTER;
- //释放速度追踪
- releaseVelocityTracker();
- return false;
- }
- case MotionEvent.ACTION_CANCEL:
- releaseVelocityTracker();
- return false;
- }
- return mIsDragging;
- }
或许有人会说:为何与onInterceptTouchEvent与有很多重复代码?这是因为如果事件不打断,并且子类不处理,就会走进onTouchEvent逻辑,所以这些重复处理是有意义的(其实是抄SwipeRefreshLayout的)。里面主要的逻辑就是两个:
- 滚动容器
- TouchUp时滚动到特定位置以及fling传递
滚动容器的逻辑:
- private void moveTargetViewTo(int target) {
- target = Math.max(target, mTargetEndOffset);
- // 用offsetTopAndBottom来偏移view
- ViewCompat.offsetTopAndBottom(mTargetView, target - mTargetCurrentOffset);
- mTargetCurrentOffset = target;
- // 滚动书籍封面view,根据TargetView进行定位
- int headerTarget;
- if (mTargetCurrentOffset >= mTargetInitOffset) {
- headerTarget = mHeaderInitOffset;
- } else if (mTargetCurrentOffset <= mTargetEndOffset) {
- headerTarget = mHeaderEndOffset;
- } else {
- float percent = (mTargetCurrentOffset - mTargetEndOffset) * 1.0f / mTargetInitOffset - mTargetEndOffset;
- headerTarget = (int) (mHeaderEndOffset + percent * (mHeaderInitOffset - mHeaderEndOffset));
- }
- ViewCompat.offsetTopAndBottom(mHeaderView, headerTarget - mHeaderCurrentOffset);
- mHeaderCurrentOffset = headerTarget;
- }
TouchUp的滚动逻辑:
- private void finishDrag(int vy) {
- Log.i(TAG, "TouchUp: vy = " + vy);
- if (vy > 0) {
- // 向下触发fling,需要滚动到Init位置
- mNeedScrollToInitPos = true;
- mScroller.fling(0, mTargetCurrentOffset, 0, vy,
- 0, 0, mTargetEndOffset, Integer.MAX_VALUE);
- invalidate();
- } else if (vy < 0) {
- // 向上触发fling,需要滚动到End位置
- mNeedScrollToEndPos = true;
- mScroller.fling(0, mTargetCurrentOffset, 0, vy,
- 0, 0, mTargetEndOffset, Integer.MAX_VALUE);
- invalidate();
- } else {
- // 没有触发fling,就近原则
- if (mTargetCurrentOffset <= (mTargetEndOffset + mTargetInitOffset) / 2) {
- mNeedScrollToEndPos = true;
- } else {
- mNeedScrollToInitPos = true;
- }
- invalidate();
- }
- }
当然这里会打上一些标志位,具体实现是在computeScroll中,这属于Scroller的功能,这里就不展开了。
这样大体逻辑就讲述清楚了,其它细节就请看官直接看源码了。
基于NestingScroll机制的实现方案
NestingScroll机制是在某个版本support包加入的,不过外界极少有文章介绍,所以应该大多数人并不知道这个机制。NestingScroll主要有两个接口:
- NestedScrollingParent
- NestedScrollingChild
当我们需要使用NestingScroll特性时,我们去实现这两个接口就好了。NestingScroll本质是内部拦截发然后将相应的接口开给外界。因此实现NestedScrollingChild接口是有难度的,不过像RecyclerView这些控件,官方已经帮我们实现好了NestedScrollingChild,要完成我们的需求,我们直接拿来用就好了(ListView就没办法使用了,当然你也可以去实现NestedScrollingChild接口)。并且NestedScrollingChild与NestedScrollingParent只要有嵌套关系就行了,并不一定NestedScrollingChild是直接的子View。
我们来来看看NestedScrollingParent的定义:
- public interface NestedScrollingParent {
- // 是否接受NestingScroll
- public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes);
- // 接受NestingScroll的Hook钩子
- public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes);
- // NestingScroll结束
- public void onStopNestedScroll(View target);
- // NestingScroll进行中。重要参数dxUnconsumed, dyUnconsumed: 用于表示没有被消耗的滚动量,一般是列表滚动到头了,就会产生未消耗量
- public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed);
- // NestingScroll滚动之前。重要参数consumed: 是用于告诉子View我消耗了多少。如果位全部消耗dy,那么子view就可以消耗了。
- public void onNestedPreScroll(View target, int dx, int dy, int[] consumed);
- // fling时
- public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed);
- // fling之前:可以由父元素消耗这次fling事件
- public boolean onNestedPreFling(View target, float velocityX, float velocityY);
- // 获取滚动轴: x轴或y轴
- public int getNestedScrollAxes();
- }
接口是非常丰富的。有一个很重要的概念:消耗量。 比如我滑动了10dp,那么父元素先看看可以消耗多少(例如4dp),然后会把未消耗量传递给子View(6dp)。这就把嵌套滚动的问题转换为资源分配的问题了。非常机智。除此以外,官方提供了NestedScrollingParentHelper类帮我实现了一些公共方法并做好了低版本兼容,我们应当拿来用。
写在最后
虽然google提供了很多新颖好玩的接口。但这需要花费部分精力去实践这些新技术。这是非常有意义的投入。多看、多写,才能帮助我们用更少的时间写更好的代码。