手势滑动之 onTouchEvent() 与 Scroller 分析

本贴最后更新于 2937 天前,其中的信息可能已经时异事殊

转自:严振杰的博客

手势滑动之玩转 onTouchEvent() 与 Scroller

昨天和我一个超级要好的朋友聊起自定义view和手势滑动,正好群里好多小伙伴总是问关于onTouchEvent()与Scroller的处理,所以就正好写一篇这样的博客,希望可以帮到需要的朋友。

今天的效果非常非常的简单,重在理解onTouchEvent()与Scroller其中的精髓,一般涉及到手势操作的都离不开它俩。

文章中的源代码:http://download.csdn.net/detail/yanzhenjie1003/9673879

效果预览

弹性效果

仿ViewPager弹性翻页


原理分析与知识普及

不讲道理的说,我们不是要做这两个才分析,而是因为分析了View#onTouchEvent(MotionEvent)Scroller才做出的这两个,所以且听我细细道来。

scrollTo(int, int)与scrollBy(int, int)

我们要发生滚动就的知道View的两个方法:View#scrollTo(int, int)View#scrollBy(int, int),这两个方法都是让View来发生滚动的,他们有什么区别呢?

  • View#scrollTo(int, int)
    Viewcontent滚动到相对View初始位置的(x, y)处。

  • View#scrollBy(int, int)
    Viewcontent滚动到相对于View当前位置的(x, y)处。

不知道你理解了木有?什么,还没理解?好那我们来一个sample,先来看看布局:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/content_scroll_method"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:gravity="center">

    <Button
        android:id="@+id/btn_scroll_to"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="scrollTo(int,int)" />
    <Button
        android:id="@+id/btn_scroll_by"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="scrollBy(int,int)" />
</LinearLayout>

这是 java 代码:

    ViewGroup mContentRoot;

    @Override
    protected void onCreate(Bundle savedInstanceState) {   
        super.onCreate(savedInstanceState);
        ...
                 
        mContentRoot = (ViewGroup) findViewById(R.id.content_scroll_method);
        findViewById(R.id.btn_scroll_to).setOnClickListener(this);
        findViewById(R.id.btn_scroll_by).setOnClickListener(this);
    }
 
    @Override 
    private void onClick(View v) {    
          int id = v.getId(); 
          switch(id){   
            case R.id.btn_scroll_to: { 
               mContentRoot.scrollTo(100, 100);   
               break;
           }      
           case R.id.btn_scroll_by: {
               mContentRoot.scrollBy(10, 20);     
               break;
           }
         }
      }

这个很好理解了,点击scrollTo()按钮的时候调用LayoutscrollTo(int, int)放,让Layoutcontent滚动到相对Layout初始位置的(100, 100)处;点击scrooBy()按钮的时候调用LayoutscrollBy(int, int)Layoutcontent滚动到相对Layout当前位置的(10, 20)处,来看看效果吧:


scrooTo()与 scrooBy()


我们发现点击scrollTo()按钮的时候,滚动了一下,然后再点就不动了,因为此时Layoutcontent已经滚动到相对于它初始位置的(100,100)处了,所以再点它还是到这里,所以再次点击就看起来不动了。

点击scrollBy()按钮的时候,发现Layoutcontent一直有在滚动,是因为无论何时,content的相对位置与当前位置都是不同的,所以它总是会去到一个新的位置,所以再次点击会一直滚动。

注意:这里我们也发现scrollTo(int, int)scrollBy(int, int)传入的值都是正数,经过我实验得出,x 传入正数则向左移动,传入负数则向右移动;y 传入正数则向上移动,传入负数则向下移动,切这个 xy 的值是像素。

我们理解了View#scrollTo(int, int)View#scrollBy(int, int)后结合View#onTouchEvent(MotionEvent)就可以做很多事了。

View#onTouchEvent(MotionEvent)

