深入了解ScrollView

做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 会丢失小数部分
    原文作者:shawn_yy
    原文地址: https://www.jianshu.com/p/d74ae730f84f
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