前言
ScrollView可以说是android里最简单的滑动控件,但是其中也蕴含了很多的知识点。今天尝试通过ScrollView的源码来了解ScrollView内部的细节。本文在介绍ScrollView时会忽略以下内容:嵌套滑动,崩溃保存,Accessibility。
ScrollView是一种控件,继承自 FrameLayout,他的子控件远远大于ScrollView本身,所以ScrollView展现出来的只有子控件的一部分,通过滑动的形式来呈现出子控件的内容。
基本用法与功能剖析
先来回顾下ScrollView的基本用法,超级简单。我们通常在ScrollView内部放一个LinearLayout,然后在LinearLayout放各种元素,ScrollView滚动时就可以看到这些元素。附带一句,LinearLayout的width通常是match_parent(也可以是warp_content,这里有个坑,我们暂且不管,后面会提)。
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/linear"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
</LinearLayout>
</ScrollView>
从测试的角度来看下,ScrollView的功能是怎么样的?
第一,滑动的时候有2种情况,如果滑的慢,ScrollView的滑动会随着手指的离开而停止(简单滑动);如果滑的快,在手指离开后,ScrollView还会再滑一段时间(这段时间内的状态我们称为fling)。
第二,fling的时候,手指碰一下,就立刻停止fling
第三,ScrollView到顶部的时候,下拉有光影效果。底部同理
子窗口大小超出父窗口
我们知道,一般情况下子view都是没有父view大的,因为measure的时候子view的大小会受到父view的制约,那什么情况下,子view会超出父view大小呢?
要想子view超出父view大小,大概有2种方式,一种是父view对子view的要求为MeasureSpec.EXACTLY,子view的size设置为某个固定值,另一种是父view对子view的要求为UNSPECIFIED,然后子view就可以随便搞了,此时子view的LayoutParams是MATCH_PARENT或WRAP_CONTENT是没有任何区别的。可以参考getChildMeasureSpec代码就能大概看出来。
public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
int specMode = MeasureSpec.getMode(spec);
int specSize = MeasureSpec.getSize(spec);
int size = Math.max(0, specSize - padding);
int resultSize = 0;
int resultMode = 0;
switch (specMode) {
// Parent has imposed an exact size on us
case MeasureSpec.EXACTLY:
if (childDimension >= 0) {
//此时为case1,resultSize可能大于specSize
resultSize = childDimension;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.MATCH_PARENT) {
// Child wants to be our size. So be it.
resultSize = size;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.WRAP_CONTENT) {
// Child wants to determine its own size. It can't be
// bigger than us.
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
}
break;
// Parent has imposed a maximum size on us
case MeasureSpec.AT_MOST:
if (childDimension >= 0) {
// Child wants a specific size... so be it
resultSize = childDimension;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.MATCH_PARENT) {
// Child wants to be our size, but our size is not fixed.
// Constrain child to not be bigger than us.
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
} else if (childDimension == LayoutParams.WRAP_CONTENT) {
// Child wants to determine its own size. It can't be
// bigger than us.
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
}
break;
// Parent asked to see how big we want to be
case MeasureSpec.UNSPECIFIED:
//此时为case2,parent不做限制,大小就可以乱来了,这个case下面,可以看到MATCH_PARENT和WRAP_CONTENT返回的结果是一致的。
if (childDimension >= 0) {
// Child wants a specific size... let him have it
resultSize = childDimension;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.MATCH_PARENT) {
// Child wants to be our size... find out how big it should
// be
resultSize = 0;
resultMode = MeasureSpec.UNSPECIFIED;
} else if (childDimension == LayoutParams.WRAP_CONTENT) {
// Child wants to determine its own size.... find out how
// big it should be
resultSize = 0;
resultMode = MeasureSpec.UNSPECIFIED;
}
break;
}
return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
}
EXACTLY+固定值
对于case1,我们举个例子,可以这么写
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.fish.a.MainActivity">
<TextView
android:id="@+id/aa"
android:layout_width="4000dp"
android:layout_height="wrap_content"
android:text="Hello World!" />
</LinearLayout>
此时TextView的就比parent的大,这是一种方式让子view超出了父view的大小。
UNSPECIFIED
而ScrollView的child能比ScrollView本身还大,用的是第二种方法。ScrollView重写了android.widget.ScrollView#measureChildWithMargins,量的时候把specMode改为UNSPECIFIED,具体代码如下所示,关键看这句
final int childHeightMeasureSpec = MeasureSpec.makeSafeMeasureSpec(
MeasureSpec.getSize(parentHeightMeasureSpec), MeasureSpec.UNSPECIFIED);
直接把childHeightMeasureSpec变为了MeasureSpec.UNSPECIFIED,此时parent传过来的高度其实已经毫无意义了。而子view的高度一般写为wrap_content(其实我们上面说过这里写wrap_content还是match_parent没有任何区别),就可以非常大了。
看下边关键代码measureChildWithMargins
@Override
protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed,
int parentHeightMeasureSpec, int heightUsed) {
final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin
+ widthUsed, lp.width);
final int childHeightMeasureSpec = MeasureSpec.makeSafeMeasureSpec(
MeasureSpec.getSize(parentHeightMeasureSpec), MeasureSpec.UNSPECIFIED);
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
嵌套滑动(NestedScrolling)
本文虽然不介绍嵌套滑动,但是嵌套滑动的相关代码频繁出现在onTouchevent里面,所以还是要简单说下。
NestedScrolling 提供了一套父 View 和子 View 滑动交互机制。要完成这样的交互,父 View 需要实现 NestedScrollingParent 接口,而子 View 需要实现 NestedScrollingChild 接口。
更多知识可以参考
http://www.jcodecraeer.com/a/anzhuokaifa/androidkaifa/2015/0822/3342.html
https://segmentfault.com/a/1190000002873657
ScrollView默认支持了嵌套滑动,既可作为父view,也可作为子view
我们在看代码的时候暂时忽略和嵌套滑动相关的(带nest的函数),后面我会写篇文章专门介绍嵌套滑动
滑动触发
首先看下,怎么触发ScrollView的滑动呢?有2条路径。
滑动触发前-down事件
我们先从down事件开始看,对照android事件分发里的down的流程图来看,ScrollView会少几个分支。
down事件分发到ScrollView之后,会走ScrollView的dispatchTouchEvent(),然后进入onInterceptTouchEvent(),onInterceptTouchEvent里面关于down的代码,我们看一下,此时必定返回false.分析下,如果L4的inChild为false,那么就直接break,返回mIsBeingDragged,此时必定false;如果inChild为true,那就会到L24,mIsBeingDragged必定是false,所以还是返回false。所以无论inChild是true还是false,此时onInterceptTouchEvent必定返回false,因此onInterceptTouchEvent返回true的分支就被剪掉了。
...
case MotionEvent.ACTION_DOWN: {
final int y = (int) ev.getY();
if (!inChild((int) ev.getX(), (int) y)) {
mIsBeingDragged = false;
recycleVelocityTracker();
break;
}
/*
* Remember location of down touch.
* ACTION_DOWN always refers to pointer index 0.
*/
mLastMotionY = y;
mActivePointerId = ev.getPointerId(0);
initOrResetVelocityTracker();
mVelocityTracker.addMovement(ev);
/*
* If being flinged and user touches the screen, initiate drag;
* otherwise don't. mScroller.isFinished should be false when
* being flinged.
*/
mIsBeingDragged = !mScroller.isFinished();
if (mIsBeingDragged && mScrollStrictSpan == null) {
mScrollStrictSpan = StrictMode.enterCriticalSpan("ScrollView-scroll");
}
startNestedScroll(SCROLL_AXIS_VERTICAL);
break;
}
...
return mIsBeingDragged;
图中还有一个很明显的分支被减掉了,那就是p:super.dispatchTouchEvent()返回false的分支,为什么这里不可能返回false呢?我们知道ScrollView的super.dispatchTouchEvent()会调用onTouchEvent,我们在看看onTouchEvent的代码,down事件下一般都返回true。(只有getChildCount为0,返回false)
所以ScrollView处理down事件之后,必定返回true,mFirstTouchTarget可能空,也可能非空。说的直白一点,那就是down事件传递到ScrollView之后,如果他的子view消费了,那ok,如果子view不消费,那ScrollView自己消费。
滑动触发中-MOVE事件
前面说了down事件后的结果,这是滑动触发的一个前置条件,真正触发滑动肯定是MOVE引起的,那么MOVE如何引起滑动呢?down事件的结果是,要么ScrollView的子类消费掉,要么ScrollView消费掉。我们对照着2种情况分别分析
ScrollView亲自消费down事件
此时ScrollView亲自消费了down事件,那么ScrollView的mFirstTouchTarget为null,(对照android事件分发的move流程图分析) 此时move事件进入ScrollView直接被拦截,传递给ScrollView的onTouchEvent。来看onTouchEvent的move
这里我们看到个变量mIsBeingDragged,这个代表的是ScrollView是否正在被拖拽,手指抬起,mIsBeingDragged就会变为false,初始化的时候也为false。看L4可知如果deltaY(滑动的距离)超过mTouchSlop,那就表示触发了ScrollView的滑动,mIsBeingDragged 置为true,mTouchSlop是一个固定阈值。然后会执行L17 overScrollBy进行滚动。
case MotionEvent.ACTION_MOVE:
...
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 (overScrollBy(0, deltaY, 0, mScrollY, 0, range, 0, mOverscrollDistance, true)
&& !hasNestedScrollingParent()) {
// Break our velocity if we hit a scroll barrier.
mVelocityTracker.clear();
}
overScrollBy这是View的方法,会触发onOverScrolled回调。此时只是普通的滑动,所以走L18,就是调super.scrollTo,根据手指滑动的距离进行移动。非常简单。
@Override
protected void onOverScrolled(int scrollX, int scrollY,
boolean clampedX, boolean clampedY) {
// Treat animating scrolls differently; see #computeScroll() for why.
if (!mScroller.isFinished()) {
//fling走这里
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();
}
ScrollView子类消费down事件
此时ScrollView的子view消费了down事件,那么ScrollView的mFirstTouchTarget非空,(对照android事件分发的move流程图分析) 此时move事件进入ScrollView会执行onInterceptTouchEvent,如果返回false就交给子view处理。如果返回true就向子view发一个cancel消息,并且把mFirstTouchTarget设置为null,这样下次move事件来就会直接拦截并进入onTouchEvent。那什么情况下,onInterceptTouchEvent会返回true呢?下面是onInterceptTouchEvent的move部分的代码,其实跟前面类似的,yDiff > mTouchSlop 触发滑动
case MotionEvent.ACTION_MOVE: {
/*
* mIsBeingDragged == false, otherwise the shortcut would have caught it. Check
* whether the user has moved far enough from his original down touch.
*/
/*
* Locally do absolute value. mLastMotionY is set to the y value
* of the down event.
*/
final int activePointerId = mActivePointerId;
if (activePointerId == INVALID_POINTER) {
// If we don't have a valid id, the touch down wasn't on content.
break;
}
final int pointerIndex = ev.findPointerIndex(activePointerId);
if (pointerIndex == -1) {
Log.e(TAG, "Invalid pointerId=" + activePointerId
+ " in onInterceptTouchEvent");
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;
if (mScrollStrictSpan == null) {
mScrollStrictSpan = StrictMode.enterCriticalSpan("ScrollView-scroll");
}
final ViewParent parent = getParent();
if (parent != null) {
parent.requestDisallowInterceptTouchEvent(true);
}
}
break;
}
return mIsBeingDragged;
滑动触发小结
**滑动触发的地方可能是在onTouchEvent也可能在onInterceptTouchEvent内。
触发的原因就是手指移动的距离超过了mTouchSlop
可能是一次move超过了mTouchSlop,也可能是多次move加起来超过了mTouchSlop。**
多次move是怎么样的呢?注意,这里说的多次move是在一个cycle内的,举个例子比如mTouchSlop21,第一次move了10,第二次move了15,第三次move了5,会怎么样呢?
第一次move了10,此时未达到mTouchSlop,所以不会触发滑动
第二次move了15,此时10+15>21,所以会触发滑动,滚多少呢?滚的距离为10+15-21=4,为啥,看下边这段代码,第一次触发滚动,滚的距离要减掉一个mTouchSlop。
然后第三次滚动距离5,那ScrollView滚动5,后面的move都跟第三次一致
if (deltaY > 0) {
deltaY -= mTouchSlop;
} else {
deltaY += mTouchSlop;
}
fling(惯性滑动)
怎么实现手指离开之后,还能滑动一段距离呢?
onTouchEvent里有这么段代码
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)) {
flingWithNestedDispatch(-initialVelocity);
} else if (mScroller.springBack(mScrollX, mScrollY, 0, 0, 0,
getScrollRange())) {
postInvalidateOnAnimation();
}
mActivePointerId = INVALID_POINTER;
endDrag();
}
break;
只要速度超过mMinimumVelocity,那就会调用flingWithNestedDispatch(),实际上就是调用mScroller.fling()。mScroller是一个OverScroller,OverScroller的相关知识可以参考 View的滚动与Scroller
fling的时候点击一下,立刻停止
这是怎么做到的?总的来说,是通过onInterceptTouchEvent和onTouchEvent的配合,调用 mScroller.abortAnimation();来停止滚动的。
分2种case来讨论
case1 ScrollView内部的LinearLayout的width为match_parent
此时随便点一下就点到了LinearLayout内部。
先来看fling时的状态,此时手指已经抬起,endDrag()被调用,mIsBeingDragged为false。此时点击一下,会到onInterceptTouchEvent()方法。此时在LinearLayout内部,所以inChild返回true,会走到mIsBeingDragged = !mScroller.isFinished();,因为在fling,所以mScroller.isFinished()必定false,所以mIsBeingDragged为true,那么down事件就被拦截起来了。
下一步会走到onTouchEvent里。
case MotionEvent.ACTION_DOWN: {
final int y = (int) ev.getY();
if (!inChild((int) ev.getX(), (int) y)) {
mIsBeingDragged = false;
recycleVelocityTracker();
break;
}
/*
* Remember location of down touch.
* ACTION_DOWN always refers to pointer index 0.
*/
mLastMotionY = y;
mActivePointerId = ev.getPointerId(0);
initOrResetVelocityTracker();
mVelocityTracker.addMovement(ev);
/*
* If being flinged and user touches the screen, initiate drag;
* otherwise don't. mScroller.isFinished should be false when
* being flinged.
*/
mIsBeingDragged = !mScroller.isFinished();
if (mIsBeingDragged && mScrollStrictSpan == null) {
mScrollStrictSpan = StrictMode.enterCriticalSpan("ScrollView-scroll");
}
startNestedScroll(SCROLL_AXIS_VERTICAL);
break;
}
再来看onTouchEvent如何处理down事件,有下面这段代码,如果在fling,那么立刻终止,达到目的。
/*
* If being flinged and user touches, stop the fling. isFinished
* will be false if being flinged.
*/
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
if (mFlingStrictSpan != null) {
mFlingStrictSpan.finish();
mFlingStrictSpan = null;
}
}
case2 ScrollView内部的LinearLayout的width较小,点击到LinearLayout外部
此时inChild返回false,那么onInterceptTouchEvent返回false,不拦截。但是注意,此时点到了LinearLayout外部,那么这个down事件,没有child去处理,所以还是交给ScrollView来处理,还是会走到onTouchEvent内,一样会调用mScroller.abortAnimation();方法
R.attr.scrollViewStyle是什么
在构造函数里,我们可以看到这么一段代码,默认给ScrollView,配置了scrollViewStyle,这有什么意义呢?其实就是设置了scrollbars和fadingEdge为vertical。看下边代码
public ScrollView(Context context, AttributeSet attrs) {
this(context, attrs, com.android.internal.R.attr.scrollViewStyle);
}
attrs.xml内(/frameworks/base/core/res/res/values/attrs.xml)有
<attr name="scrollViewStyle" format="reference" />
themes.xml内(/frameworks/base/core/res/res/values/themes.xml)有
<item name="scrollViewStyle">@style/Widget.ScrollView</item>
styles.xml内(/frameworks/base/core/res/res/values/styles.xml
)有
<style name="Widget.ScrollView">
<item name="scrollbars">vertical</item>
<item name="fadingEdge">vertical</item>
</style>
fillViewport
这是ScrollView的一个属性,可以让LinearLayout在内容过少的时候,充满ScrollView。啊,没听明白?
举个例子
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingBottom="@dimen/activity_vertical_margin"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:context="com.fish.a1.MainActivity"
tools:showIn="@layout/activity_main">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#ff0000">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#00ff00">
<TextView
android:padding="5dp"
android:gravity="center"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="空空如也" />
</LinearLayout>
</ScrollView>
</RelativeLayout>
这是一个很简单的ScrollView,内部一个LinearLayout,里面只放了一个TextView,此时LinearLayout的高度就是TextView的高度,为什么?此时measure LinearLayout的高度传进来的参数是UNSPECIFIED+0,那最后LinearLayout的高度就是LinearLayout里的内容的高度。而且从前文中我们还知道,此时LinearLayout的高度写为wrap_content或者match_parent的效果是一样的 ,LinearLayout的最终高度就是子view的高度和。
那这里就有个问题了,此时LinearLayout高度明显小于ScrollView的高度。那有没有办法,让LinearLayout高度等于ScrollView高度呢?对于一般的情况下,我们要达到这个目的只要match_parent就做到了,但是对于ScrollView内部的LinearLayout,match_parent失效,要想实现这种效果,就是把fillViewport设置为true。
在ScrollView里加入 android:fillViewport=”true”
就可以了,加入android:fillViewport=”true”前后的效果图,如下所示
总结
- ScrollView必然会消费掉down事件,因为他的onTouchEvent的down一般返回true,所以down事件传到ScrollView之后,要么被ScrollView消费,要么被ScrollView的子view消费
- 滑动触发的地方可能是在onTouchEvent也可能在onInterceptTouchEvent内,触发的原因就是手指移动的距离超过了mTouchSlop
可能是一次move超过了mTouchSlop,也可能是多次move加起来超过了mTouchSlop - 因为用了OverScroller,所以mScrollY可能是负值
- Scrollview到顶部的时候下拉的晕影效果,主要是用EdgeEffect实现
- 我们会在下篇文章从0开始写一个ScrollView