对于View#onTouchEvent(MotionEvent)方法,它是当View接受到触摸事件时被调用(暂不关心事件分发),第一我们从它可以拿到DOWNMOVEUPCANCEL几个关键事件,第二我们可以拿到每个DOWN等事件发生时手指在屏幕上的位置和手指在View内的位置。基于此我们可以想到做很多事,假如我们在手指DOWN时记录手指的 xy,在MOVE时根据DOWN时的 xy 来计算手指滑动的距离,然后让View发生一个移动,在手指UP/CANCEL时让 View 回到最开始的位置,因此我们做了第一个效果,下面来做具体的代码分析。

我们定义一个 ScrollLayout,然后继承自LinearLayout,在 xml 中引用,然后在ScrollLayout中放一个TextView,并让内容居中:

<?xml version="1.0" encoding="utf-8"?>
<com.yanzhenjie.defineview.widget.ScrollLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center">
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="按住我拖动试试" />
</com.yanzhenjie.defineview.widget.ScrollLayout>

布局就是这样的,根据上面的分析我们实现ScrollLayout的具体代码,请看:

// 手指最后在 View 中的坐标。
private int mLastX;
private int mLastY;
// 手指按下时 View 的相对坐标。
private int mDownViewX;
private int mDownViewY;
@Override
public boolean onTouchEvent(MotionEvent event) { 
   // 第一步,记录手指在 view 的坐标。
    int x = (int) event.getRawX(); 
    int y = (int) event.getRawY(); 
    int action = event.getAction(); 
    switch (action){   
       case MotionEvent.ACTION_DOWN: { 
         //记录 View 初始坐标,为了手指松开时回来。
          mDownX = getScrollX();
          mDownY = getScrollY();         
         //更新手指此时的坐标。
          mLastX = x;
          mLastY = y;         
           return true;
        }      
       case MotionEvent.ACTION_MOVE: {    
        //计算手指此时的坐标和上次的坐标滑动的距离。
          int dy = y - mLastY;
          int dx = x - mLastX;     
        //更新手指此时的坐标。
           mLastX = x;
          mLastY = y; 
        //滑动相对距离。
          scrollBy(-dx, -dy); 
           return true;
        }       
       case MotionEvent.ACTION_UP:     
       case MotionEvent.ACTION_CANCEL: {
           scrollTo(mDownViewX, mDownViewY); 
           return true;
        }
    }    
     return super.onTouchEvent(event);
}

根据我们上面的分析,这里处理了四个事件,分别是:MotionEvent.ACTION_DOWNMotionEvent.ACTION_MOVEMotionEvent.ACTION_UPMotionEvent.ACTION_CANCEL

  • 第一步,因为ACTION_DOWNACTION_MOVE中都需要记录手指当前坐标,所以一进入就记录了event.getRawX()event.getRawY()

  • 第二步ACTION_DOWN手指按下时被调用,在一次触摸中只会被调用一次,在ACTION_DOWN的时候记录了content相对于最开始滚动的位置getScrollX()getScrollY(),方便我们手指松开时调用scrollTo(int, int)回到初始位置。同时记录了手指此时的坐标,用来在ACTION_MOVE的时候计算第一次ACTION_MOVE时的移动距离。

  • 第三步ACTION_MOVE会在手指移动的时候调用,所以它会调用多次,所以每次需要计算与上次的手指坐标的滑动距离,并且更新本次的手指坐标,然后调用scrollBy(int, int)去滑动当前手指与上次手指的坐标(当前View的位置)的距离

  • 第四步ACTION_UP在手指抬起时被调用,ACTION_CANCEL在手指滑动这个View的区域时被调用,此时我们调用scrollTo(int, int)回到最初的位置。

我们来看看效果:


这里写图片描述


嗯效果已经实现了,但是我们发现和开头演示的效果有点出入,就是手指松开时View一下子就回去了而不是平滑的回到最初的位置,因此我们需要用到Scroller

Scroller

