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

和我一个超级要好的朋友聊起自定义view和手势滑动,正好群里好多小伙伴老是问关于onTouchEvent()与Scroller的处理,因此就正好写一篇这样的博客,但愿能够帮到须要的朋友。java

今天的效果很是很是的简单,因此只能说是入门级,重在理解其中的精髓,今天主要讲两个东西,一个是View#onTouchEvent(MotionEvent)方法,另外一个是Scroller类,通常涉及到手势操做的都离不开它俩。android

本demo源代码下载:download.csdn.net/detail/yanz…api

效果预览

弹性效果
仿ViewPager弹性翻页

原理分析与知识普及

不讲道理的说,咱们不是要作这两个才分析,而是由于分析了View#onTouchEvent(MotionEvent)Scroller才作出的这两个,因此且听我细细道来。ide

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

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

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

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

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

 

  
  
  

 

    
    
复制代码

这是java代码:spa

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)处,来看看效果吧:.net

scrooTo()与scrooBy()

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

点击scrollBy()按钮的时候,发现Layoutcontent一直有在滚动,是由于不管什么时候,content的相对位置与当前位置都是不一样的,因此它老是会去到一个新的位置,因此再次点击会一直滚动。

注意:这里咱们也发现scrollTo(int, int)scrollBy(int, int)传入的值都是正数,通过我实验得出,x传入正数则向左移动,传入负数则向右移动;y传入正数则向上移动,传入负数则向下移动,且这个xy的值是像素。这里和Android坐标系是相反的,不日我将新开一篇博客来专门讲这个问题。

咱们理解了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,并让内容居中:

 

  
  
  

 
    
  
  
  

 
复制代码

布局就是这样的,根据上面的分析咱们实现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相对于初始位置的滚动坐标。
            mDownViewX = getScrollX();
            mDownViewY = 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);
}复制代码

那么这里再来讲明两个方法:

  • View#getScrollX()
    获取View相对于它初始位置X方向的滚动量。

  • View#getScrollY()
    获取View相对于它初始位置Y方向的滚动量。

根据咱们上面的分析,这里处理了四个事件,分别是:

  1. MotionEvent.ACTION_DOWN
  2. MotionEvent.ACTION_MOVE
  3. MotionEvent.ACTION_UP
  4. MotionEvent.ACTION_CANCEL
  • 第一步,由于ACTION_DOWNACTION_MOVE中都须要记录手指当前坐标,因此一进入就记录了event.getRawX()event.getRawY()

  • 第二步ACTION_DOWN手指按下时被调用,在一次触摸中只会被调用一次,在ACTION_DOWN的时候记录了content相对于最开始滚动的坐标getScrollX()getScrollY(),在咱们咱们手指松开时它滚动了多少getScrollX()和多少getScrollY(),那么咱们就调用scrollTo(int, int)滚动多少-getScrollX()和多少-getScrollY(),这样它不就回到初始位置了吗?同时记录了手指此时的坐标,用来在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。内部逻辑是先判断startScroll()动画是否还在继续,若是没有完成则计算最新位置,计算最新位置前会对duration作判断,第一若是时间没到,则真正的计算位置,而且返回true,第二若是时间到了,把记录是否继续的boolean成员变量标记完成,并直接赋值最新位置为最终目的位置,而且返回true;若是startScroll()已经完成则直接返回false。咱们判断Scroller#computeScrollOffset()是true时说明还没完成,此时拿到Scroller#getCurrX()Scroller#getCurrY()作一个滚动,待会代码中能够看到这个逻辑。

  • 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

 

  
  
  

 

    
  
  
  

 

        
  
  
  

 

    

    
  
  
  

 

        
  
  
  

 

    

    
  
  
  

 

        
  
  
  

 

    
复制代码

布局蛮简单了,就是一个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弹性翻页
相关文章
相关标签/搜索