打造双向滑动的ScrollView

原文出处:http://blog.csdn.net/dantestones/article/details/47659411

注:这个ScrollView是自定义FrameLayout实现的,性能方面可能不如sdk中的ScrollView,但是这是一个学习自定义布局、自定义view、手势处理的实例教程。而且对于性能要求不高的场景,滚动内容不多的情况下,完全可以使用。不过这种控件的使用场景其实不多。以下是原文:

Android View的用法中事件拦截和Scroller的滑动一直是值得注意的地方,这次的双向滑动ScrollView就是利用这2个知识点来实现。

关于Scroller

Scroller是用来帮助实现滑动的辅助类,它的内部封装了关于滚动的参数比如getCurrX(),getCurrY()获取目前应该滚动的位置,通过调用scrollTo(),scrollBy()方法来进行滚动(scrollBy()方法其实也就是调用的scrollTo())。滚动的时候会启动一个循环进程定期的去调用 computeScroll()方法,我们可以在这个方法中判断滚动的状态,如何判断滚动状态呢? 调用computeScrollOffset()方法会返回true或者false,true表示滑动未完成,未完成我们就继续 scrollTo() 然后postInvalidate() 让它完成滑动。整个过程有点费解,不过没关系,下面看代码的时候大家可以明白。

开始实现

首先开始做点准备工作。

public class MyScrollView extends FrameLayout {
    //处理滑动的Scroller 这里如果是api 9以上最好使用OverScroller代替Scroller
    //Scroller的速滑效果很差
    private OverScroller mScroller;
    //判断滑动速度
    private VelocityTracker mVelocityTracker;
    //滑动的阀值
    private int mTouchSlop;
    //滑动速度
    private int mMaxVelocity, mMinVelocity;
    //滑动锁
    private boolean mDragging = false;
    //上一次移动事件的位置
    private float mLastX, mLastY;
    public MyScrollView(Context context) {
        super(context);
        init(context);
    }
    public MyScrollView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init(context);
    }
    public MyScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context);
    }
    public void init(Context context) {
        mScroller = new OverScroller(context);
        mVelocityTracker = VelocityTracker.obtain();
        //获取系统的触摸阀值
        //可以认为用户是在滑动的距离
        mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
        //滑动的最快速度
        mMaxVelocity = ViewConfiguration.get(context).getScaledMaximumFlingVelocity();
        //滑动的最慢速度
        mMinVelocity = ViewConfiguration.get(context).getScaledMinimumFlingVelocity();
    }

这里系统给我们提供了很多阀值给我们使用,比如这里的判断用户是否是滑动 ViewConfiguration.get(context).getScaledTouchSlop(),在滑动的时候有快速滑动的情况这种情况,需要使用检测类VelocityTracker来帮我们计算用户的滑动速度,同时获取系统的滑动速度阀值,来判断是否进行速滑操作。

这里还需要去重写测量方法,原因如果不复写父视图的默认方案会强制子视图和父视图一样大 也就是按父视图的方案来实现

//这里的方案是不测量保证视图尽可能按自己的大小来 如果不复写父视图的默认方案会强制子视图和父视图一样大 也就是按父视图的方案来实现
    @Override
    protected void measureChild(View child, int parentWidthMeasureSpec, int parentHeightMeasureSpec) {
        int childWidthMeasureSpec;
        int childHeightMeasureSpec;
        childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
        childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
        child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
    }
    //与上面一样
    @Override
    protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed, int parentHeightMeasureSpec, int heightUsed) {
        MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
        int childWidthMeasureSpec;
        int childHeightMeasureSpec;
        childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(lp.leftMargin + lp.rightMargin, MeasureSpec.UNSPECIFIED);
        childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(lp.topMargin + lp.bottomMargin, MeasureSpec.UNSPECIFIED);
        child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
    }

