前言
ScrollView垂直可滑动控件,当容器中的子视图高度大于ScrollView高度时,通过滑动ScrollView展示内容。
ScrollView继承FrameLayout。它只能有一个子视图,通常默认是LinearLayout。真正展示视图的垂直方向的区域是ScrollView高度除去其上下的padding值。即当计算出LinearLayout高度大于ScrollView高度,可滑动。
ScrollView视图如下图所示
绿色区域是去除padding后,ScrollView内部视图可视范围。LinearLayout子视图即内容视图区域的高度大于绿色。
橙色区域表示多出可视部分,这部分高度是可滚动的Range大小。
ScrollView要显示非可见部分视图,需要通过触屏滑动实现,因此,本文分析的滑动原理,和触屏事件方法有很大关系。
几个重要方法,onInterceptTouchEvent方法,事件拦截,onTouchEvent方法,事件处理,OverScroller类,滚动控制。结合几个特定的手势场景解析ScrollView如何实现滑动。
静止状态,手指触屏到ScrollView,向上滑动
静止状态,手指触屏到ScrollView,向上滑动,然后猛地松开甩出。
滚动状态,手指触屏到ScrollView,向上滑动。
onInterceptTouchEvent方法
负责事件拦截决策,容器类视图都有这个方法,默认不拦截,ScrollView重写该方法。
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
//如果是Move,且mIsBeingDragged已经为true,直接返回。
final int action = ev.getAction();
if ((action == MotionEvent.ACTION_MOVE) && (mIsBeingDragged)) {
return true;
}
//不支持滚动,返回false。
if (getScrollY() == 0 && !canScrollVertically(1)) {
return false;
}
//根据事件类型处理代码段,下面说明
...
return mIsBeingDragged;
}
当Move事件并且设置mIsBeingDragged拖拽标志时,拦截。如果y轴不支持滚动,不拦截,在这种情况下,不管事件交给谁处理,ScrollView都不会介入事件处理。
打断方法,ACTION_DOWN事件处理。
case MotionEvent.ACTION_DOWN: {
final int y = (int) ev.getY();
//判断触屏坐标是否在子视图。
if (!inChild((int) ev.getX(), (int) y)) {
mIsBeingDragged = false;
recycleVelocityTracker();
break;
}
//记住y轴位置
mLastMotionY = y;
mActivePointerId = ev.getPointerId(0);
initOrResetVelocityTracker();
mVelocityTracker.addMovement(ev);
//Scroller未结束运动,说明ScrollView在滚动。返回mIsBeingDragged=true。
mIsBeingDragged = !mScroller.isFinished();
...
startNestedScroll(SCROLL_AXIS_VERTICAL);//Nested相关暂不谈
break;
}
手指触屏ScrollView的第一个事件。mIsBeingDragged是全局变量,true说明ScrollView正在拖拽。
inChild方法,判断触屏点坐标是否在ScrollView的子视图中,坐标是相对于ScrollView坐标系的坐标,不在子视图中,返回mIsBeingDragged是false,不拦截。
OverScroller#isFinished判断ScrollView是否正在滚动,滚动可能是OverScroller#fing或startScroll触发,那么对应第三种手势场景。设置mIsBeingDragged是true,拦截。
ACTION_DOWN事件拦截时,并被成功处理(onTouchEvent返回true),后续事件就不会再往下传递了。
ACTION_DOWN事件不拦截时,事件传递给LinearLayout内部子View,如果有子View接收事件(如按钮),ACTION_DOWN就被消耗掉啦,否则还得靠ScrollView的onTouchEvent方法处理,反正onTouchEvent总能消耗返回true(后面介绍)。
打断方法,ACTION_MOVE事件处理。
case MotionEvent.ACTION_MOVE: {
final int activePointerId = mActivePointerId;
//activePointerId无效break
...
final int y = (int) ev.getY(pointerIndex);
final int yDiff = Math.abs(y - mLastMotionY);
//没有垂直嵌套滚动
if (yDiff > mTouchSlop && (getNestedScrollAxes()
& SCROLL_AXIS_VERTICAL) == 0) {
mIsBeingDragged = true;
mLastMotionY = y;
initVelocityTrackerIfNotExists();
mVelocityTracker.addMovement(ev);
mNestedYOffset = 0;
.....
final ViewParent parent = getParent();
//告诉父节点,你不要拦截了,直接给我
if (parent != null) {
parent.requestDisallowInterceptTouchEvent(true);
}
}
break;
}
ACTION_DOWN未拦截,继续看ACTION_MOVE
1:比较当前y坐标与上一次记住的mLastMotionY的差值yDiff,差值太小(小于阀值mTouchSlop ),不足以让屏幕认为是手指在滑动,不拦截。后期传递与处理与ACTION_DOWN不拦截的处理方法一致。
2:y坐标不同,ACTION_MOVE事件会连续,当有一次差值大于阀值,手指滑动。开始拦截。
对应第一种手势场景,mIsBeingDragged拖拽标志true。屏幕认为:手指从点击到滑动开启新的动作。
3:屏幕认为你在滑动了,需要告诉ScrollView的父视图不要拦截,所有的事件发到ScrollView处理,因为要滚动。
打断方法,ACTION_UP事件处理。
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
/* Release the drag */
mIsBeingDragged = false;
mActivePointerId = INVALID_POINTER;
recycleVelocityTracker();
if (mScroller.springBack(mScrollX, mScrollY, 0, 0, 0,
getScrollRange())) {
postInvalidateOnAnimation();
}
stopNestedScroll();
break;
}
ACTION_MOVE未拦截,手指离开,一直未被屏幕认为要滑动,事件可能被子视图消耗,也可能在onTouchEvent中消耗,无关紧要了,反正我要离开了,将mIsBeingDragged恢复到false。
onTouchEvent分析
负责事件处理,基类View方法,基类已经提供了成功接纳事件的场景,如View可点击,返回true。
ScrollView完全重写该方法,在方法中未触发View的onTouchEvent。导致ScrollView无法使用OnClickListener监听,貌似也没有业务会用到,呵。除非子视图不存在。ScrollView#onTouchEvent的返回值一定是true。
两种基本情景 ,1:被拦截的事件。2:下层未消耗的事件。
事件处理,ACTION_DOWN事件
case MotionEvent.ACTION_DOWN: {
if (getChildCount() == 0) {
return false;
}
//总之我要处理,上面别拦我
if ((mIsBeingDragged = !mScroller.isFinished())) {
final ViewParent parent = getParent();
if (parent != null) {
parent.requestDisallowInterceptTouchEvent(true);
}
}
//正在滚动,停止
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
if (mFlingStrictSpan != null) {
mFlingStrictSpan.finish();
mFlingStrictSpan = null;
}
}
// 记住开始的y坐标
mLastMotionY = (int) ev.getY();
mActivePointerId = ev.getPointerId(0);
startNestedScroll(SCROLL_AXIS_VERTICAL);
break;
}
1:正在滚动,在拦截方法的down事件,已经设置了mIsBeingDragged=true,也有可能都是false,还没滚动,下面未消耗事件传上来的,告诉父视图,别拦我。
2:若正在滚动,则停止滚动abortAnimation,最终返回true,ACTION_DOWN事件被消耗。这就是为什么我们在正滑动的ScrollView视图触摸后,它停止了的原因。
事件处理,ACTION_MOVE事件
case MotionEvent.ACTION_MOVE:
final int activePointerIndex = ev.findPointerIndex(mActivePointerId);
//无效activePointerIndex
.....
final int y = (int) ev.getY(activePointerIndex);
int deltaY = mLastMotionY - y;//mLastMotionY >y时说明手指上移
if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset)) {
deltaY -= mScrollConsumed[1];
vtev.offsetLocation(0, mScrollOffset[1]);
mNestedYOffset += mScrollOffset[1];
}
if (!mIsBeingDragged && Math.abs(deltaY) > mTouchSlop) {
final ViewParent parent = getParent();
if (parent != null) {
parent.requestDisallowInterceptTouchEvent(true);
}
mIsBeingDragged = true;
if (deltaY > 0) {//说明手指上移
deltaY -= mTouchSlop;
} else {
deltaY += mTouchSlop;
}
}
if (mIsBeingDragged) {
...
final int oldY = mScrollY;
final int range = getScrollRange();
final int overscrollMode = getOverScrollMode();
boolean canOverscroll = overscrollMode == OVER_SCROLL_ALWAYS ||
(overscrollMode == OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0);
// View#overScrollBy会触发onOverScrolled
if (overScrollBy(0, deltaY, 0, mScrollY, 0, range, 0, mOverscrollDistance, true)
&& !hasNestedScrollingParent()) {
// Break our velocity if we hit a scroll barrier.
mVelocityTracker.clear();
}
final int scrolledDeltaY = mScrollY - oldY;
final int unconsumedY = deltaY - scrolledDeltaY;
if (dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset)) {
mLastMotionY -= mScrollOffset[1];
vtev.offsetLocation(0, mScrollOffset[1]);
mNestedYOffset += mScrollOffset[1];
} else if (canOverscroll) {
//处理底部与顶部的边缘效果
final int pulledToY = oldY + deltaY;
if (pulledToY < 0) {
mEdgeGlowTop.onPull((float) deltaY / getHeight(),
ev.getX(activePointerIndex) / getWidth());
if (!mEdgeGlowBottom.isFinished()) {
mEdgeGlowBottom.onRelease();
}
} else if (pulledToY > range) {
//见底后上拉的阴影效果
mEdgeGlowBottom.onPull((float) deltaY / getHeight(),
1.f - ev.getX(activePointerIndex) / getWidth());
if (!mEdgeGlowTop.isFinished()) {
mEdgeGlowTop.onRelease();
}
}
if (mEdgeGlowTop != null
&& (!mEdgeGlowTop.isFinished() || !mEdgeGlowBottom.isFinished())) {
postInvalidateOnAnimation();
}
}
}
1:判断上次mLastMotionY坐标与当前的差值deltaY,mIsBeingDragged=false且大于阀值mTouchSlop,说明下层未消耗事件,差值可能因Nested发生了改变,在onInterceptTouchEvent未发现需要拦截所以mIsBeingDragged是false,此时设置拖拽状态mIsBeingDragged=true。
2:拖拽状态下,以手指向上滑动为例分析。
滚动范围,getScrollRange方法,获取滚动的范围Range,即橙色部分。
child.getHeight() - (getHeight() - mPaddingBottom - mPaddingTop),ScrollView子视图的高度height=(mBottom - mTop)大于可视区域,height减去可视区域的高度(注意Padding)就是滚动范围Range。当子视图高度较小,可视区域满足,不需滚动,Range=0。
滚动模式,View#mOverScrollMode滚动模式,OVER_SCROLL_IF_CONTENT_SCROLLS默认,可以滚动canOverscroll的要求是,一直可以滚OVER_SCROLL_ALWAYS或内容足够大以支持有意义的滚动OVER_SCROLL_IF_CONTENT_SCROLLS且Range大于0。
View#overScrollBy滚动,一旦开启了滚动mIsBeingDragged=true我就停不下来。在方法onTouchEvent中会连续的ACTION_MOVE事件,上滑时deltaY较大,速度慢下来deltaY变小,总之是deltaY动态变化的,也可能中间两个间隔deltaY是0,但都不影响overScrollBy。
protected boolean overScrollBy(int deltaX, int deltaY,
int scrollX, int scrollY,
int scrollRangeX, int scrollRangeY,
int maxOverScrollX, int maxOverScrollY,
boolean isTouchEvent) {
//X轴滚动忽略
...
int newScrollY = scrollY + deltaY;
...
final int top = -maxOverScrollY;
final int bottom = maxOverScrollY + scrollRangeY;
..
boolean clampedY = false;
if (newScrollY > bottom) {
newScrollY = bottom;
clampedY = true;
} else if (newScrollY < top) {
newScrollY = top;
clampedY = true;
}
onOverScrolled(newScrollX, newScrollY, clampedX, clampedY);
return clampedX || clampedY;
}
当前scrollY+deltaY得到新newScrollY,这里传入的maxOverScrollY是mOverscrollDistance=0,
如果新值大于滑动区域range,newScrollY设为range,并设置clampedY=true说明上滑已见底。overScrollBy返回的clampedY表示是否已见底。
触发onOverScrolled方法,ScrollView中重写onOverScrolled。onOverScrolled功能就是为View设置偏移mScrollY。一种是立即设置scrollTo,另一种是平滑设置OverScroller。
手指上滑scroll,ScrollView整体往上移动,mScrollY值大于0,mScrollY值不断加deltaY递增。
边缘效果,如果已经见底,手指继续上滑动,oldY即mScrollY是经过overScrollBy设置的最新值,此时一直是range,不会改变。+deltaY后的pulledToY将大于range,mEdgeGlowBottom#onPull触发阴影效果。
ACTION_MOVE事件结束,第一种手势场景主要在这儿完成。
3:未拖拽状态,不关心事件来自哪里,反正我来管,返回true。
事件处理,ACTION_UP事件
case MotionEvent.ACTION_UP:
if (mIsBeingDragged) {
final VelocityTracker velocityTracker = mVelocityTracker;
velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
int initialVelocity = (int) velocityTracker.getYVelocity(mActivePointerId);
if ((Math.abs(initialVelocity) > mMinimumVelocity)) {
//手指猛地一滑,速度很大,触发fling
flingWithNestedDispatch(-initialVelocity);
} else if (mScroller.springBack(mScrollX, mScrollY, 0, 0, 0,
getScrollRange())) {
postInvalidateOnAnimation();
}
mActivePointerId = INVALID_POINTER;
endDrag();
}
手指离开屏幕ACTION_UP事件
1:如果mIsBeingDragged=false,未拖拽,下层未处理,我来管,返回tue。
2:拖拽状态时,速度判断,速度未达到最小值,安静的离开endDrag初始化mIsBeingDragged=false并释放速度追踪器和顶部/底部的边缘效果。速度大于最小值时,触发fling平滑一段距离。
ScrollView#flingWithNestedDispatch方法
private void flingWithNestedDispatch(int velocityY) {
final boolean canFling = (mScrollY > 0 || velocityY > 0) &&
(mScrollY < getScrollRange() || velocityY < 0);
if (!dispatchNestedPreFling(0, velocityY)) {
dispatchNestedFling(0, velocityY, canFling);
if (canFling) {
fling(velocityY);
}
}
}
在flingWithNestedDispatch方法中,判定mScrollY在Range范围,且有启动速度,那么开始fling吧。(这里暂不支持Nested,dispatchNestedPreFling会返回false),
ScrollView的fling方法,由OverScroller负责。
public void fling(int velocityY) {
if (getChildCount() > 0) {
int height = getHeight() - mPaddingBottom - mPaddingTop;
int bottom = getChildAt(0).getHeight();
mScroller.fling(mScrollX, mScrollY, 0, velocityY, 0, 0, 0,
Math.max(0, bottom - height), 0, height/2);
...
postInvalidateOnAnimation();
}
}
height是ScrollView可显示区域的高度即绿色区域,bottom是子视图LinearLayout高度。bottom-height即滑动区域Range。fling自动滚动一段距离,mScrollY的最大值不应超过range,最小值是0。
ACTION_UP事件结束,手指离开触屏,第二种手势场景主要在这儿完成。
OverScroller
先看ScrollView#onOverScrolled,在overScrollBy中触发。
@Override
protected void onOverScrolled(int scrollX, int scrollY,
boolean clampedX, boolean clampedY) {
if (!mScroller.isFinished()) {
final int oldX = mScrollX;
final int oldY = mScrollY;
mScrollX = scrollX;
mScrollY = scrollY;
invalidateParentIfNeeded();
onScrollChanged(mScrollX, mScrollY, oldX, oldY);
if (clampedY) {
mScroller.springBack(mScrollX, mScrollY, 0, 0, 0, getScrollRange());
}
} else {
super.scrollTo(scrollX, scrollY);
}
awakenScrollBars();
}
isFinished平滑运动判定
所谓平滑运动是指mScrollY并非直接设置为最终值,而是经过一个duration到达某数值。此时在绘制时,就会出现平滑运动的效果,系统中默认持续时间250毫秒。
触发View的scrollTo方法,ScrollView设置新scrollY,并重新绘制。连续的ACTION_MOVE事件,不断更新scrollY,于是就会出现手指在ScrollView上滑动,ScrollView发生滚动的效果。触屏事件的overScrollBy处理一般都这条路。
scrollBy(x,y),当前视图内容在x轴和y轴分别偏移x和y个坐标点。
scrollTo(x,y),当前视图内容移动到x轴和y轴上的某个坐标点。
这两个方法都是View提供的方法,控制视图内容的偏移。这种偏移会非常快的移到目标位置,并不能进行流程控制。
OverScroller原理分析
OverScroller的作用就是在偏移的过程中进行流程控制,通过设定一个时间,在规定的时间内移动到指定位置。
OverScroller#startScroll开启平滑滚动
startScroll(int startX, int startY, int dx, int dy, int duration),从x,y的起始位置,偏移一定的距离dx和dy,经历的时间是duration,内部触发SplineOverScroller#startScroll方法
SplineOverScroller#startScroll
void startScroll(int start, int distance, int duration) {
mFinished = false;
mCurrentPosition = mStart = start;
mFinal = start + distance;
mStartTime = AnimationUtils.currentAnimationTimeMillis();
mDuration = duration;
....
}
start代表当前ScrollY,mFinal 是最终值,duration是持续时间。
当我开启了一次startScroll平滑,并触发postInvalidateOnAnimation重绘,computeScroll()是在绘制树形结构中一直触发的方法。
ScrollView#computeScroll()
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
int oldX = mScrollX;
int oldY = mScrollY;
int x = mScroller.getCurrX();
int y = mScroller.getCurrY();
if (oldX != x || oldY != y) {
final int range = getScrollRange();
final int overscrollMode = getOverScrollMode();
final boolean canOverscroll = overscrollMode == OVER_SCROLL_ALWAYS ||
(overscrollMode == OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0);
overScrollBy(x - oldX, y - oldY, oldX, oldY, 0, range,
0, mOverflingDistance, false);
onScrollChanged(mScrollX, mScrollY, oldX, oldY);
....
}
if (!awakenScrollBars()) {
// Keep on drawing until the animation has finished.
postInvalidateOnAnimation();
}
} else {
.....
}
}
computeScrollOffset判断平滑duration是否耗完,根据duration和起始终点位置计算此时的值。从OverScroll中获取当前计算的值,根据与mScrollY差值变化触发overScrollBy,这里会触及到onOverScrolled的isFinished为false的情况,设定新mScrollY,不停地个绘制看到平滑的滚动,直到到达终点。
可以看出OverScroller只是会计算一定的时间内滚动多少才会在滚动时间完成滚动。真正的View移动还是通过View的overScrollBy方法去设置mScrollY。
OverScroller并不是控制滚动,它只是空间移动轨迹的辅助计算类,计算什么时间滚动到什么位置。
OverScroller#fling速度自动移动距离一段,内部触发SplineOverScroller的fling方法。由ACTION_UP事件发起
SplineOverScroller#fling方法
void fling(int start, int velocity, int min, int max, int over) {
...
mFinished = false;
mCurrVelocity = mVelocity = velocity;
mDuration = mSplineDuration = 0;
mStartTime = AnimationUtils.currentAnimationTimeMillis();
mCurrentPosition = mStart = start;
....
if (velocity != 0) {
mDuration = mSplineDuration = getSplineFlingDuration(velocity);
totalDistance = getSplineFlingDistance(velocity);
}
mSplineDistance = (int) (totalDistance * Math.signum(velocity));
mFinal = start + mSplineDistance;
... 根据最大值和最小值校正mFinal和mDuration
if (mFinal > max) {
adjustDuration(mStart, mFinal, max);
mFinal = max;
}
}
从代码可以看出,start是当前的ScrollY,velocity是速度,max是range,即ScrollY的最大值。主要目的是根据当前速度,计算出后续自动移动的距离mSplineDistance与耗时mDuration,mFinal是最终偏移值,最后根据最大偏移max,校正时间与mFinal 。
简单一句总结就是fling方法手指离开屏幕时的速度初始化了一个持续时间和可滑动距离,后续开始自动滑动并最终在该时间达到指定位置的过程。
同startScroll类似,也是触发computeScroll方法,不同点在于computeScrollOffset计算当前值的差值器算法不同。
任重而道远