Android Scroll 滑动分析

作者: 一个有故事的程序员 | 来源:发表于2017-09-19 16:01 被阅读156次

    导语

    滑动算是Android比较常用的效果了,滑动的操作具有很好的用户体验性。

    主要内容

    • 滑动效果是如何产生的
    • 实现滑动的七种常用方法

    具体内容

    滑动效果是如何产生的

    滑动一个View的本质其实就是移动一个View,改变其当前所在的位置,它的原理和动画效果十分的相似,就是通过不断的改变View的坐标来实现这一效果,动态且不断的改变View的坐标,从而实现View跟随用户触摸滑动而滑动。

    但是在讲解滑动效果之前,需要先了解一下Android中窗口坐标体系和屏幕的触控事件——MotionEvent。

    Android坐标系

    在物理学上,要描述一个物体的运动,就必须选定一个参考系,所谓滑动,正是相对于参考系的运动,在Android,系统将屏幕最左上角的顶点作为Android坐标系的原点,从这个点向右是X轴正方向,从这个点向下是Y轴正方向,如下图所示。

    Android坐标系

    系统提供了getLocationOnScreen(intlocation[])来获取Android坐标中的位置,即该视图左上角Android的坐标,另外,在触摸事件中使用getRawX(),getRawY()方法来获取坐标同样是Android坐标系中的坐标。

    视图坐标系

    Android中除了上面所说的这种坐标系之外们还有一个视图坐标系,他描述了子视图在父视图的位置关系,这两个坐标系并不复杂也不矛盾,他们的作用是相辅相成的,与Android坐标系类似,视图坐标系同样的以原点向右为X正方向,以原点向下为Y方法,只不过在视图坐标系中,原点不再是Android坐标系中的屏幕左上角,而是以父视图左上角为坐标原点,如下图所示。

    视图坐标系

    在触控事件中通过getX,getY来获取的坐标就是视图坐标中的坐标。

    触控事件——MotionEvent

    触控事件MotionEvent在用户的交互中,占着举足轻重的位置,学好触控事件是掌握后续内容的基础,首先,我们来看看MotionEvent中封装的一些常量,他定义了触摸事件的不同类型。

    //单点触摸按下的动作
    public static final int ACTION_DOWN = 0;
    //单点触摸离开的动作
    public static final int ACTION_UP = 1;
    //单点触摸移动的动作
    public static final int ACTION_MOVE = 2;
    //单点触摸取消
    public static final int ACTION_CANCEL = 3;
    //单点触摸超出边界
    public static final int ACTION_OUTSIDE = 4;
    //多点触摸按下的动作
    public static final int ACTION_POINTER_DOWN = 5;
    //多点触摸离开的动作
    public static final int ACTION_POINTER_UP = 6;
    

    通常情况下,我们会在onTouchEvent(MotionEvent event)方法中通过event.getAction()来获取触摸事件的类型,并使用switch来判断,这个代码模块是固定的。

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        //获取当前输入点的X,Y坐标(视图坐标)
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                //处理输入的按下动作
                break;
            case MotionEvent.ACTION_MOVE:
                //处理输入的移动动作
                break;
            case MotionEvent.ACTION_UP:
                //处理输入的离开动作
                break;
        }
        return true;
    }
    

    在不涉及多点触控的前提下,通常可以使用以上代码来完成触摸事件的监听,不过这里只是一个代码模块,后面我们会讲具体的逻辑的。

    Android获取坐标的方法

    在Android中,系统提供了非常多的方法来获取坐标值,相对距离等,方法丰富固然好,但也给初学者带来了很多的困扰,不知道在什么情况下使用下图总结人一下一些常用的API。

    获取坐标值的各种方法

    这些方法可以分成两个类别:

    • View提供的获取坐标方法:
      • getTop():获取到的是View自身的顶部到其父布局顶部的距离。
      • getLeft():获取到的是View自身的左边到其父布局左边的距离。
      • getRight():获取到的是View自身的右边到其父布局左边的距离。
      • getBottom():获取到的是View自身的底部到其父布局顶部的距离。
    • MotionEvent提供的方法:
      • getX():获取点击事件距离控件左边的距离,即视图坐标。
      • getY():获取点击事件距离控件顶部的距离,即视图坐标。
      • getRawX:获取点击事件整个屏幕左边的距离,即绝对坐标。
      • getRawY:获取点击事件整个屏幕顶部的距离,即绝对坐标。

    实现滑动的七种方法

    当了解了Android坐标系和触控事件之后,我们再来看一看如何使用系统提供的API来实现动态的修改一个View的坐标,即滑动效果,而不管采用哪种方式,其实现的思路基本上是一样的,当触摸View的时候,系统几下View的坐标,从而获得到相对于之前坐标的偏移量,并通过偏移量来修改View的坐标,这样不断的重复就实现了滑动的过程。

    下面通过实例来看看Android中该如何实现滑动效果。定义一个View,并置于一个LinearLayout中,实现一个简单的布局,代码如下所示。

    <?xml version="1.0" encoding="utf-8"?>
    <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent">
    
        <com.lgl.scrollviewdemo.DragView
            android:layout_width="100dp"
            android:layout_height="100dp" />
    </RelativeLayout>
    

    默认的显示样子如下图所示。

    示例布局
    layout方法

    我们都知道,在View的绘制上,会调用onLayout()方法来设置显示的位置,同样可以修改View的left,top,right,bottom四个属性来控制View的坐标,与前面提供的模板代码一样,每次调用onTouchEvent()的时候,我们来获取点的坐标,这里的逻辑很清楚,代码如下所示。

     //触摸事件
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int rawX = (int) event.getRawX();
        int rawY = (int) event.getRawY();
        int lastX = 0;
        int lastY = 0;
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                //记录触摸点的坐标
                lastX = rawX;
                lastY = rawY;
                break;
            case MotionEvent.ACTION_MOVE:
                //计算偏移量
                int officeX = rawX - lastX;
                int officeY = rawY - lastY;
                //在当前的left,top,right,bottom基础上加上偏移量
                layout(getLeft() + officeX, getTop() + officeY, getRight() + officeX, getBottom() + officeY);
                //重新设置初始值
                lastX = rawX;
                lastY = rawY;
                break;
            case MotionEvent.ACTION_UP:
                //处理输入的离开动作
                break;
        }
        return true;
    }
    

    这里我们就可以移动这个View了。

    offsetLeftAndRight()与offsetTopAndBottom()

    这个方法相当于系统提供的一个对左右,上下移动的封装,当计算出偏移量的时候,只需要使用如下的代码就可以完成View的重新布局,效果和使用Layout()方法是一样的。

    //同时对左右偏移
    offsetLeftAndRight(officeX);
    //同时对上下偏移
    offsetTopAndBottom(officeY);
    
    LayoutParams

    LayoutParams保留了一个View的布局参数,因此可以在程序中,通过改变LayoutParams来动态改变一个布局的位置参数,从而改变View位置的效果,我们可以很方便的在程序中使用getLayoutParams()来获取一个View的LayoutParams,当然,在计算偏移量的方法和Layout方法中计算offset是一样的,当获取到偏移量之后,可以通过setLayoutParams来改变LayoutParams,代码如下所示。

    LinearLayout.LayoutParams layoutParams = (LinearLayout.LayoutParams) getLayoutParams();
                    layoutParams.leftMargin = getLeft()+officeX;
                    layoutParams.topMargin = getTop()+officeY;
                    setLayoutParams(layoutParams);
    

    不过这里需要注意的是,通过getLayoutParams()获取layoutParams时,需要根据View所在的跟布局的类型来设置不同的类型,比如View放在LinearLayout里那就是 LinearLayout.LayoutParams,比如在RelativeLayout里就是 RelativeLayout.LayoutParams,不然系统是无法获取到layoutParams的。

    在通过一个layoutParams来改变一个View的位置时,通常改变的是这个view的Margin属性,所以除了使用布局的layoutParams属性外,还需要 ViewGroup.MarginLayoutParams来实现这样的功能。

    ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) getLayoutParams();
                    layoutParams.leftMargin = getLeft()+officeX;
                    layoutParams.topMargin = getTop()+officeY;
                    setLayoutParams(layoutParams);
    

    我们可以发现,用ViewGroup更好,都不用去管父布局是什么。

    scrollTo与scrollBy

    在一个View当中,系统提供了scrollTo与scrollBy这两种方式来实现移动一个View的位置,这两种方法的区别也很好理解,to和by,scrollTo(x,y)表示移动到一个具体的点,scrollBy(dx,dy)表示移动的增量。

    int officeX = rawX - lastX;
    int officeY = rawY - lastY;
    scrollBy(officeX,officeY);
    

    但是,当我们拖动View的时候,你会发现View并没有移动,难道我们写错了?方法没有写错,View也的确移动了,但是他移动的并不是我们想要的东西,他只是移动了view的content,即让View的内容移动了,如果用ViewGroup使用to和by的话,那所有的子View都将移动,要是在View中使用的话,,那么移动的就是View的内容了,我们举个例子,比如TextView,content就是他的文本,ImageView,drawable就是对象。

    相信通过上面的分析,你也应该知道为什么不能再View里面使用这个两个方法来拖动这个view了,那么我们就该View所在的ViewGroup中使用scrollBy方法来移动这个view。

    ((View)getParent()).scrollBy(officeX,officeY);
    

    但是,当再次拖动View的时候,你会发现View虽然移动了,但却在乱动,并不是我们想要的跟随触摸点的移动而移动,这里需要先了解一下视图移动的一些知识,大家在理解这个问题的时候,不妨想象一下手机是一个中空的盖板,盖板下面是一个巨大的画布,也就是我们想要显示的视图,当把这个盖板盖在画布的某处时,透过中间空着的矩形,我们看见了手机屏幕上显示的视图,而画布上其他的视图,则被盖板盖住了无法看见,我们的视图和这个例子事项相似,我们没有看见视图,但并不代表它不存在,有可能只是在屏幕外面而已,当调用scrollBy的方法时,可以想象外面的盖板在移动,这么说比较抽象,我们来看一下具体的例子。

    理解scrollBy

    上图,中间的矩形相当于屏幕,即可是区域,后面的content相当于画布,代表视图,大家可以看到,只有视图的中间部分目前是可视的,其他部分都不可见,可见区域中设置一个button,他的坐标是(20.10),下面我们使用scrollBy方法来进行移动后如图。

    移动之后的可视区域

    我们会发现,虽然设置scrollBy(20.10),偏移量均为XY的正方向,但是屏幕的可视区域,Button却向反方向移动了,这就是参考系选择的不同,而产生的不同效果。

    通过上面的分析,可以发现,我们将scrollBy的参数dx,dy设置成正数,那么content将向坐标轴负方向移动,反之,则正方向。

    int officeX = rawX - lastX;
    int officeY = rawY - lastY;
    scrollBy(-officeX,-officeY);
    

    再去试验一下,大家可以发现,效果和前面的几种方法相同了,类似的,我们使用scrollTo也是可以实现的。

    Scroller

    scrollTo与scrollBy是一瞬间完成的事情,很突兀,而Scroller就可以实现平滑的效果。Scroller的原理与前面使用scrollTo与scrollBy的方法原理是一样的。代码如下所示。

    • 初始化scroller :

    首先,通过他的构造方法来创建一个scroller对象。

    //初始化mScroller
    mScroller = new Scroller(context);
    
    • 重写computeScroll,实现模拟滑动:

    下面我们需要重写computeScroll这个方法,他是使用Scroller的核心,系统在绘制View的同时,会在onDraw()方法中调用这个方法,这个方法实际上就是使用了ScrollTo()方法再结合Scroller对象,帮助获取到当前的滚动值,我们可以通过不断的瞬息移动一个小的距离来实现整体上的平滑移动效果,通常情况下,computeScroll的代码可以利用标准的写法。

    /**
     * 模拟滑动
     */
    @Override
    public void computeScroll() {
        super.computeScroll();
    
        //判断Scroller是否执行完毕
        if (mScroller.computeScrollOffset()) {
            ((View) getParent()).scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
        }
        //通过重绘来不断调用computeScroll
        invalidate();
    }
    

    Scroller类提供了computeScrollOffset()来判断是否完成了整个页面的滑动,同时也提供了getCurrX(),getCurrY()来获取当前滑动坐标,上面唯一要注意的就是invalidate()了,因为只能在computeScroll中获得模拟过程中的scrollX和scrollY坐标,但computeScroll方法是不会自动调用的,只能通过invalidate→OnDraw→computeScroll()来简介调用,所以需要这个invalidate,而当模拟过程结束的时候,computeScrollOffset返回的是false,从而结束循环。

    • startScroll开启模拟过程:

    最后,万事俱备只欠东风了,我们需要使用平滑移动事件,使用Scroller类的startScroll()方法来开启平滑过程,startScroll有两个重载的方法。

    public void startScroll(int startX, int startY, int dx, int dy, int duration) {}
    public void startScroll(int startX, int startY, int dx, int dy) {}
    

    可以看到他们的区别分别是具有指定的持续时长,而另一个每有,与在动画中的设置时间是一样的,而其他四个坐标,就是起始和偏移量,通过上述的步骤,就可以完成一个平移的效果了。

    下面我们回到实例,在构造分钟初始化Scroller对象,然后重写computeScroll方法,最后需要监听手指离开屏幕的事件,并在该事件之后调用startScroll()完成平移,所以我们在ACTION_UP中。

    case MotionEvent.ACTION_UP:
        //处理输入的离开动作
        View view = ((View)getParent());
        mScroller.startScroll(view.getScrollX(),view.getScrollY(),view.getScrollX(),view.getScrollY());
        invalidate();
        break;
    
    属性动画

    这一内容比较多,放到以后再详细记录。

    ViewDragHelper

    Google在其support库中为我们提供了一个DrawerLayout和SlidingPaneLayout两个布局来帮助开发者实现侧划效果,这两个布局,大大的方便了我们自己创建自己的滑动布局,然而,这两个强大的布局背后,却隐藏着一个鲜为人知,却功能强大的类——ViewDragHelper,通过ViewDragHelper,基本可以实现各种不同的侧滑,拖放需求,因此这个方法也是各种滑动解决方案的终极绝招。

    ViewDragHelper虽然很强大,但是使用也是本章节最复杂 的,我们需要了解ViewDragHelper的基本使用方法的基础上,通过不断的练习去掌握它,我们这里就实现一个 QQ滑动侧边栏的布局,我么来看看具体怎么实现的。

    • 初始化ViewDragHelper:

    首先,自认是初始化ViewDragHelper,ViewDragHelper通常定义在一个ViewGroup中,通过其静态方法初始化。

    mViewDragHelper = ViewDragHelper.create(this,callback);
    

    他的第一个参数是要监听的View,第二个参数是一个Callback回调,这个回调是整个业务的核心。

    • 拦截事件:

    接下来,要重写拦截事件,将事件传递给ViewDragHelper进行处理。

    //事件拦截
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        return mViewDragHelper.shouldInterceptTouchEvent(ev);
    }
    //触摸事件
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        //将触摸事件传递给ViewDragHelper
        mViewDragHelper.processTouchEvent(event);
        return true;
    }
    
    • 处理computeScroll():

    没错,使用ViewDragHelper也是需要重写computeScroll的,因为ViewDragHelper内部也是通过Scroller来实现平移的,我们可以这样使用。

    @Override
    public void computeScroll() {
        if(mViewDragHelper.continueSettling(true)) {
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }
    
    • 处理回调Cakkback:

    我们可以直接的new出来。

    //侧滑回调
    private ViewDragHelper.Callback callback = new ViewDragHelper.Callback() {
        //何时开始触摸
        @Override
        public boolean tryCaptureView(View child, int pointerId) {
            //如果当前触摸的child是mMainView开始检测
            return mMainView == child;
        }
        //处理水平滑动
        @Override
        public int clampViewPositionHorizontal(View child, int left, int dx) {
            return left;
        }
        //处理垂直滑动
        @Override
        public int clampViewPositionVertical(View child, int top, int dy) {
            return 0;
        }
        //拖动结束后调用
        @Override
        public void onViewReleased(View releasedChild, float xvel, float yvel) {
            super.onViewReleased(releasedChild, xvel, yvel);
            //手指抬起后缓慢的移动到指定位置
            if (mMainView.getLeft() < 500) {
                //关闭菜单
                mViewDragHelper.smoothSlideViewTo(mMainView, 0, 0);
                ViewCompat.postInvalidateOnAnimation(DragViewGroup.this);
            } else {
                //打开菜单
                mViewDragHelper.smoothSlideViewTo(mMainView, 300, 0);
                ViewCompat.postInvalidateOnAnimation(DragViewGroup.this);
            }
        }
    };
    
    • 定义ViewGroup的onFinishInflate()方法

    在onFinishInflate()方法中,按顺序将子View分别定义为MenuView和MainView,并在onSizeChanged()方法中获得View的宽度。如果你根据View的宽度来滑动后的效果,就可以使用这个值来判断。

    //XML加载组建后回调
    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        mMenuView = getChildAt(0);
        mMainView = getChildAt(1);
    }
    //组件大小改变时回调
    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        mWidth = mMenuView.getMeasuredWidth();
    }
    
    • 最后通过整个ViewDragHelper来实现侧滑的代码如下:
    import android.content.Context;
    import android.support.v4.view.ViewCompat;
    import android.support.v4.widget.ViewDragHelper;
    import android.util.AttributeSet;
    import android.view.MotionEvent;
    import android.view.View;
    import android.widget.FrameLayout;
    
    /**
     * QQ侧滑
     * Created by lgl on 16/3/22.
     */
    public class DragViewGroup extends FrameLayout {
    
        //侧滑类
        private ViewDragHelper mViewDragHelper;
        private View mMenuView, mMainView;
        private int mWidth;
    
        public DragViewGroup(Context context) {
            super(context);
            initView();
    
        }
    
        public DragViewGroup(Context context, AttributeSet attrs) {
            super(context, attrs);
            initView();
        }
    
        public DragViewGroup(Context context, AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
            initView();
        }
    
        //初始化数据
        private void initView() {
            mViewDragHelper = ViewDragHelper.create(this, callback);
        }
    
        //XML加载组建后回调
        @Override
        protected void onFinishInflate() {
            super.onFinishInflate();
            mMenuView = getChildAt(0);
            mMainView = getChildAt(1);
        }
    
    
        //组件大小改变时回调
        @Override
        protected void onSizeChanged(int w, int h, int oldw, int oldh) {
            super.onSizeChanged(w, h, oldw, oldh);
            mWidth = mMenuView.getMeasuredWidth();
        }
    
        //事件拦截
        @Override
        public boolean onInterceptTouchEvent(MotionEvent ev) {
    
            return mViewDragHelper.shouldInterceptTouchEvent(ev);
        }
    
        //触摸事件
        @Override
        public boolean onTouchEvent(MotionEvent event) {
            //将触摸事件传递给ViewDragHelper
    
            mViewDragHelper.processTouchEvent(event);
    
            return true;
        }
    
        //侧滑回调
        private ViewDragHelper.Callback callback = new ViewDragHelper.Callback() {
            //何时开始触摸
            @Override
            public boolean tryCaptureView(View child, int pointerId) {
                //如果当前触摸的child是mMainView开始检测
                return mMainView == child;
            }
    
            //处理水平滑动
            @Override
            public int clampViewPositionHorizontal(View child, int left, int dx) {
                return left;
            }
    
            //处理垂直滑动
            @Override
            public int clampViewPositionVertical(View child, int top, int dy) {
                return 0;
            }
    
            //拖动结束后调用
            @Override
            public void onViewReleased(View releasedChild, float xvel, float yvel) {
                super.onViewReleased(releasedChild, xvel, yvel);
                //手指抬起后缓慢的移动到指定位置
                if (mMainView.getLeft() < 500) {
                    //关闭菜单
                    mViewDragHelper.smoothSlideViewTo(mMainView, 0, 0);
                    ViewCompat.postInvalidateOnAnimation(DragViewGroup.this);
                } else {
                    //打开菜单
    //                mViewDragHelper.smoothSlideViewTo(mMainView, 300, 0);  // 侧边栏宽度为300
                    mViewDragHelper.smoothSlideViewTo(mMainView, mWidth, 0);  // 侧边栏宽度为布局定义宽度
                    ViewCompat.postInvalidateOnAnimation(DragViewGroup.this);
                }
            }
        };
    
        @Override
        public void computeScroll() {
            if (mViewDragHelper.continueSettling(true)) {
                ViewCompat.postInvalidateOnAnimation(this);
            }
        }
    }
    
    • 最后在xml布局中使用
    <?xml version="1.0" encoding="utf-8"?>
    <com.example.cc.myapplication.DragViewGroup xmlns:android="http://schemas.android.com/apk/res/android"
                    android:layout_width="match_parent"
                    android:layout_height="match_parent"
                    android:windowSoftInputMode="adjustPan|stateHidden">
    
    
        <LinearLayout
            android:layout_width="300dp"
            android:layout_height="match_parent"
            android:background="@android:color/black"
            android:orientation="horizontal">
    
            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:textSize="24sp"
                android:textColor="@android:color/white"
                android:text="侧边栏"/>
    
        </LinearLayout>
    
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:background="@android:color/darker_gray"
            android:orientation="vertical">
    
            <View
                android:layout_width="match_parent"
                android:layout_height="0dp"
                android:layout_weight="1"
                />
    
            <TextView
                android:layout_width="wrap_content"
                android:layout_height="0dp"
                android:layout_weight="1"
                android:textSize="24sp"
                android:textColor="@android:color/white"
                android:text="主界面"/>
    
        </LinearLayout>
    
    </com.example.cc.myapplication.DragViewGroup>
    

    这只是简单模拟QQ侧滑菜单这个功能。ViewDragHelper还有很多强大的功能。

    • ViewDragHelper.Callback的其它监听事件

    系统定义了大量的监听事件帮助我们处理各种事件,下面就列举几个。

    事件 作用
    onViewCaptured() 在用户触摸到View后回调。
    onViewDragStateChanged() 在拖拽状态改变时回调(idle,draggin等状态)。
    onViewPositionChanged() 在位置改变时回调(常用于滑动时更改scale进行缩放等效果)。

    总结

    • 触控事件MotionEvent和Android获取坐标。
    • 七种常用滑动方式的原理。
    • 使用ViewDragHelper制做类似QQ侧划栏。

    更多内容戳这里(整理好的各种文集)

    相关文章

      网友评论

        本文标题:Android Scroll 滑动分析

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