还有一种速滑的情况Scroller有方法支持就是 fling()方法

    //处理快速滑动的方法 参数是滑动速度
    public void fling(int VelocityX, int VelocityY) {
        if (getChildCount() > 0) {
            int height = getHeight() - getPaddingTop() - getPaddingBottom();
            int width = getWidth() - getPaddingLeft() - getPaddingRight();
            int bottom = getChildAt(0).getHeight();
            int right = getChildAt(0).getWidth();
            mScroller.fling(getScrollX(), getScrollY(), VelocityX, VelocityY, 0, Math.max(0, right - width), 0, Math.max(0, bottom - height));
            invalidate();
        }
    }

初期工作已经准备完成,现在需要考虑一种情况,就是滑动的位置超过了View的边界,这种情况需要自己判断解决。n是要滚动的值,my是父视图的边界大小,child是子视图的边界大小。默认是返回n,当子View比父视图还要小的时候就不需要滑动了返回0,当父视图边界加上滚动的距离超出子视图的边界的时候,就返回 
child-my 直接滑动到子视图的边界。光靠文字不好理解,直接上图

blob.png

    //辅助方法判断是否超过边界
    private int clamp(int n, int my, int child) {
        //子View小于父视图或者滑动小于0 不滑动
        if (my >= child || n < 0) {
            return 0;
        }
        //滚动超过了子View的边界,直接滑到边界
        if ((my + n) > child) {
            return child - my;
        }
        return n;
    }

现在准备工作已经完成,开始处理Scroller的滑动:

    //computeScroll会被定期调用 判断滑动状态
    @Override
    public void computeScroll() {
        //判断滑动状态 返回true表示没有完成
        //使用这个方法保证滑动动画的完成
        if (mScroller.computeScrollOffset()) {
            int oldx = getScrollX();
            int oldy = getScrollY();
            //现在滚动到的x的位置
            int x = mScroller.getCurrX();
            //现在滚总到的y位置
            int y = mScroller.getCurrY();
            if (getChildCount() > 0) {
                View child = getChildAt(0);
                x = clamp(x, getWidth() - getPaddingLeft() - getPaddingRight(), child.getWidth());
                y = clamp(y, getHeight() - getPaddingTop() - getPaddingBottom(), child.getHeight());
                if (x != oldx || y != oldy) {
                    scrollTo(x, y);
                }
            }
            //滑动完成之前一直绘制 就是保证这个方法还会进来
            postInvalidate();
        }
    }
    @Override
    public void scrollTo(int x, int y) {
        //依赖View.ScrollBy方法调用ScrollTo
        if (getChildCount() > 0) {
            View child = getChildAt(0);
            //边界检查
            x = clamp(x, getWidth() - getPaddingLeft() - getPaddingRight(), child.getWidth());
            y = clamp(y, getHeight() - getPaddingTop() - getPaddingBottom(), child.getHeight());
            //如果x== getScrollX()滚动已经完成??
            if (x != getScrollX() || y != getScrollY()) {
                super.scrollTo(x, y);
            }
        }
    }

其实整个逻辑并不复杂,只是有点困惑,系统会定期调用computeScroll()方法,我们在这之中通过computeScrollOffset()方法判断滚动状态,未完成则调用scrollTo()接着滚动,同时滚动的值都接受了边界的判断。这里还重写了scrollTo()方法添加了边界的判断。

