onTouchEvent(二) 使用Scroller实现黏性滑动的ScrollView

本文为楼主原创,转载请表明出处:http://blog.csdn.net/suma_sun/article/details/52900476
上一篇博文介绍了onTouchEvent()手势控制相关的一些坐标概念,这篇文章结合上一篇内容加上Scroller实现一个简单的带黏性滑动的ScrollView。

onTouchEvent(二) 使用Scroller实现黏性滑动的ScrollView_第1张图片

思路

  1. 滑动的时候判断最终偏移量是不是超出了子view(ViewGroup内容)的范围,如果超出了就限制不让其超出,这样就可以做出一个滑动的ScrollView并且不会滑出界
  2. 当松手(action_up)的时候判断当前偏移量在该子view的什么位置,如果小于三分之一就滑动回该view的头部,如果超出三分之二就滑动到下个view

首先看看我们需要做些什么,自定义View要复写的方法有好几个,我们要复写的有:
1. onMeasure()
2. onLayout()
3. onTouchEvent()
4. computeScroll()

因为是ViewGroup需要计算、定位子View所以需要复写1和2两个函数。
3不用说了,要实现滑动功能必须要实现的函数。
4这是配合Scroller使用的一个函数。

首先介绍下Scroller这个类,这个类是一个工具,并不是实际UI操作。这个工具提供的功能是提供差值计算,就像属性动画一样,要做一个平滑过渡的动画需要用到差值器来辅助提供偏移差值。

下面是一个不使用Scroller的Demo。

差别很明显。

onMeasure

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        //遍历子View,测量子View
        int count = getChildCount();
        for (int i = 0; i < count; i++) {
            View view = getChildAt(i);
            measureChild(view, widthMeasureSpec, heightMeasureSpec);
        }
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }

onMeasure()没什么好说的,遍历子view,测量子view。

onLayout

 @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
//        这里的四个坐标都是以ViewGroup来参照的,而不是屏幕的原点 
//        top默认0
//        left默认0
//        right默认屏幕宽度,ViewGroup的布局mactch_parent
//        bottom默认屏幕高度,ViewGroup的布局mactch_parent
        int count = getChildCount();
        for (int i = 0; i < count; i++) {
            View view = getChildAt(i);
            //这个demo模仿垂直的ScrollView所以就没有累计宽度
            int right = view.getMeasuredWidth();
            //设置子View摆放位置
            //mMaxHeight初始化为0
            view.layout(l , mMaxHeight, right, mMaxHeight + view.getMeasuredHeight());
            mMaxHeight += view.getMeasuredHeight();
            //mHeights是一个集合,用于保存每个自view的高度,用于粘性滑动判断
            mHeights.add(mMaxHeight);
            if(right > mMaxWidth){
                mMaxWidth = right;
            }
        }
    }

要说明的都已经在注释上了。

onTouchEvent

首先提醒下getScrollY()、getScrollX()这两个方法获取的是当前的偏移量,初始状态都为0,左移累加正值,右移累加负值,上移累加正值,下移累加负值。如果看不懂其中坐标操作等请参考前一篇文章onTouchEvent(一) 你所必须知道的坐标详解。

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        float x = event.getX();
        float y = event.getY();
        int dx = 0;
        int dy = 0;
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                //动画未停止,直接停止
                if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                }
                mLastX = x;
                mLastY = y;
                break;
            case MotionEvent.ACTION_MOVE:
                if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                }
                dx = (int) (mLastX - x);
                dy = (int) (mLastY - y);
                if (dx < 0) {//右移
//              为什么不判断getScrollX() <= 0呢,因为下面的判断已经限制了,另外三个方向手势亦然
//              限制最终偏移量不能超过0(不超过子view显示ViewGroup的背景)
                    if (dx + getScrollX() < 0) {
                        dx = 0 - getScrollX();
                    }
                } else if (dx > 0) {//左移
//              左移动是正值偏移量,最大值不能超过内容的最大宽度,并且为了不超出显示容器的背景还要减去容器的宽度
//              (为什么不用屏幕宽度呢,这是一个坑,如果容器不是match_parent的话那就有问题了)                
                    int maxW = mMaxWidth - getWidth();
                    if (dx > maxW - getScrollX()) {
                        dx = maxW - getScrollX();
                    }
                }

                if (dy < 0) {//下滑
                    if (getScrollY() + dy < 0) {
                        dy = 0 - getScrollY();
                    }

                } else if (dy > 0) {//上滑
//               为什么不用屏幕宽高呢?内容只能显示在ViewGroup上!状态栏、标题栏、导航栏都会占用屏幕高度,
//               还有match_parent
                    int maxH = mMaxHeight - getHeight(); 
                    if (maxH < dy + getScrollY()) {
                        dy = maxH - getScrollY();
                    }

                }
                scrollBy(dx, dy);
                mLastX = x;
                mLastY = y;
                break;
            case MotionEvent.ACTION_UP:
 //                  mScrollY 不会小于0,为什么呢? 下滑到极限的时候是初始状态,即mScorllY=0的时候。
                if (mHeights.size() == 0) {
                    return true;
                }
                    int currY = getScrollY();
                    int i = 0;
                    for (; i < mHeights.size(); i++) {
                        if (mHeights.get(i) > currY) {//偏移量坐标 小于 集合的值 说明是前一个view
                            break;
                        }
                    }
                    View child = getChildAt(i);
                    int height = (child.getBottom() - child.getTop()) / 3;//计算3分之一的高度,小于三分之一回滚top,
