深入了解ScrollView

作者: shawn_yy | 来源:发表于2017-06-12 21:11 被阅读103次

    做android开发也有很长一段时间类,一直没有仔细想过ScrollView是怎么实现的,如何实现滚动的,所以就去研究类一下其源码,顺便做一下笔记,望日后好查阅。俗话说好记性不如烂笔头嘛。小弟不才,哪里理解错了还望大神指教,再此先谢过。

    理论上弄清楚源码是怎么做的,我们按照这个逻辑也可以写出一个的ScrollView的,所有我也写了一个ScrollView,留作参考。这个ScrollView对于滑动到边界的处理,只做了回弹的处理。所以支持边界阻尼回弹的ScrollView。

    原理请参考:实现一个ScrollView

    项目地址:https://github.com/cyuanyang/ScrollView.git

    FillViewport

    众所周知ScrollView有一个FillViewport属性,而他的实现也很简单,下面是源码,注释是依照我的理解自己加上去的。

        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            //1.如果是false 按照父视图的测量方式测量ScrollView的子View的宽高 
            // 即使你的子View设置math_parant 也只当者wrap_content处理
            if (!mFillViewport) {
                return;
            }
    
            final int heightMode = MeasureSpec.getMode(heightMeasureSpec);
            if (heightMode == MeasureSpec.UNSPECIFIED) {
                return;
            }
    
            //2.如果设置mFillViewport=true 则会走这里开始测量子View的宽高
            if (getChildCount() > 0) {
                //3.因为ScrollView有且只有一个子View所以直接取第一个
                final View child = getChildAt(0);
                final int widthPadding;
                final int heightPadding;
                final int targetSdkVersion = getContext().getApplicationInfo().targetSdkVersion;
                //4.拿到布局参数
                final FrameLayout.LayoutParams lp = (LayoutParams) child.getLayoutParams();
                //5.计算padding
                if (targetSdkVersion >= VERSION_CODES.M) {
                    widthPadding = mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin;
                    heightPadding = mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin;
                } else {
                    widthPadding = mPaddingLeft + mPaddingRight;
                    heightPadding = mPaddingTop + mPaddingBottom;
                }
    
                //6. desiredHeight 是scrollView的高度减去上下margin剩下的高度 如果child的高度小于这个才去测量 
                // 如果大于的话已经充满里没必要再折腾一次 源码的水平还是很有质量的
                final int desiredHeight = getMeasuredHeight() - heightPadding;
                if (child.getMeasuredHeight() < desiredHeight) {
                    //7. 计算宽高 调用child的measure  完成
                    final int childWidthMeasureSpec = getChildMeasureSpec(
                            widthMeasureSpec, widthPadding, lp.width);
                    final int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(
                            desiredHeight, MeasureSpec.EXACTLY);
                    child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
                }
            }
        }
    

    这里细心的人可能会有疑问,scrollView是FrameLayout的子类,而mFillViewport=false,会调用 super.onMeasure()测量子View的宽高,这样我们也会得到一个正确的值的。事实并不是这么简单的,再FrameLayout的测量View的方法中,测量child是有一个额外条件

    if (mMeasureAllChildren || child.getVisibility() != GONE)
    

    mMeasureAllChildren再mFillViewport为false的时候就是false

    onInterceptTouchEvent

    这个方法对于ScrollView是很关键的。如果想要滑动,肯定得返回true的,但是又不能全部返回true要不子View就接受不到事件了。这个方法就是处理何时该拦截事件。还是拿关键的源码说话。如果不懂mScroller或者VelocityTracker请参考实现一个ScrollView

    case MotionEvent.ACTION_DOWN: {
                    // 1. 如果按下的位置在不在 子View上
                    final int y = (int) ev.getY();
                    if (!inChild((int) ev.getX(), (int) y)) {
                        mIsBeingDragged = false;
                        recycleVelocityTracker();
                        break;
                    }
    
                    /*
                     * 2. 记住down事件 取第一个手指
                     * Remember location of down touch.
                     * ACTION_DOWN always refers to pointer index 0.
                     */
                    mLastMotionY = y;
                    mActivePointerId = ev.getPointerId(0);
    
                    initOrResetVelocityTracker();
                    //3. 这个是计算速率的 主要用来计算手指离开后的fling的速率
                    mVelocityTracker.addMovement(ev);
                    /*
                     * If being flinged and user touches the screen, initiate drag;
                     * otherwise don't. mScroller.isFinished should be false when
                     * being flinged. We need to call computeScrollOffset() first so that
                     * isFinished() is correct.
                     *
                    */
                    //4. 下面是如何区分是点击子View还是拖动ScrollView 原因上面源码注释也很清楚
                    //如果mScroller再滚动 即认为是拖动 直接赋值true
                    mScroller.computeScrollOffset();
                    mIsBeingDragged = !mScroller.isFinished();
                    if (mIsBeingDragged && mScrollStrictSpan == null) {
                        mScrollStrictSpan = StrictMode.enterCriticalSpan("ScrollView-scroll");
                    }
                    startNestedScroll(SCROLL_AXIS_VERTICAL);
                    break;
                }
    

    这个是move事件的处理

                    //6.如果先点击没有滑动,拦截事件中为false,ScrollView中的button也能接受到事件,这是再根据滑动的距离来决定是不是需要拦截事件
                    //mTouchSlop(这个值是一个系统值,判断滑动的一个阈值)
                    final int y = (int) ev.getY(pointerIndex);
                    final int yDiff = Math.abs(y - mLastMotionY);
                    if (yDiff > mTouchSlop && (getNestedScrollAxes() & SCROLL_AXIS_VERTICAL) == 0) {
                        mIsBeingDragged = true;
                        //7.  赋值down后的y的位置
                        mLastMotionY = y;
                        //8. 初始化速率轨迹计算 主要用来计算手指离开后的fling的速率
                        initVelocityTrackerIfNotExists();
                        mVelocityTracker.addMovement(ev);
                        mNestedYOffset = 0;
                        if (mScrollStrictSpan == null) {
                            mScrollStrictSpan = StrictMode.enterCriticalSpan("ScrollView-scroll");
                        }
                        final ViewParent parent = getParent();
                        if (parent != null) {
                            parent.requestDisallowInterceptTouchEvent(true);
                        }
                    }
    

    onTouchEvent

    这个是ScrollView最关键,最关键,最关键的地方,重要的话说三遍。理解这个地方后自己就可以写出一个ScrollView了。还是拿代码说话吧

        //代码不必要每一步都懂 只需要理解关键的地方即可,毕竟android是一个系统,考虑的很多很多,我们没有必要理解每一句代码的含义
        //所以这里列举一下关键的地方
        public boolean onTouchEvent(MotionEvent ev) {
            //1. 如果没有初始化速率轨迹 初始化它,这个还是用于手指离开后计算fling的
            initVelocityTrackerIfNotExists();
    
            MotionEvent vtev = MotionEvent.obtain(ev);
    
            final int actionMasked = ev.getActionMasked();
    
            if (actionMasked == MotionEvent.ACTION_DOWN) {
                mNestedYOffset = 0;
            }
            vtev.offsetLocation(0, mNestedYOffset);
    
            switch (actionMasked) {
                case MotionEvent.ACTION_DOWN: {
                    if (getChildCount() == 0) {
                        return false;
                    }
                    //2.请求父视图不要拦截
                    if ((mIsBeingDragged = !mScroller.isFinished())) {
                        final ViewParent parent = getParent();
                        if (parent != null) {
                            parent.requestDisallowInterceptTouchEvent(true);
                        }
                    }
    
                    /*
                     * If being flinged and user touches, stop the fling. isFinished
                     * will be false if being flinged.
                     */
                    //3. 如果当前在fling 就是mScroller还没有完成就触摸了
                    //立刻放弃当前的滚动
                    if (!mScroller.isFinished()) {
                        mScroller.abortAnimation();
                        if (mFlingStrictSpan != null) {
                            mFlingStrictSpan.finish();
                            mFlingStrictSpan = null;
                        }
                    }
    
                    // Remember where the motion event started
                    //4. 记住触摸的位置 mLastMotionY 这个值在move的时候用来计算手指移动的变化量,然后用来计算需要滚动的距离
                    mLastMotionY = (int) ev.getY();
                    mActivePointerId = ev.getPointerId(0);
                    //5. 这个是处理内部滚动 可以先不用管这个
                    //涉及到Nested的都可以先不用管它  这个好像是为了支持v4包内的某个功能做的处理
                    startNestedScroll(SCROLL_AXIS_VERTICAL);
                    break;
                }
                case MotionEvent.ACTION_MOVE:
                    final int activePointerIndex = ev.findPointerIndex(mActivePointerId);
                    if (activePointerIndex == -1) {
                        Log.e(TAG, "Invalid pointerId=" + mActivePointerId + " in onTouchEvent");
                        break;
                    }
    
                    final int y = (int) ev.getY(activePointerIndex);
                    //6. deltaY 计算手指移动的距离 在4中记录的 同时下面还会更新这个值 8中会用到这个值来计算需要滚动的距离
                    int deltaY = mLastMotionY - y;
                    if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset)) {
                        deltaY -= mScrollConsumed[1];
                        vtev.offsetLocation(0, mScrollOffset[1]);
                        mNestedYOffset += mScrollOffset[1];
                    }
                    //7. 如果先点击没有滑动,拦截事件中为false,ScrollView中的button也能接受到事件,这是再根据滑动的距离来决定是不是需要拦截事件
                    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) {
                        // Scroll to follow the motion event
                        //更新mLastMotionY 这个很关键 否则根本滑不懂
                        mLastMotionY = y - mScrollOffset[1];
    
                        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);
    
                        // Calling overScrollBy will call onOverScrolled, which
                        // calls onScrollChanged if applicable.
                        //8. 调用overScrollBy方法计算滚动 这个方法就是计算一下滚动的距离然后回调给onOverScrolled()在这里调用scrollTo方法
                        // 到这里的时候 ScrollView还不会滚动,滚动的代码在onOverScrolled()中,紧接着下面会出现
                        // 这里返回true表示滑动超出了内容区域 像滑倒顶部会有阻尼的那种效果就可以用这个实现
                        // 这个是最关键的地方 关键的源码都有注释 厉害了word
                        if (overScrollBy(0, deltaY, 0, mScrollY, 0, range, 0, mOverscrollDistance, true)
                                && !hasNestedScrollingParent()) {
                            // Break our velocity if we hit a scroll barrier.
                            mVelocityTracker.clear();
                        }
    
                        //9.下面就没必要仔细去研究了 这里处理一下滑到边界出的效果
                        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();
                            }
                        }
                    }
                    break;
                case MotionEvent.ACTION_UP:
                    if (mIsBeingDragged) {
                        //10. 速率轨迹终于要大显神威了
                        // up后 8中的计算滚动就会停止,但是实际上ScrollView还会滚动一段距离
                        // 这里根据 VelocityTracker 得到手指离开这一瞬间的Velocity
                        final VelocityTracker velocityTracker = mVelocityTracker;
                        velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
                        int initialVelocity = (int) velocityTracker.getYVelocity(mActivePointerId);
    
                        //11. 速录很大 则会认为是一个fling 动作
                        // flingWithNestedDispatch()方法内部就是执行了mScroller.fling()方法
                        //else if 含义:速录很小,例如我们滑动最后停下来,然后手指离开屏幕,这时的速率可能为0,就不需要fling
                        //但是若滑动到顶部就需要回弹动画 ,直接动用 mScroller.springBack()即可
                        if ((Math.abs(initialVelocity) > mMinimumVelocity)) {
                            flingWithNestedDispatch(-initialVelocity);
                        } else if (mScroller.springBack(mScrollX, mScrollY, 0, 0, 0,
                                getScrollRange())) {
                            postInvalidateOnAnimation();
                        }
    
                        mActivePointerId = INVALID_POINTER;
                        endDrag();
                    }
                    break;
                //12. 取消事件的处理 类似于up事件 理解上面的下面的多个触摸点的处理就很简单了
                case MotionEvent.ACTION_CANCEL:
                    if (mIsBeingDragged && getChildCount() > 0) {
                        if (mScroller.springBack(mScrollX, mScrollY, 0, 0, 0, getScrollRange())) {
                            postInvalidateOnAnimation();
                        }
                        mActivePointerId = INVALID_POINTER;
                        endDrag();
                    }
                    break;
                case MotionEvent.ACTION_POINTER_DOWN: {
                    final int index = ev.getActionIndex();
                    mLastMotionY = (int) ev.getY(index);
                    mActivePointerId = ev.getPointerId(index);
                    break;
                }
                case MotionEvent.ACTION_POINTER_UP:
                    onSecondaryPointerUp(ev);
                    mLastMotionY = (int) ev.getY(ev.findPointerIndex(mActivePointerId));
                    break;
            }
    

    刚刚在8中调用overScrollBy用来计算滚动的距离然后回调给onOverScrolled来处理是否需要滚动,这里就是处理逻辑

        @Override
        protected void onOverScrolled(int scrollX, int scrollY,
                                      boolean clampedX, boolean clampedY) {
            // Treat animating scrolls differently; see #computeScroll() for why.
            //这个if是用来区别mScroller滚动调用的还是手指拖动滚动的
            //mScroller.isFinished()为true 就是手指拖动引起的滚动 直接调用super.scrollTo,这样就完成了滚动  完美
            //if代码块其实就是一个和scrollTo的代码差不多,这里并没有直接调用我也不知道为什么,看注解也没太明白,哪位大神知道麻烦告诉我一下,谢谢。
            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);
            }
            //显示滚动条 滚动条是View的方法,其实每个View都有滚动的功能的。
            awakenScrollBars();
        }
    

    到此,ScrollView就能滚动了。

    总结

    浏览源码不是为了去写一个ScrollView,而是在看完之后我们学到了啥。就像小时候学校组织看电影一样,学校单纯的只想让你看完电影就算了,一般都会让我们写一篇读后感。haha。。。

    OverScroller

    如果你要是想做一个滚动的View,这个一定能帮助你实现梦想。 自带强大的滚动技能。一般配合VelocityTracker来计算fling滚动。


    如何优雅的区分是点击还是滑动操作

    当我们做一个滑动的容器组件的时候,当我们快速的滑动的时候,并不想让down事件传递下去,但同时又不影响点击容器内的View。我们可以这么做。这是在onInterceptTouchEvent中哦!

     case MotionEvent.ACTION_DOWN:
           .....
           mScroller.computeScrollOffset();
           mIsBeingDragged = !mScroller.isFinished();
           ......
            break;
     return mIsBeingDragged;
    

    可能会坑猿的地方

    ScrollView会自动滚动到获取焦点的View上面。例如我们在ScrollView中放一个WebView,就会发现总是会滚动到WebView那里。笔者有一次用WebView来加载MathJax来渲染数学符号的时候就遇到这个坑。解决办法有很多。主要思路就是移除不必要的焦点。

    scrollBy参数是Int 会丢失小数部分

    相关文章

      网友评论

        本文标题:深入了解ScrollView

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