Scroller是手指滑动中比较重要的一个辅助类,可以辅助我们完成一些动画参数的计算等,下面把它的几个重要的方法做个简单解释。

  • Scroller#startScroll(int startX, int startY, int dx, int dy)

  • Scroller#startScroll(int startX, int startY, int dx, int dy, int duration)
    这俩方法几乎是一样的,用来标记一个View想要从哪里移动到哪里。
    startX,x 方向从哪里开始移动。
    startY,y 方向从哪里开始移动。
    dx,x 方向移动多远。
    dy,y 方向移动多远。
    duration,这个移动操作需要多少时间执行完,默认是 250 毫秒。

当然光这个方法是不够的,它只是标记一个位置和时间,那么怎么计算呢?

  • Scroller#computeScrollOffset()
    这个方法用来计算当前你想知道的一个新位置,Scroller会自动根据标记时的坐标、时间、当前位置计算出一个新位置,记录到内部,我们可以通过Scroller#getCurrX()Scroller#getCurrY()获取的新的位置。

    要知道的是,它计算出的新位置是一个闭区间[x, y],而且会在你调用startScroll传入的时间内渐渐从你指定的int startXint startY移动int dxint dy的距离,所以我们每次调用Scroller#computeScrollOffset()后再调用ViewscrollTo(int, int)然后传入Scroller#getCurrX()Scroller#getCurrY()就可以得到一个渐渐移动的效果。

    同时这个方法有一个返回值是boolean类型的,内部是用一个boolean来记录是否完成的,在调用Scroller#startScroll)时会把这个boolean参数置为false,因此一般是在Scroller#startScroll)后调用Scroller#computeScrollOffset()

  • Scroller#getCurrX()

  • Scroller#getCurrY()
    这两个方法就是拿到通过Scroller#computeScrollOffset()计算出的新的位置,上面也解释过了。

  • Scroller.isFinished()
    上次的动画是否完成。

  • Scroller.abortAnimation()
    取消上次的动画。

这里要强调的是Scroller.isFinished()和一般是配套使用的,一般咋ACTION_DWON的时候判断是否完成,如果没有完成咋取消动画。

基于此,我们完善上面的效果,让它平滑滚动,所以我们来完善一下。

View#onTouchEvent(MotionEvent)与 Scroller 结合完善动画