//                    大于三分之二滚到下个view头部
                    if (getScrollY() > (child.getBottom() - height)) {//大于三分之二view高度
//                    不会有超出最后一个view的情况,因为move的时候已经限制了不能超出内容的
                        child = getChildAt(i + 1);
                        int viewHeight = child.getBottom() - child.getTop();
//                    如果下一个view是最后一个view 判断其高度是否小于容器高度
                        if (i + 1 == mHeights.size() - 1 && viewHeight < getHeight()) {
//                   最后一个view的bottom也就是maxHeight,其减去容器高度,
//                  就是最后满屏的Y轴坐标,减去当前偏移量获取需要的偏移量
                            dy = child.getBottom() - getHeight() - getScrollY();
                        } else {
                            dy = child.getTop() - getScrollY();
                        }
                    } else if (getScrollY() < (child.getTop() + height)) {//小于三分之一view高度
                        dy = child.getTop() - getScrollY();
                    }
                    mScroller.startScroll(getScrollX(), getScrollY(), 0, dy);
                    invalidate();

                break;

        }
        return true;
    }

该说明的都在注释里标明了,毕竟后面解释的话来回滚动很麻烦。
startScroll()还有一个重载。
public void startScroll(int startX, int startY, int dx, int dy, int duration)自己设定操作的时间,上面那个重载是用得默认值250.

computeScroll()

    /**
     * Called by a parent to request that a child update its values for mScrollX
     * and mScrollY if necessary. This will typically be done if the child is
     * animating a scroll using a {@link android.widget.Scroller Scroller}
     * object.
     */
    public void computeScroll() {
    }

computeScroll()是一个空实现的函数,父控件调用用于更新子View,操作的是mScrollX和mScrollY,也就是移动偏移量。

onTouchEvent(二) 使用Scroller实现黏性滑动的ScrollView_第2张图片

该函数在draw中被调用,故可以用来控制scroll。但其实一个空实现的函数,那怎么做才能达到我们要的效果呢?layout()重新设置布局位置,不过这个太麻烦了用起来,上一篇介绍的scrollTo()、scrollBy()是正统滑动的代码。

    @Override
    public void computeScroll() {
        super.computeScroll();
        if (mScroller.computeScrollOffset()) {//是否完成整个滑动,无调用startScroll()返回false
            scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
            //通过重绘来不断调用computeScroll()
            invalidate();
        }
    }

直接上代码就很直观了,但为什么要调用invalidate()来不断调用呢?很多文章(网上随便查)都是这么写的。
PS:其实我开始是相信大神们的,后面写着写着有自己的理解,就开始质疑了…然后注释掉跑了下,滑动是有时会黏性滚动有时不会,我才又相信大神们了囧rz。
说说我得思路,如果不对希望有人能给我指出,谢谢。
首先onTouchEvent()中调用了Scroller.startScroll()方法,该方法不涉及UI操作所以在后面调用了一次invalidate()让View去重绘,根据上面的computeScroll()的调用得知draw的时候会调用该方法,即invalidate()之后会被调用。里面通过scrollTo()来进行移动的操作,scrollTo()该方法源码中有一个函数postInvalidateOnAnimation()

public void scrollTo(int x, int y) {
        if (mScrollX != x || mScrollY != y) {
            int oldX = mScrollX;
            int oldY = mScrollY;
            mScrollX = x;
            mScrollY = y;
            invalidateParentCaches();
            onScrollChanged(mScrollX, mScrollY, oldX, oldY);
            if (!awakenScrollBars()) {
                postInvalidateOnAnimation();
            }
        }
    }
/**
     * 

Cause an invalidate to happen on the next animation time step, typically the * next display frame.

* *

This method can be invoked from outside of the UI thread * only when this View is attached to a window.

* * @see #invalidate() */
public void postInvalidateOnAnimation() { // We try only with the AttachInfo because there's no point in invalidating // if we are not attached to our window final AttachInfo attachInfo = mAttachInfo; if (attachInfo != null) { attachInfo.mViewRootImpl.dispatchInvalidateOnAnimation(this); } }

效果跟invalidate()差不多,也会重绘,但是前面有个判断。

 protected boolean awakenScrollBars() {
        return mScrollCache != null &&
                awakenScrollBars(mScrollCache.scrollBarDefaultDelayBeforeFade, true);
    }

awakenScrollBars(int startDelay, boolean invalidate)该方法里面也有可能会调用invalidate(),但也只是有可能,里面有其他判断可能会过滤掉,所以终其原因就是有不会调动invalidate()的可能行存在,所以手动调用了一次invalidate()。

既然手动调用invalidate()就会重绘,也就会调用到computeScroll(),里面再判断Scroller的差值计算是否完毕,未完毕则继续移动就这样循环直至偏移到最终坐标。

附上布局文件


<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:orientation="vertical"
              android:layout_width="match_parent"
              android:layout_height="match_parent">
    <com.suma.viewdemo.widget.MyScrollerView2
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="#656565">
        <View
            android:layout_width="1280dp"
            android:layout_height="400dp"
            android:background="#50acde"/>

        <View
            android:layout_width="720dp"
            android:layout_height="400dp"
            android:background="#cc53"/>

        <View
            android:layout_width="match_parent"
            android:layout_height="400dp"
            android:background="#88acde88"/>

        <View
            android:layout_width="match_parent"
            android:layout_height="400dp"
            android:background="#55bc88"/>
    com.suma.viewdemo.widget.MyScrollerView2>

LinearLayout>

核心代码下载

结束语

呼,终于完成这一篇了,医生让我一周一更,还是有点累的,之前的我辣么懒,不过坚持下来我就是赚到了,下面就这个主题还准备写一两篇,又要挤时间敲敲demo了…

你可能感兴趣的:(Andriod-View)