滑动工作已经完成最后还剩下事件拦截:

    //监控传递给子视图的触摸事件 一旦进行拖拽就拦截
    //如果子视图是可交互的,允许子视图接收事件
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                //终止正在进行的滑动
                if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                }
                //还原速度追踪器
                mVelocityTracker.clear();
                mVelocityTracker.addMovement(ev);
                //保存初始触点
                mLastX = ev.getX();
                mLastY = ev.getY();
                break;
            case MotionEvent.ACTION_MOVE:
                final float x = ev.getX();
                final float y = ev.getY();
                final int DiffX = (int) Math.abs(x - mLastX);
                final int DiffY = (int) Math.abs(y - mLastY);
                //检查x或者Y方向是否达到了滑动的阀值
                if (DiffX > mTouchSlop || DiffY > mTouchSlop) {
                    mDragging = true;
                    mVelocityTracker.addMovement(ev);
                    //开始自己捕捉触摸事件
                    return true;
                }
                break;
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                mDragging = false;
                mVelocityTracker.clear();
                break;
        }
        return super.onInterceptTouchEvent(ev);
    }
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        //这里所有的事件都会交给检测器
        mVelocityTracker.addMovement(event);
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                //已经保留了初始的触点,如果这里不返回true,后续的触摸事件就不会再传递
                return true;
            case MotionEvent.ACTION_MOVE:
                final float x = event.getX();
                final float y = event.getY();
                final float DeltaX = mLastX - x;
                final float DeltaY = mLastY- y;
                //判断阀值
                if ((Math.abs(DeltaX) > mTouchSlop || Math.abs(DeltaY) > mTouchSlop) && !mDragging) {
                    mDragging = true;
                }
                if(mDragging){
                    //滚动视图 这里的scrollBy 还是去调用scrollTo 
                    //至于为什么要用scrollBy 有兴趣可以去看看源码
                    scrollBy((int)DeltaX,(int)DeltaY);
                    //更新坐标
                    mLastX = x;
                    mLastY = y;
                }
                break;
            case MotionEvent.ACTION_CANCEL:
                //终止滑动
                mDragging = false;
                if(!mScroller.isFinished()){
                    mScroller.abortAnimation();
                }
                break;
            case MotionEvent.ACTION_UP:
                mDragging = false;
                //处理快速滑动的情况
                mVelocityTracker.computeCurrentVelocity(1000,mMaxVelocity);
                final int VelocityX = (int)mVelocityTracker.getXVelocity();
                final int VelocityY = (int)mVelocityTracker.getYVelocity();
                if(Math.abs(VelocityX) > mMinVelocity || Math.abs(VelocityY) > mMinVelocity){
                    //为什么要取负值? 因为滑动的时候 正值是向上滑动 负值是向下滑动
                    fling(-VelocityX,-VelocityY);
                }
                break;
        }
        return super.onTouchEvent(event);
    }

整个代码在注释下还是很好理解的,就是通过阀值来判断是否在滑动,是否是速滑,然后调用相关方法。需要注意一些小细节,在onInterceptTouchEvent完成拦截后在onTouchEvent中需要在MotionEvent.ACTION_DOWN动作中返回true,如果不这样后续的事件就不会给你了,默认情况下return请写成return super.onTouchEvent(event); return super.onInterceptTouchEvent(ev); 除非你需要拦截返回true。如果你都修改的话会导致触摸事件出问题,因为父类还有许多方法,你不会想去重写的,最后注意一点: scrollTo(100,100) 是向上滑动100 和 向左滑动100,scrollTo(-100,-100) 才是向下滑动100,向右滑动100 跟第一映像是相反的

如何使用

到此为止MyScrollView 就已经完成了,使用起来也非常简单

public class MainActivity extends AppCompatActivity {
    int\[\] data = {R.mipmap.am,R.mipmap.fw,R.mipmap.jiansheng,R.mipmap.kaer,R.mipmap.tf};
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        MyScrollView myScrollView = new MyScrollView(this);
        int size = (int)TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,400,getResources().getDisplayMetrics());
        LinearLayout layout = new LinearLayout(this);
        LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(size,size);
        layout.setOrientation(LinearLayout.VERTICAL);
        for(int i = 0 ; i< 5; i++){
            ImageView image = new ImageView(this);
            image.setImageResource(data\[i\]);
            layout.addView(image,lp);
        }
        myScrollView.addView(layout);
        setContentView(myScrollView);
    }
}

跟系统的ScrollView非常类似也是需要一个子View但是只能有一个,这里使用的是纵向的滑动layout.setOrientation(LinearLayout.VERTICAL) 只需要把这里的方法修改一下就可以成横向的了,大家可以试一试。最后贴上代码和效果图,虚拟机上滑动不是很好操作,真机上测试效果更好

源码下载 

20150814134202880.gif