Android中实现滑动效果

预备知识

  1. Android屏幕区域划分
    我们先看一副图来了解一下Android屏幕的区域划分,如下:


    Android屏幕的区域划分

    通过上图我们可以很直观的看到Android对于屏幕的划分定义。下面我们就给出这些区域里常用区域的一些坐标或者度量方式。如下:

//获取屏幕区域的宽高等尺寸获取
DisplayMetrics metrics = new DisplayMetrics();
getWindowManager().getDefaultDisplay().getMetrics(metrics);
int widthPixels = metrics.widthPixels;
int heightPixels = metrics.heightPixels;
//应用程序App区域宽高等尺寸获取
Rect rect = new Rect();
getWindow().getDecorView().getWindowVisibleDisplayFrame(rect);
//获取状态栏高度
Rect rect= new Rect();
getWindow().getDecorView().getWindowVisibleDisplayFrame(rect);
int statusBarHeight = rectangle.top;
//View布局区域宽高等尺寸获取
Rect rect = new Rect();  
getWindow().findViewById(Window.ID_ANDROID_CONTENT).getDrawingRect(rect);

特别注意:上面这些方法最好在Activity的onWindowFocusChanged ()方法或者之后调运,因为只有这时候才是真正的显示OK。

  1. Android坐标系、View坐标系、位置的获取、距离的获取和View宽度的获取
    在Android中,将屏幕最左上角的顶点作为Android坐标系的原点,从这个点向右是X轴正方向,从这个点向下是Y轴正方向。
    在Android中,将View的左上角顶点作为View坐标系的原点,从这个点向右是X轴正方向,从这个点向下是Y轴正方向。
    下面我们就来看看在上面两种坐标系下位置的获取、距离的获取和View宽度的获取 的方法。
    1中我们分析了Android屏幕的划分,可以发现我们平时开发的重点其实都在关注View布局区域,那么下面我们就来细说一下View区域常用的位置和距离。先看下面这幅图:

    通过上图我们可以很直观的给出View一些坐标相关的方法解释,不过必须要明确的是上面这些方法必须要在layout之后才有效,如下:
View的静态坐标方法 解释
getLeft() 返回View自身左边到父布局左边的距离(返回值是mLeft)
getTop() 返回View自身顶边到父布局顶边的距离(返回值是mTop)
getRight() 返回View自身右边到父布局左边的距离(返回值是mRight)
getBottom() 返回View自身底边到父布局顶边的距离(返回值是mBottom)
getX() 返回值为getLeft()+getTranslationX(),当setTranslationX()时getLeft()不变,getX()变。
getY() 返回值为getTop()+getTranslationY(),当setTranslationY()时getTop()不变,getY()变。

同时也可以看见上图中给出了手指触摸屏幕时MotionEvent提供的一些方法解释,如下:

MotionEvent坐标方法 解释
getX() 当前触摸事件距离当前View左边的距离
getY() 当前触摸事件距离当前View顶边的距离
getRawX() 当前触摸事件距离整个屏幕左边的距离
getRawY() 当前触摸事件距离整个屏幕顶边的距离

下面我们来看看几个和上面方法紧密相关的获取View宽高的View方法。如下:

View宽高方法 解释
getWidth() layout后有效,返回值是mRight-mLeft,一般会参考measure的宽度(measure可能没用),但不是必须的。
getHeight() layout后有效,返回值是mBottom-mTop,一般会参考measure的高度(measure可能没用),但不是必须的。
getMeasuredWidth() 返回measure过程得到的mMeasuredWidth值,供layout参考,或许没用。
getMeasuredHeight() 返回measure过程得到的mMeasuredHeight值,供layout参考,或许没用。

上面解释了自定义View时各种获取宽高的一些方法,下面我们再来看看获取View可见区域和顶点坐标的一些方法,不过这些方法需要在Activity的onWindowFocusChanged ()方法之后才能使用。如下图:



下面我们就给出上面这幅图涉及的View的一些坐标方法的结果,如下所示:

View的方法 上图View1结果 上图View2结果 结论描述
getLocalVisibleRect() (0, 0, 410, 100) (0, 0, 410, 470) 获取View自身可见的坐标区域,坐标以自己的左上角为原点(0,0),另一点为可见区域右下角相对自己(0,0)点的坐标,其实View2当前height为550,可见height为470。
getGlobalVisibleRect() (30, 100, 440, 200) (30, 250, 440, 720) 获取View在屏幕绝对坐标系中的可视区域,坐标以屏幕左上角为原点(0,0),另一个点为可见区域右下角相对屏幕原点(0,0)点的坐标。
getLocationOnScreen() (30, 100) (30, 250) 坐标是相对整个屏幕而言,Y坐标为View左上角到屏幕顶部的距离。
getLocationInWindow() (30, 100) (30, 250) 如果为普通Activity则Y坐标为View左上角到屏幕顶部(此时Window与屏幕一样大);如果为对话框式的Activity则Y坐标为当前Dialog模式Activity的标题栏顶部到View左上角的距离。

