前言
ScrollView可以說是android里最簡單的滑動控件,但是其中也蘊含了很多的知識點。今天嘗試通過ScrollView的源碼來了解ScrollView內(nèi)部的細節(jié)。本文在介紹ScrollView時會忽略以下內(nèi)容:嵌套滑動,崩潰保存,Accessibility。
ScrollView是一種控件,繼承自 FrameLayout,他的子控件遠遠大于ScrollView本身,所以ScrollView展現(xiàn)出來的只有子控件的一部分,通過滑動的形式來呈現(xiàn)出子控件的內(nèi)容。
基本用法與功能剖析
先來回顧下ScrollView的基本用法,超級簡單。我們通常在ScrollView內(nèi)部放一個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還會再滑一段時間(這段時間內(nèi)的狀態(tài)我們稱為fling)。
第二,fling的時候,手指碰一下,就立刻停止fling
第三,ScrollView到頂部的時候,下拉有光影效果。底部同理
子窗口大小超出父窗口
我們知道,一般情況下子view都是沒有父view大的,因為measure的時候子view的大小會受到父view的制約,那什么情況下,子view會超出父view大小呢?
要想子view超出父view大小,大概有2種方式,一種是父view對子view的要求為MeasureSpec.EXACTLY,子view的size設(shè)置為某個固定值,另一種是父view對子view的要求為UNSPECIFIED,然后子view就可以隨便搞了。可以參考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不做限制,大小就可以亂來了
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的大小。
ScrollView重寫了android.widget.ScrollView#measureChildWithMargins
UNSPECIFIED
而ScrollView的child能比ScrollView本身還大,用的是第二種方法,量的時候把specMode改為UNSPECIFIED,具體代碼如下所示,關(guān)鍵看這句
final int childHeightMeasureSpec = MeasureSpec.makeSafeMeasureSpec(
MeasureSpec.getSize(parentHeightMeasureSpec), MeasureSpec.UNSPECIFIED);
直接把childHeightMeasureSpec變?yōu)榱薓easureSpec.UNSPECIFIED,此時parent傳過來的高度其實已經(jīng)毫無意義了。而子view的高度一般寫為wrap_content,就可以非常大了。
@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)
本文雖然不介紹嵌套滑動,但是嵌套滑動的相關(guān)代碼頻繁出現(xiàn)在onTouchevent里面,所以還是要簡單說下。
NestedScrolling 提供了一套父 View 和子 View 滑動交互機制。要完成這樣的交互,父 View 需要實現(xiàn) NestedScrollingParent 接口,而子 View 需要實現(xiàn) NestedScrollingChild 接口。

更多知識可以參考
http://www.jcodecraeer.com/a/anzhuokaifa/androidkaifa/2015/0822/3342.html
https://segmentfault.com/a/1190000002873657
ScrollView默認支持了嵌套滑動,既可作為父view,也可作為子view
我們在看代碼的時候暫時忽略和嵌套滑動相關(guān)的(帶nest的函數(shù)),后面我會寫篇文章專門介紹嵌套滑動
滑動觸發(fā)
首先看下,怎么觸發(fā)ScrollView的滑動呢?有2條路徑。
滑動觸發(fā)前-down事件
我們先從down事件開始看,對照android事件分發(fā)里的down的流程圖來看,ScrollView會少幾個分支。

