庖丁解牛之ScrollView

时间:2021-05-28 18:03:35

前言

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 接口。

庖丁解牛之ScrollView

更多知识可以参考
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会少几个分支。

庖丁解牛之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
庖丁解牛之ScrollView

总结

  1. ScrollView必然会消费掉down事件,因为他的onTouchEvent的down一般返回true,所以down事件传到ScrollView之后,要么被ScrollView消费,要么被ScrollView的子view消费
  2. 滑动触发的地方可能是在onTouchEvent也可能在onInterceptTouchEvent内,触发的原因就是手指移动的距离超过了mTouchSlop
    可能是一次move超过了mTouchSlop,也可能是多次move加起来超过了mTouchSlop
  3. 因为用了OverScroller,所以mScrollY可能是负值
  4. Scrollview到顶部的时候下拉的晕影效果,主要是用EdgeEffect实现
  5. 我们会在下篇文章从0开始写一个ScrollView