美文网首页
ScrollView

ScrollView

作者: gczxbb | 来源:发表于2018-04-14 14:51 被阅读82次

    前言

    ScrollView垂直可滑动控件,当容器中的子视图高度大于ScrollView高度时,通过滑动ScrollView展示内容。


    ScrollView继承FrameLayout。它只能有一个子视图,通常默认是LinearLayout。真正展示视图的垂直方向的区域是ScrollView高度除去其上下的padding值。即当计算出LinearLayout高度大于ScrollView高度,可滑动。

    ScrollView视图如下图所示

    ScrollView视图.png 黄色区域ScrollView,绿色区域是ScrollView可视范围。LinearLayout子视图即内容视图区域的高度大于绿色,橙色区域表示多余部分,这部分高度是可滚动Range。
    下面分析几个重要方法,事件拦截onInterceptTouchEvent,事件处理onTouchEvent,滚动控制OverScroller。结合几个特定的手势场景解析ScrollView如何实现滑动。

    1:ScrollView静止,手指触屏到ScrollView,向上滑动
    2:ScrollView静止,手指触屏到ScrollView,向上滑动,然后猛地松开甩出。
    3:ScrollView正在滚动,手指触屏到ScrollView,向上滑动。


    onInterceptTouchEvent

    负责事件拦截决策,容器类View都有这个方法,拦截返回false,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;
    }
    

    ACTION_MOVE且已经设置过mIsBeingDragged拖拽标志,拦截
    Y轴不支持滚动,不拦截,在这种情况下不管事件交给谁处理,ScrollView都不会介入事件处理。
    在事件类型处理时,初始化mIsBeingDragged,最后返回mIsBeingDragged值
    onInterceptTouchEvent#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返回false,说明ScrollView正在滚动,滚动可能是OverScroller#fing或startScroll触发,那么对应第三种手势场景。mIsBeingDragged=true,拦截。ACTION_DOWN被拦截并被成功处理(onTouchEvent返回true),后续事件就不会再往下传递了。
    ACTION_DOWN不拦截时,事件传递给LinearLayout内部子View,如果有子View接收事件(如按钮),ACTION_DOWN就被消耗掉啦,否则还得靠ScrollView#onTouchEvent处理,反正onTouchEvent总能消耗返回true(后面介绍)。

    onInterceptTouchEvent#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的父View不要拦截,所有的事件发到ScrollView处理,因为要滚动。

    onInterceptTouchEvent#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未拦截,手指离开,一直未被屏幕认为要滑动,事件可能被子View消耗,也可能在onTouchEvent中消耗,无关紧要了,反正我要离开了。mIsBeingDragged初始化false。


    onTouchEvent分析

    onTouchEvent负责事件处理,View方法,作为基类View已经提供了成功接纳事件的场景,如View可点击,返回true。
    ScrollView完全重写该方法,在方法中未触发View的onTouchEvent。导致ScrollView无法使用OnClickListener监听,貌似也用不到^^。
    除非子视图不存在,ScrollView#onTouchEvent的返回值一定是true。
    两种基本情景 ,1:被拦截的事件。2:下层未消耗的事件。
    onTouchEvent#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:正在滚动且mIsBeingDragged=true,是onInterceptTouchEvent拦截的。也有可能都是false,没滚动,下面未消耗传上来的。requestDisallowInterceptTouchEvent告诉父View,别拦我。
    2:正在滚动,停止滚动abortAnimation,最终返回true,ACTION_DOWN事件被消耗。
    onTouchEvent#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。
    onTouchEvent#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方法

    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负责。

    ScrollView#fling
    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计算当前值的差值器算法不同。


    ^^
    666

    相关文章

      网友评论

          本文标题:ScrollView

          本文链接:https://www.haomeiwen.com/subject/dbvfkftx.html