通过layout方法实现滑动

我们知道,在View进行绘制时,会调用onLayout方法来设置显示的位置。同样,可以通过修改View的mLeft, mTop, mRight, mBottom四个属性来控制View的位置。实现代码如下所示:

@Override
public boolean onTouchEvent(MotionEvent event) {
    // TODO Auto-generated method stub
    int x = (int) event.getX();
    int y = (int) event.getY();
    switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN:
        // 记录触摸点坐标
        android.util.Log.i("chenyang", "onTouchEvent ACTION_DOWN x = " + x + ",  y = " + y);
        mLastX = x;
        mLastY = y;
        break;
    case MotionEvent.ACTION_MOVE:
        // 计算偏移量
        android.util.Log.i("chenyang", "onTouchEvent ACTION_HOVER_MOVE x = " + x + ",  y = " + y);
        int offsetX = x - mLastX;
        int offsetY = y - mLastY;
        // 在当前mLeft, mTop, mRight, mBottom的基础上加上偏移量
        layout(getLeft() + offsetX, getTop() + offsetY, getRight()
                + offsetX, getBottom() + offsetY);
        break;

    default:
        break;
    }
    return true;
}

通过offsetLeftAndRight()与offsetTopAndBottom实现滑动

这两个方法相当于系统提供了一个对左右、上下移动的API的封装。与上面一样,也是通过修改View的mLeft, mTop, mRight, mBottom四个属性来控制View的位置,实现代码如下:

@Override
public boolean onTouchEvent(MotionEvent event) {
    // TODO Auto-generated method stub
    int x = (int) event.getX();
    int y = (int) event.getY();
    switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN:
        // 记录触摸点坐标
        android.util.Log.i("chenyang", "onTouchEvent ACTION_DOWN x = " + x + ",  y = " + y);
        mLastX = x;
        mLastY = y;
        break;
    case MotionEvent.ACTION_MOVE:
        // 计算偏移量
        android.util.Log.i("chenyang", "onTouchEvent ACTION_HOVER_MOVE x = " + x + ",  y = " + y);
        int offsetX = x - mLastX;
        int offsetY = y - mLastY;
        offsetLeftAndRight(offsetX);
        offsetTopAndBottom(offsetY);
        break;

    default:
        break;
    }
    return true;
}

通过LayoutParams实现滑动

LayoutParams保存了一个View的布局参数,因此可以在程序中,通过改变LayoutParams来动态地修改一个View的布局参数,从而达到改变View位置的效果。我们可以很方便的在程序中使用getLayoutParams()来获取一个View的LayoutParams(注意必须在layout之后才可以获取到)。实现代码如下:

@Override
public boolean onTouchEvent(MotionEvent event) {
    // TODO Auto-generated method stub
    int x = (int) event.getX();
    int y = (int) event.getY();
    switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN:
        // 记录触摸点坐标
        android.util.Log.i("chenyang", "onTouchEvent ACTION_DOWN x = " + x + ",  y = " + y);
        mLastX = x;
        mLastY = y;
        break;
    case MotionEvent.ACTION_MOVE:
        // 计算偏移量
        android.util.Log.i("chenyang", "onTouchEvent ACTION_HOVER_MOVE x = " + x + ",  y = " + y);
        int offsetX = x - mLastX;
        int offsetY = y - mLastY;
        ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) getLayoutParams();
        layoutParams.leftMargin = getLeft() + offsetX;
        layoutParams.topMargin = getTop() + offsetY;
        setLayoutParams(layoutParams);
        break;

    default:
        break;
    }
    return true;
}

通过ViewDragHelper实现滑动

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

ViewDragHelper虽然功能强大,但其使用方法也是最复杂的。下面通过一个实例,来演示一下如何使用ViewDragHelper创建一个滑动布局,在这个例子中,准备实现类似QQ滑动侧边栏的效果,初始时显示内容界面,当用户手指滑动超过一定距离时,内容界面侧滑显示菜单界面,整个过程下图所示:


初始状态

侧滑展开菜单界面

实现代码如下所示:

package com.cytmxk.test.scroll;

import android.content.Context;
import android.support.v4.view.ViewCompat;
import android.support.v4.widget.ViewDragHelper;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.widget.FrameLayout;

/**
 * Created by chenyang on 16/6/26.
 */
public class DragViewGroup extends FrameLayout {

    private static final String TAG = DragViewGroup.class.getCanonicalName();