private Scroller mScroller;
private int mLastX;
private int mLastY;
public ScrollLayout(Context context) {  
   this(context, null, 0);
}
public ScrollLayout(Context context, AttributeSet attrs) { 
   this(context, attrs, 0);
}
public ScrollLayout(Context context, AttributeSet attrs, int defStyleAttr) {  
   super(context, attrs, defStyleAttr);
   mScroller = new Scroller(context);
}
@Override
public boolean onTouchEvent(MotionEvent event) {   
    int x = (int) event.getRawX();
    int y = (int) event.getRawY(); 
    int action = event.getAction();  
    switch(action){      
        case MotionEvent.ACTION_DOWN: {   
           if(!mScroller.isFinished()) {
              //如果上次的调用没有执行完就取消。
              mScroller.abortAnimation();
           }
            mLastX = x;
            mLastY = y;        
            return true;
        }       
        case MotionEvent.ACTION_MOVE: {  
            int dy = y - mLastY;       
            int dx = x - mLastX; 
            mLastX = x;
            mLastY = y; 
            scrollBy(-dx, -dy); 
            return true;
        }      
       case MotionEvent.ACTION_UP: 
       case MotionEvent.ACTION_CANCEL: {   
            //XY 都从滑动的距离回去,最后一个参数是多少毫秒内执行完这个动作。
             mScroller.startScroll(getScrollX(),getScrollY(),-getScrollX(),-getScrollY(),1000);
           invalidate();        
            return true;
        }
    }    
     return super.onTouchEvent(event);
}
 /**
 * 这个方法在调用了 invalidate()后被回调。
 */
 @Override
 public void computeScroll() { 
   if(mScroller.computeScrollOffset()){
    //计算新位置,并判断上一个滚动是否完成。
     scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
     invalidate();//再次调用 computeScroll。
  }
}
  • 第一步,在构造方法中初始化Scroller

  • 第二步,在ACTION_DOWN时去掉最开始记录的content的初始位置,下面讲为什么。并且判断Scroller的动画是否完成,没有完成则取消。

  • 第三步,在ACTION_MOVE的时候调用滚动,让View跟着手指走。

  • 第四步,在ACTION_UPACTION_CANCEL时让View平滑滚动到最初位置。
    根据上面Scroller的分析,这里可以调用Scroller#startScroll(startX, startY, dx, dy, duration)记录开始位置,和滑动的距离以及指定动画完成的时间。

    1. (startX, startY)传入当前content的相对与最开始滚动的位置(getScrollX(), getScrollY())

    2. (dx, dy)要传入要平滑滑动的距离,那么传什么呢?既然它滚动了(getScrollX(), getScrollY()),那么我们就让它滚这么多的距离回去不久行了?所以我们传入(-getScrollX(), -getScrollY())

    3. duration 滚动时间,我们传个 800 毫秒,1000 毫秒的都可以,默认是 250 毫秒。

  • 第五步,调用invalidate()/postInvalidate()刷新View,最底层View会调用一系列方法,这里我们重写其中computeScroll()方法。

    1. 我们看到invalidate()postInvalidate()invalidate()在当前线程调用,也就是主线程,这里我们使用invalidate()postInvalidate()一般在子线程需要刷新View时调用。

    2. computeScroll()方法是用来计算滚动的,我们平滑滚动时不就是要它么。

  • 第六步,根据上面Scroller的分析,在computeScroll()中此时调用Scroller.computeScrollOffset()再好不过了,计算出一个新的相对位置,然后调用scrollTo(int, int)滑动过去。

  • 第七步,在computeScroll()scrollTo(int, int)后调用invalidate()computeScroll 刷新视图,呈现出一个动画的效果。


弹性效果


View#onTouchEvent(MotionEvent)与 Scroller 再升级

View#onTouchEvent(MotionEvent)Scroller结合再升级,这一节是基于上一节的,如果你没看上一节,那么最好看完再看这个,不然非常可能看不懂。下面我们来完成文中开头的第二个效果,一个模拟ViewPager翻页且加弹性动画的效果。

上面的自定义ScrollLayout是继承LinearLayout的,下面我们新建一个ScrollPager的继承ViewGroup,来完成目标:

public class ScrollPager extends ViewGroup {   
    public ScrollPager(Context context) {    
         this(context,null,0);
    }  
    public ScrollPager(Context context,AttributeSet attrs) {  
         this(context,attrs,0);
    }  
    public ScrollPager(Context context,AttributeSet attrs,int defStyleAttr) {     
         super(context,attrs,defStyleAttr);
    }
}

然后我们把布局写好,放三个Layout,高度为100dp,宽度都为match_parent

<?xml version="1.0" encoding="utf-8"?>
<com.yanzhenjie.defineview.widget.ScrollPagerxmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"> 
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="200dp"
        android:gravity="center"
        android:orientation="vertical">

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="第一页" />

    </LinearLayout>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="200dp"
        android:gravity="center"
        android:orientation="vertical">

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="第二页" />

    </LinearLayout>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="200dp"
        android:gravity="center"
        android:orientation="vertical">

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="第三页" />

    </LinearLayout>
</com.yanzhenjie.defineview.widget.ScrollPager>

布局蛮简单了,就是一个ViewGroup中三个高度为100dp,宽度都为match_parentLinearLayout,宽度为match_parent是为了占满一屏的宽。然后每个LinearLayout中一个TextView,分别为第一页第二页第三页

分析一下,ViewPager首先要每一屏一个Layout/View,加上继承ViewGroup必须要重写ViewGroup#onLayout()ViewGroup#onLayout()是用来布局子View的,也就是在它里面决定哪个View放在哪里。