down事件分發(fā)到ScrollView之后,會走ScrollView的dispatchTouchEvent(),然后進入onInterceptTouchEvent(),onInterceptTouchEvent里面關(guān)于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()會調(diào)用onTouchEvent,我們在看看onTouchEvent的代碼,down事件下一般都返回true。(只有g(shù)etChildCount為0,返回false)
所以ScrollView處理down事件之后,必定返回true,mFirstTouchTarget可能空,也可能非空。說的直白一點,那就是down事件傳遞到ScrollView之后,如果他的子view消費了,那ok,如果子view不消費,那ScrollView自己消費。
滑動觸發(fā)中-MOVE事件
前面說了down事件后的結(jié)果,這是滑動觸發(fā)的一個前置條件,真正觸發(fā)滑動肯定是MOVE引起的,那么MOVE如何引起滑動呢?down事件的結(jié)果是,要么ScrollView的子類消費掉,要么ScrollView消費掉。我們對照著2種情況分別分析
ScrollView親自消費down事件
此時ScrollView親自消費了down事件,那么ScrollView的mFirstTouchTarget為null,(對照android事件分發(fā)的move流程圖分析) 此時move事件進入ScrollView直接被攔截,傳遞給ScrollView的onTouchEvent。來看onTouchEvent的move
這里我們看到個變量mIsBeingDragged,這個代表的是ScrollView是否正在被拖拽,手指抬起,mIsBeingDragged就會變?yōu)閒alse,初始化的時候也為false。看L4可知如果deltaY(滑動的距離)超過mTouchSlop,那就表示觸發(fā)了ScrollView的滑動,mIsBeingDragged 置為true,mTouchSlop是一個固定閾值。然后會執(zhí)行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的方法,會觸發(fā)onOverScrolled回調(diào)。此時只是普通的滑動,所以走L18,就是調(diào)super.scrollTo,根據(jù)手指滑動的距離進行移動。非常簡單。
@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事件分發(fā)的move流程圖分析) 此時move事件進入ScrollView會執(zhí)行onInterceptTouchEvent,如果返回false就交給子view處理。如果返回true就向子view發(fā)一個cancel消息,并且把mFirstTouchTarget設(shè)置為null,這樣下次move事件來就會直接攔截并進入onTouchEvent。那什么情況下,onInterceptTouchEvent會返回true呢?下面是onInterceptTouchEvent的move部分的代碼,其實跟前面類似的,yDiff > mTouchSlop 觸發(fā)滑動
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;
滑動觸發(fā)小結(jié)
滑動觸發(fā)的地方可能是在onTouchEvent也可能在onInterceptTouchEvent內(nèi)。
觸發(fā)的原因就是手指移動的距離超過了mTouchSlop
可能是一次move超過了mTouchSlop,也可能是多次move加起來超過了mTouchSlop。
多次move是怎么樣的呢?注意,這里說的多次move是在一個cycle內(nèi)的,舉個例子比如mTouchSlop21,第一次move了10,第二次move了15,第三次move了5,會怎么樣呢?
第一次move了10,此時未達到mTouchSlop,所以不會觸發(fā)滑動
第二次move了15,此時10+15>21,所以會觸發(fā)滑動,滾多少呢?滾的距離為10+15-21=4,為啥,看下邊這段代碼,第一次觸發(fā)滾動,滾的距離要減掉一個mTouchSlop。
然后第三次滾動距離5,那ScrollView滾動5,后面的move都跟第三次一致
if (deltaY > 0) {
deltaY -= mTouchSlop;
} else {
deltaY += mTouchSlop;
}
fling(慣性滑動)
怎么實現(xiàn)手指離開之后,還能滑動一段距離呢?
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,那就會調(diào)用flingWithNestedDispatch(),實際上就是調(diào)用mScroller.fling()。mScroller.fling是一個OverScroller,OverScroller的相關(guān)知識可以參考 View的滾動與Scroller
fling的時候點擊一下,立刻停止
這是怎么做到的?總的來說,是通過onInterceptTouchEvent和onTouchEvent的配合,調(diào)用 mScroller.abortAnimation();來停止?jié)L動的。
分2種case來討論
case1 ScrollView內(nèi)部的LinearLayout的width為match_parent
此時隨便點一下就點到了LinearLayout內(nèi)部。
先來看fling時的狀態(tài),此時手指已經(jīng)抬起,endDrag()被調(diào)用,mIsBeingDragged為false。此時點擊一下,會到onInterceptTouchEvent()方法。此時在LinearLayout內(nèi)部,所以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內(nèi)部的LinearLayout的width較小,點擊到LinearLayout外部
此時inChild返回false,那么onInterceptTouchEvent返回false,不攔截。但是注意,此時點到了LinearLayout外部,那么這個down事件,沒有child去處理,所以還是交給ScrollView來處理,還是會走到onTouchEvent內(nèi),一樣會調(diào)用mScroller.abortAnimation();方法
R.attr.scrollViewStyle是什么
在構(gòu)造函數(shù)里,我們可以看到這么一段代碼,默認給ScrollView,配置了scrollViewStyle,這有什么意義呢?其實就是設(shè)置了scrollbars和fadingEdge為vertical。看下邊代碼
public ScrollView(Context context, AttributeSet attrs) {
this(context, attrs, com.android.internal.R.attr.scrollViewStyle);
}
attrs.xml內(nèi)有
<attr name="scrollViewStyle" format="reference" />
themes.xml內(nèi)有
<item name="scrollViewStyle">@style/Widget.ScrollView</item>
styles.xml內(nèi)有
<style name="Widget.ScrollView">
<item name="scrollbars">vertical</item>
<item name="fadingEdge">vertical</item>
</style>
其他
- 因為用了OverScroller,所以mScrollY可能是負值
- Scrollview到頂部的時候下拉的暈影效果,主要是用EdgeEffect實現(xiàn)
- 我們會在下篇文章從0開始寫一個ScrollView