    private ViewDragHelper mViewDragHelper = null;
    private View mMenuView = null;
    private View mMainView = null;
    private int mMenuWidth;

    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() {
        //初始化ViewDragHelper,第一个参数是要监听的View,通常需要是一个ViewGroup,
        //即parentView;第二个参数是一个Callback回调,后面会做解释。
        mViewDragHelper = ViewDragHelper.create(this, callback);
    }

    //获取菜单布局的宽度,之后可以根据菜单布局(mMenuView)的宽度处理滑动后的效果。
    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        mMenuWidth = mMenuView.getMeasuredWidth();
        Log.d(TAG, "onSizeChanged mMenuWidth = " + mMenuWidth);
    }
    //初始化菜单布局(mMenuView)和主布局(mMainView)
    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        mMenuView = getChildAt(0);
        mMainView = getChildAt(1);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        //将触摸事件传递给ViewDragHelper,此操作必不可少
        return mViewDragHelper.shouldInterceptTouchEvent(ev);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        super.onTouchEvent(event);
        //将触摸事件传递给ViewDragHelper,此操作必不可少
        mViewDragHelper.processTouchEvent(event);
        return true;
    }

    private ViewDragHelper.Callback callback = new ViewDragHelper.Callback() {
        // 何时开始检测触摸事件,通过这个方法,我们可以指定在创建ViewDragHelper时,
       //参数parentView中的哪一个View可以被移动。
        @Override
        public boolean tryCaptureView(View child, int pointerId) {
            //如果当前触摸的child是mMainView时开始检测,并且只有mMainView可以被移动
            return mMainView == child;
        }

        // 触摸到View后回调
        @Override
        public void onViewCaptured(View capturedChild,
                                   int activePointerId) {
            super.onViewCaptured(capturedChild, activePointerId);
        }

        // 当拖拽状态改变,比如idle,dragging
        @Override
        public void onViewDragStateChanged(int state) {
            super.onViewDragStateChanged(state);
        }

        // 当位置改变的时候调用,常用与滑动时更改scale等
        @Override
        public void onViewPositionChanged(View changedView,
                                          int left, int top, int dx, int dy) {
            super.onViewPositionChanged(changedView, left, top, dx, dy);
        }

        // 处理水平滑动
        @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);
            Log.d(TAG, "onViewReleased mMainView.getLeft() = " + mMainView.getLeft() + ", mMenuWidth = " + mMenuWidth);
            //手指抬起后缓慢移动到指定位置
            if (mMainView.getLeft() < mMenuWidth) {
                //关闭菜单
                //相当于Scroller的startScroll方法
                mViewDragHelper.smoothSlideViewTo(mMainView, 0, 0);
                ViewCompat.postInvalidateOnAnimation(DragViewGroup.this);
            } else {
                //打开菜单
                mViewDragHelper.smoothSlideViewTo(mMainView, mMenuWidth, 0);
                ViewCompat.postInvalidateOnAnimation(DragViewGroup.this);
            }
        }

    };

    //由于ViewDragHelper内部是利用Scroller实现滑动的,所以利用computeScroll方法实现平滑滑动
    @Override
    public void computeScroll() {
        super.computeScroll();
        if (mViewDragHelper.continueSettling(true)) {
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }
}

通过scrollTo和scrollBy实现滑动

  1. 在一个View中,系统提供了scrollTo、scrollBy两种方式来改变一个View中初始可见内容的位置。这两个方法的区别非常好理解,与英文中To和By的区别类似,scrollTo(x, y)表示让View中初始可见内容的在水平方向偏移到点(- x, - y)(x大于零表示向左偏移,否者向右偏移; y大于零表示向上偏移,否者向右偏移),scrollBy(dx, dy)表示让View中初始可见内容的在水平方向偏移dx(dx大于零表示向左偏移,否者向右偏移),在垂直方向偏移dy(dy大于零表示向上偏移,否者向右偏移)如下是这两个方法的代码实现:
    /**
     * The offset, in pixels, by which the content of this view is scrolled
     * horizontally.
     * {@hide}
     */
    @ViewDebug.ExportedProperty(category = "scrolling")
    protected int mScrollX;
    /**
     * The offset, in pixels, by which the content of this view is scrolled
     * vertically.
     * {@hide}
     */
    @ViewDebug.ExportedProperty(category = "scrolling")
    protected int mScrollY;
    /**
     * Set the scrolled position of your view. This will cause a call to
     * {@link #onScrollChanged(int, int, int, int)} and the view will be
     * invalidated.
     * @param x the x position to scroll to
     * @param y the y position to scroll to
     */
    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();
            }
        }
    }

    /**
     * Move the scrolled position of your view. This will cause a call to
     * {@link #onScrollChanged(int, int, int, int)} and the view will be
     * invalidated.
     * @param x the amount of pixels to scroll by horizontally
     * @param y the amount of pixels to scroll by vertically
     */
    public void scrollBy(int x, int y) {
        scrollTo(mScrollX + x, mScrollY + y);
    }