为了新建的ScrollPager中的View横向铺开,所以我们接着实现ScrollPager#onLayout(),但是要想布局子View,就得知道子View的宽高,所以先要测量宽高,因此还得重写ScrollPager#onMeasure方法测量View 大小,因此我们有了下面的代码:

@Override
protected void onMeasure(int widthMeasureSpec,int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    int childCount=getChildCount();  
   //在 Layout 子 view 之前测量子 view 大小,在 layout 的时候才能调用 getMeasuredWidth()和 getMeasuredHeight()。
    for(int i=0;i<childCount;i++){
        View childView=getChildAt(i);
        measureChild(childView,widthMeasureSpec,heightMeasureSpec);
    }
}
@Override
protected void onLayout(boolean changed,int l,int t,int r,int b){
   if(changed){     
      int childCount=getChildCount();   
       for(int i=0;i<childCount;i++){
         View childView=getChildAt(i);      
          int childW=childView.getMeasuredWidth();     
          //把所有子 view 放在水平方向,依次排开。
          //left:0,w,2w,3w..
          //top:0...
         //right:w,2w,3w...
          //topL h...
        childView.layout(i*childW,0,childW*i+childW,childView.getMeasuredHeight());
      }
    }
}

onMeasure()没神马好解释的,就是挨个测量子View的大小,如果细节不懂可以自行搜索。那么onLayout()中调用子ViewView#layout()方法把子View布局到ScrollPager上,并且依次横向排开。

然后我们把'onTouchEvent()'中的滑动处理一下:

// 手指每次移动时需要更新 xy,记录上次手指所处的坐标。
private float mLastX;
@Override
public boolean onTouchEvent(MotionEvent event){
     float x=event.getRawX();   
     int action=event.getAction();   
     switch(action){   
         case MotionEvent.ACTION_DOWN:
              mLastX = x;           
              return true;      
         case MotionEvent.ACTION_MOVE:         
              int dxMove = (int) (mLastX - x);
              scrollBy(dxMove, 0);
              mLastX = x;        
              return true;      
         case MotionEvent.ACTION_UP:    
         case MotionEvent.ACTION_CANCEL: {   
            //松开时处理惯性滑动。
             break;
        }
    }  
     return super.onTouchEvent(event);
}

这里我们只是没有处理ACTION_UPACTION_CANCEL事件,我们来运行一把看看:


简陋的 Pager 效果


哦哟,出来了,可是没有像ViewPager那样松开时自动动切换到某一页,所以我们还要处理ACTION_UPACTION_CANCEL事件。

要想有松开时平滑滑动到某一页,我们分析一下,肯定是需要Scroller的,然后还要重写View#computeScroll()方法,下面是完成的代码:

