庖丁解牛之ScrollView

前言

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,我们举个例子,可以这么写


<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_第1张图片

更多知识可以参考
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_第2张图片

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)有


themes.xml内(/frameworks/base/core/res/res/values/themes.xml)有

@style/Widget.ScrollView

styles.xml内(/frameworks/base/core/res/res/values/styles.xml
)有

    

fillViewport

这是ScrollView的一个属性,可以让LinearLayout在内容过少的时候,充满ScrollView。啊,没听明白?
举个例子


<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"前后的效果图,如下所示


总结

  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

你可能感兴趣的:(android)