有上面的代码可以得知mScrollX,mScrollY是用来保存View初始可见内容的偏移量。理解了mScrollX和mScrollY的用法,就不难理解getScrollX() 和getScrollY()。这两个函数的源码如下所示:

    /**
     * Return the scrolled left position of this view. This is the left edge of
     * the displayed part of your view. You do not need to draw any pixels
     * farther left, since those are outside of the frame of your view on
     * screen.
     *
     * @return The left edge of the displayed part of your view, in pixels.
     */
    public final int getScrollX() {
        return mScrollX;
    }

    /**
     * Return the scrolled top position of this view. This is the top edge of
     * the displayed part of your view. You do not need to draw any pixels above
     * it, since those are outside of the frame of your view on screen.
     *
     * @return The top edge of the displayed part of your view, in pixels.
     */
    public final int getScrollY() {
        return mScrollY;
    }
  1. 举例说明,如下图所示(注意,图中黄色矩形区域表示的是View,绿色虚线矩形为View中初始可见的内容。一般情况下两者的大小一致,本文为了显示方便,将虚线框画小了一点。图中的黄色区域的位置始终不变,发生偏移的是初始可见的内容。):



    scrollTo(0, 100)的效果如下图所示:



    scrollTo(100, 100)的效果图如下:

    若函数中参数为负值,则子View的移动方向将相反:


通过Scroller实现滑动

上面举例中通过scrollTo偏移View的初始可见内容是在瞬间完成的,这样的效果会让人感觉非常突兀。Google也想到了这一点,所以提供了Scroller类来模拟平滑滑动的效果。
Scroller类提供了startScroll方法来初始化一个模拟平滑滑动的过程,然后调用invalidate()方法,这个方法会导致View重绘,系统在绘制View的时候会在draw方法中调用computeScroll方法来实现模拟滑动,在computeScroll方法中通过调用Scroller的computeScrollOffset方法判断是否完成了整个滑动,同时Scroller也提供了getCurrX、getCurrY来获取当前滑动过程中 View初始可见内容 即将的偏移量,然后利用srcollTo方法实现偏移即可,然后执行invalidate方法实现循环调用computeScroll方法直到滑动结束。

  1. Scroller中相关API简介如下:
mScroller.getCurrX() //获取mScroller当前水平方向滑动过程中的位置  
mScroller.getCurrY() //获取mScroller当前竖直方向滑动过程中的位置  
mScroller.getFinalX() //获取mScroller最终停止滑动的水平位置  
mScroller.getFinalY() //获取mScroller最终停止滑动的竖直位置  
mScroller.setFinalX(int newX) //设置mScroller最终停留的水平位置,没有动画效果,直接跳到目标位置  
mScroller.setFinalY(int newY) //设置mScroller最终停留的竖直位置,没有动画效果,直接跳到目标位置  
mScroller.startScroll(int startX, int startY, int dx, int dy) //使用默认完成时间250ms  
mScroller.startScroll(int startX, int startY, int dx, int dy, int duration)  
//开始滑动,startX, startY为 View初始可见内容 开始滑动的位置(即mScrollX,mScrollY的值),dx,dy分别为水平方向和垂直方向的偏移量(dx大于零表示向左偏移,否者向右偏移;dy大于零表示向上偏移,否者向下偏移), duration为完成滚动的时间 。
mScroller.computeScrollOffset() //返回值为boolean,true说明滑动尚未完成,false说明滑动已经完成。这是一个很重要的方法,通常放在View.computeScroll()中,用来判断是否滑动是否结束。

2 举例如下:

public void moveToDest(int index) {

    /*
     * 对 index 进行判断 ,确保 是在合理的范围
     * 即  index >=0  && index <=getChildCount()-1
     */
    //确保 index>=0
    index = index >= 0 ? index : 0;
    //确保 currIndex<=getChildCount()-1
    currIndex = index <= getChildCount() - 1 ? index : getChildCount() - 1;

    if (null != mOnPagerChangeListener) {
        mOnPagerChangeListener.OnPagerChange(currIndex);
    }
    myScroller.startScroll(getScrollX(), 0, currIndex * getWidth() - getScrollX(), 0, 500);
    invalidate();
}

@Override
public void computeScroll() {
    super.computeScroll();
    if (myScroller.computeScrollOffset()) {
        scrollTo(myScroller.getCurrX(), 0);
        invalidate();
    }
}

参考文档

  1. Android应用坐标系统全面详解
  2. Android群英传

推荐阅读更多精彩内容