private Scroller mScroller;
// 手指每次移动时需要更新 xy,记录上次手指所处的坐标。
private float mLastX;
public ScrollPager(Context context) {
    this(context, null, 0);
}
public ScrollPager(Context context,AttributeSet attrs) {  
    this(context, attrs, 0);
}
public ScrollPager(Context context,AttributeSet attrs,int defStyleAttr){  
    super(context, attrs, defStyleAttr);
    mScroller = new Scroller(context);
}
@Override
public boolean onTouchEvent(MotionEvent event){  
      float x=event.getRawX();   
      int action=event.getAction();   
      switch(action){      
          case MotionEvent.ACTION_DOWN:         
              if(!mScroller.isFinished()) { 
              //如果上次的调用没有执行完就取消。
               mScroller.abortAnimation();
              }
                mLastX = x;        
              return true;      
          case MotionEvent.ACTION_MOVE:        
             int dxMove=(int)(mLastX-x);
             scrollBy(dxMove, 0);
             mLastX = x;       
             return true;     
          case MotionEvent.ACTION_UP:   
          case MotionEvent.ACTION_CANCEL: {       
              int sonIndex=(getScrollX()+getWidth()/2)/getWidth(); 
             //如果滑动超过最后一页,就退回到最后一页。
              int childCount=getChildCount();       
             if(sonIndex>=childCount)
               sonIndex=childCount-1;
            //现在滑动的相对距离。
            int dx =sonIndex*getWidth()-getScrollX(); 
            // Y 方向不变,X 方向到目的地。
             mScroller.startScroll(getScrollX(), 0, dx, 0, 500);
              invalidate();       
             break;
        }
    }   
       return super.onTouchEvent(event);
}
@Override
public void computeScroll() {
    if(mScroller.computeScrollOffset()){
        scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
        invalidate();
    }
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){
    super.onMeasure(widthMeasureSpec,heightMeasureSpec);
    int childCount = getChildCount(); 
    //在 Layout 子 view 之前测量子 view 大小,在 onLayout 的时候才能调用 getMeasuredWidth()和 getMeasuredHeight()。
    for (int i =0;i<childCount; i++){
        View childView = getChildAt(i);
        measureChild(childView,widthMeasureSpec,heightMeasureSpec);
    }
}
@Override
protected void onLayout(boolean changed,int l,int t,int r,int b) { 
   if(changed){   
       int childCount=getChildCount();       
     for(int i=0;i<childCount;i++){
        View childView = getChildAt(i);         
        int childW = childView.getMeasuredWidth();  
        // 把所有子 view 放在水平方向,依次排开。
       // left:  0, w, 2w, 3w..
       // top:   0...
       // right: w, 2w, 3w...
       // topL   h...
       childView.layout(i*childW,0,childW*i+childW,childView.getMeasuredHeight());
     }
    }
}

这里需要解释的只有这一段代码:

case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL: {
    int sonIndex = (getScrollX()+getWidth()/2)/getWidth();
    //如果滑动页面超过当前页面数,那么把屏 index 定为最大页面数的 index。
    int childCount =getChildCount(); 
    if (sonIndex >=childCount)
        sonIndex =childCount-1;  
    //现在滑动的相对距离。
    int dx =sonIndex*getWidth()-getScrollX();  
    //Y 方向不变,X 方向到目的地。
    mScroller.startScroll(getScrollX(), 0, dx, 0, 500);
    invalidate();   
    break;
}

当手指松开的时候怎么平滑过度到某一页呢?

  • 先来看int sonIndex = (getScrollX() + getWidth() / 2) / getWidth();,这句话的意思是拿到从最开始滑动到当前位置的距离 加上 Layout一半的Layout宽 除以Layout宽,得到的结果是在屏幕上显示的较多区域的这一屏的子View的 index。

    是什么意思呢?,举个例子来说,当前向左滑动了一屏,那么getScrollX()的距离和getWidth的宽度就是相等的,因为滑动了一屏的距离,这个时候如果直接用getScrollX()/getWidth()那么得到的结果是 1 没有问题。

    如果现在从 0 屏开始滑,滑了小半屏,此时的getScrollX() < getWidth(),那么计算出的 int 必将是 0,假如我滑了大半屏,此时计算出的结果又是 0,但是根据惯性和四舍五入,我们滑动大半屏的时候,应该跑到下一屏,所以我们在getScrollX()/getWidth()之前给getScrollX()加了getWidth()/2的距离,这样不满一屏的将会自动补满一屏。

  • 然后int dx = sonIndex * getWidth() - getScrollX();,目标位置的距离sonIndex * getWidth()减掉已经滑动的距离getScrollX()得出的现在要滑动的相对距离。

此时运行一把,我们将得到正确的效果:


仿 ViewPager 弹性翻页


  • Scroller
    2 引用 • 1 回帖
  • Android

    Android 是一种以 Linux 为基础的开放源码操作系统,主要使用于便携设备。2005 年由 Google 收购注资,并拉拢多家制造商组成开放手机联盟开发改良,逐渐扩展到到平板电脑及其他领域上。

    334 引用 • 323 回帖
  • 手势滑动
    1 引用 • 1 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...