当前位置: 首页 > 科技观察

玩转Android嵌套滚动

时间:2023-03-22 14:35:24 科技观察

在AndroidUI开发过程中,经常会遇到嵌套滚动的需求。所谓嵌套滚动,就是父视图可以滚动的时候,子视图也可以滚动,比如下拉刷新(PullToRefresh)。在之前版本的微信读书中,在读书讨论圈有一个比较复杂的嵌套滚动的例子。我把它提取出来作为今天讲解的例子:这个例子的嵌套比较复杂。顶部的header是书的封面,底部的header是ViewPager+TabLayout组成的容器(以下简称VT容器),ViewPager中的三个item是三个列表,也可以滚动。业务需求是:VT容器可以滚动;书籍封面可以视差滚动;当VT容器滚动到顶部时,列表可以滚动,滚动可以连接。当列表滚动到顶部时,书的封面和VT容器就可以滚动了,滚动逻辑上是连在一起的。接下来,让我们看看如何实现它。在android5之前,对于这种滚动,我们只能选择自己拦截处理事件,但是在后来的版本中,android引入了NestingScroll机制,让开发者的日子轻松了许多,android提供了一个非常好的容器class:CoordinatorLayout,大大简化了开发者的工作。当然,我们也需要投入精力去学习和使用这些新的API。当然,我们还需要知道在没有这些API的情况下如何实现这些效果。因此,本文将使用三种方法来实现这个效果:纯事件拦截和派发方案基于NestingScroll机制的实现方案基于CoordinatorLayout的实现方案和Behavior方案实现示例代码放在Github上,大家可以clone下来观看纯事件拦截调度方案结合文章这是最原始的方案,当然也是最灵活的方案。原则上,其他解决方案都是基于系统提供的封装。在使用该方案时,我们需要解决以下问题:视图的滚动(Scroller);视图的速度跟踪(VelocityTracker);当VT容器滚动到顶部时,我们如何将事件传递给ListView?当ListView滚动到顶部时,VT容器如何拦截事件?第1点和第2点属于滚动的基础知识,这里不再详细解释。为什么会出现第三点?因为android系统在派发事件的时候,如果事件被拦截了,那么后续的事件就不会传递给子view了。解决方法也很简单:滚动到顶部时主动派发一个Down事件:if(mTargetCurrentOffset+dy<=mTargetEndOffset){moveTargetView(dy);//重新派发一个down事件让列表继续滚动到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,外部容器就拿不到事件了。所以我们要打破它的内部拦截:@OverridepublicvoidrequestDisallowInterceptTouchEvent(booleanb){//去掉默认的行为,这样每个事件都会像上面一样通过这个Layout}方法,把requestDisallowInterceptTouchEvent的实现干掉就行了。提出了主要的技术要点。那么下面就看工具实体,首先看使用xml:EventDispatchTargetLayout实现了自定义接口ITargetView:publicinterfaceITargetView{booleancanChildScrollUp();voidfling(floatvy);}这个是因为和具体业务脱离了,不知道里面的框是什么(可能是ListView,或者可能是ViewPager包裹了ListView),主要实现在EventDispatchPlanLayout中。使用时可以在xml中指定header_init_offset、target_init_offset等变量,基本与业务逻辑无关。关键的实现逻辑在onInterceptTouchEvent和onTouchEvent中。个人不建议挪动dispatchTouchEvent,虽然所有的事件都会经过这里,但是这样显然会增加代码处理的复杂度://不阻塞中断事件的快速路径:如果目标视图可以向上滚动或者`EventDispatchPlanLayout`没有启用if(!isEnabled()||mTarget.canChildScrollUp()){Log.d(TAG,"fastendonIntercept:isEnabled="+isEnabled()+";canChildScrollUp="+mTarget.canChildScrollUp());returnfalse;}switch(action){caseMotionEvent.ACTION_DOWN:mActivePointerId=ev.getPointerId(0);mIsDragging=false;pointerIndex=ev.findPointerIndex(mActivePointerId);if(pointerIndex<0){returnfalse;}//记录下降时的初始y值mInitialDownY=ev.getY(pointerIndex);break;caseMotionEvent.ACTION_MOVE:pointerIndex=ev.findPointerIndex(mActivePointerId);if(pointerIndex<0){Log.e(TAG,"GotACTION_MOVEeventbuthaveaninvalidactivepointerid。");returnfalse;}finalfloaty=ev.getY(pointerIndex);//判断是否拖动startDragging(y);break;caseMotionEventCompat.ACTION_POINTER_UP://双指逻辑处理onSecondaryPointerUp(ev);break;caseMotionEvent.ACTION_UP:caseMotionEvent.ACTION_CANCEL:mIsDragging=false;mActivePointerId=INVALID_POINTER;break;}returnmIsDragging;}代码逻辑很清晰,所以应该不用多说了,看onTouchEvent的处理逻辑。publicbooleanonTouchEvent(MotionEventev){finalintaction=MotionEventCompat.getActionMasked(ev);intpointerIndex;if(!isEnabled()||mTarget.canChildScrollUp()){Log.d(TAG,"fastendonTouchEvent:isEnabled="+isEnabled()+";canChildScrollUp="+mTarget.canChildScrollUp());returnfalse;}//速度追踪acquireVelocityTracker(ev);switch(action){caseMotionEvent.ACTION_DOWN:mActivePointerId=ev.getPointerId(0);mIsDragging=false;break;caseMotionEvent.ACTION_MOVE:{pointerIndex=ev.findPointerIndex(mActivePointerId);if(pointerIndex<0){Log.e(TAG,"GotACTION_MOVEeventbuthaveaninvalidactivepointerid");returnfalse;}finalfloaty=ev.getY(pointerIndex);startDragging(y);if(mIsDragging){floatdy=y-mLastMotionY;if(dy>=0){moveTargetView(dy);}else{if(mTargetCurrentOffset+dy<=mTargetEndOffset){moveTargetView(dy);//重新调度一次down事件,使列表可以继续滚动intoldAction=ev.getAction();ev.setAction(MotionEvent.ACTION_DOWN);dispatchTouchEvent(ev);ev.setAction(oldAction);}else{moveTargetView(dy);}}mLastMotionY=y;}break;}caseMotionEventCompat.ACTION_POINTER_DOWN:{pointerIndex=MotionEventCompat.getActionIndex(ev);if(pointerIndex<0){Log.e(TAG,"GotACTION_POINTER_DOWNeventbuthaveaninvalidactionindex.");returnfalse;}mActivePointerId=ev.getPointerId(pointerIndex);break;}caseMotionEventCompat.ACTION_POINTER_UP:onSecondaryPointerUp(ev);break;caseMotionEvent.ACTION_UP:{pointerIndex=ev.findPointerIndex(mActivePointerId);if(pointerIndex<0){Log.e(TAG,"GotACTION_UPeventbutdon'thaveanactivepointerid.");returnfalse;}if(mIsDragging){mIsDragging=false;//获取瞬时速度mVelocityTracker.computeCurrentVelocity(1000,mMaxVelocity);finalfloatvy=mVelocityTracker.getYVelocity(finishDragging)(mActive)vy);}mActivePointerId=INVALID_POINTER;//释放速度跟踪releaseVelocityTracker();returnfalse;}caseMotionEvent.ACTION_CANCEL:releaseVelocityTracker();returnfalse;}returnmIsDragging;}有人可能会说:为什么onInterceptTouchEvent会有那么多重复代码?因为如果某事如果item没有被中断,子类没有处理,就会进入onTouchEvent逻辑,所以这些重复的处理是有意义的(实际上是从SwipeRefreshLayout复制过来的)里面主要有两个逻辑:滚动容器TouchUp时滚动到特定位置,fling调用滚动容器的逻辑:privatevoidmoveTargetViewTo(inttarget){target=Math.max(target,mTargetEndOffset);//使用offsetTopAndBottom偏移viewViewCompat.offsetTopAndBottom(mTargetView,target-mTargetCurrentOffset);mTargetCurrentOffset=target;//滚动书籍封面视图,根据TargetView定位到theaderTarget中;if(mtargetCurrentOffset>=mtargetInitOffSet){headerTarget=mheaderInitOffSet;}elseif(mtargetCurrentEnderOffseft<=mtargetheaderoffsef=mtargetheaderoffse=mtargetheAderOffsect=mtargetheAdeRoffSet)*(mHeaderInitOffset-mHeaderEndOffset));}ViewCompat.offsetTopAndBottom(mHeaderView,headerTarget-mHeaderCurrentOffset);mHeaderCurrentOffset=headerTarget;}TouchUp的滚动逻辑:privatevoidfinishDrag(intvy){Log.i(TAG,"TouchUp:vy="+vy);if(vy>0){//触发器向下滑动,需要滚动到Init位置mNeedScrollToInitPos=true;mScroller.fling(0,mTargetCurrentOffset,0,vy,0,0,mTargetEndOffset,Integer.MAX_VALUE);invalidate();}elseif(vy<0){//触发向上抛,需要滚动到结束位置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();}}当然这里也会标记一些flags。具体实现在computeScroll中,属于Scroller的功能。这里就不展开了,为了把大致的逻辑解释清楚。其他细节请直接参考源码。基于NestingScroll机制的实现NestingScroll机制是在某个版本的支持包中加入的,但是外界介绍的文章很少,所以大多数人应该不知道这个机制。NestingScroll主要有两个接口:NestedScrollingParentNestedScrollingChild当我们需要使用NestingScroll特性时,只需要实现这两个接口即可。NestingScroll的本质是在内部拦截发送,然后对外开放相应的接口。所以NestedScrollingChild接口实现起来比较困难,但是像RecyclerView这样的控件,官方已经帮我们实现了NestedScrollingChild。为了满足我们的需求,直接使用即可(ListView不能使用,当然你也可以自己实现NestedScrollingChild接口)。并且只要NestedScrollingChild和NestedScrollingParent之间存在嵌套关系,NestedScrollingChild不一定是直接子View。我们来来看看NestedScrollingParent的定义:publicinterfaceNestedScrollingParent{//是否接受NestingScrollpublicbooleanonStartNestedScroll(Viewchild,Viewtarget,intnestedScrollAxes);//接受NestingScroll的Hook钩子publicvoidonNestedScrollAccepted(Viewchild,Viewtarget,intnestedScrollAxes);//NestingScroll结束publicvoidonStopNestedScroll(Viewtarget);//NestingScroll正在进行中。重要参数dxUnconsumed、dyUnconsumed:用于表示还没有被消耗的滚动量。一般当列表滚动到最后时,会产生一个未消费的量publicvoidonNestedScroll(Viewtarget,intdxConsumed,intdyConsumed,intdxUnconsumed,intdyUnconsumed);//滚动前的NestingScroll。consumed重要参数:用来告诉子View我消费了多少。如果所有位都消费了dy,那么子view就可以消费了。publicvoidonNestedPreScroll(Viewtarget,intdx,intdy,int[]consumed);//publicbooleanonNestedFling(Viewtarget,floatvelocityX,floatvelocityY,booleanconsumed);//在fling之前:这个fling事件可以被父元素消费publicbooleanonNestedPreFling(Viewtarget,floatvelocityX,floatvelocityX,fling);//获取滚动轴:x轴或y轴publicintgetNestedScrollAxes();}接口非常丰富。有一个很重要的概念:消费。比如我滑动10dp,父元素会先看自己能消耗多少(比如4dp),然后把未消耗的量传给子View(6dp)。这就把嵌套滚动的问题变成了资源分配的问题。很机智。另外,官方提供了NestedScrollingParentHelper类,帮我实现了一些公共方法,做低版本兼容,我们应该用到。写在***虽然谷歌提供了很多新奇好玩的界面。但是实践这些新技术需要一些精力。这是一项非常有意义的投资。多读多写可以帮助我们用更少的时间写出更好的代码。

最新推荐
猜你喜欢