配色: 字号:
手势滑动之玩转onTouchEvent()与Scroller
2016-11-08 | 阅:  转:  |  分享 
  
手势滑动之玩转onTouchEvent()与Scroller

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



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



下面先来预览一下效果,源码在文章末尾。



效果预览



原理分析与知识普及



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



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



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



View#scrollTo(int,int)

让View的content滚动到相对View初始位置的(x,y)处。



View#scrollBy(int,int)

让View的content滚动到相对于View当前位置的(x,y)处。



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






android:id="@+id/content_scroll_method"

android:layout_width="match_parent"

android:layout_height="match_parent"

android:orientation="vertical"

android:gravity="center">




android:id="@+id/btn_scroll_to"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

android:text="scrollTo(int,int)"/>


android:id="@+id/btn_scroll_by"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

android:text="scrollBy(int,int)"/>





这是Java代码:



ViewGroupmContentRoot;



@Override

protectedvoidonCreate(BundlesavedInstanceState){

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

privatevoidonClick(Viewv){

intid=v.getId();

switch(id){

caseR.id.btn_scroll_to:{

mContentRoot.scrollTo(100,100);

break;

}

caseR.id.btn_scroll_by:{

mContentRoot.scrollBy(10,20);

break;

}

}

}

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



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



点击scrollBy()按钮的时候,发现Layout的content一直有在滚动,是因为无论何时,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接受到触摸事件时被调用(暂不关心事件分发),第一我们从它可以拿到DOWN、MOVE、UP、CANCEL几个关键事件,第二我们可以拿到每个DOWN等事件发生时手指在屏幕上的位置和手指在View内的位置。基于此我们可以想到做很多事,假如我们在手指DOWN时记录手指的xy,在MOVE时根据DOWN时的xy来计算手指滑动的距离,然后让View发生一个移动,在手指UP/CANCEL时让View回到最开始的位置,因此我们做了第一个效果,下面来做具体的代码分析。



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






xmlns:android="http://schemas.android.com/apk/res/android"

android:layout_width="match_parent"

android:layout_height="match_parent"

android:gravity="center">


android:layout_width="wrap_content"

android:layout_height="wrap_content"

android:text="按住我拖动试试"/>





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



//手指最后在View中的坐标。

privateintmLastX;

privateintmLastY;



//手指按下时View的相对坐标。

privateintmDownViewX;

privateintmDownViewY;



@Override

publicbooleanonTouchEvent(MotionEventevent){

//第一步,记录手指在view的坐标。

intx=(int)event.getRawX();

inty=(int)event.getRawY();

intaction=event.getAction();

switch(action){

caseMotionEvent.ACTION_DOWN:{

//记录View相对于初始位置的滚动坐标。

mDownViewX=getScrollX();

mDownViewY=getScrollY();



//更新手指此时的坐标。

mLastX=x;

mLastY=y;

returntrue;

}

caseMotionEvent.ACTION_MOVE:{

//计算手指此时的坐标和上次的坐标滑动的距离。

intdy=y-mLastY;

intdx=x-mLastX;



//更新手指此时的坐标。

mLastX=x;

mLastY=y;



//滑动相对距离。

scrollBy(-dx,-dy);

returntrue;

}

caseMotionEvent.ACTION_UP:

caseMotionEvent.ACTION_CANCEL:{

scrollTo(mDownViewX,mDownViewY);

returntrue;

}

}

returnsuper.onTouchEvent(event);

}



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



View#getScrollX()

获取View相对于它初始位置X方向的滚动量。



View#getScrollY()

获取View相对于它初始位置Y方向的滚动量。



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



MotionEvent.ACTION_DOWN

MotionEvent.ACTION_MOVE

MotionEvent.ACTION_UP

MotionEvent.ACTION_CANCEL

第一步,因为ACTION_DOWN、ACTION_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(intstartX,intstartY,intdx,intdy)



Scroller#startScroll(intstartX,intstartY,intdx,intdy,intduration)

这俩方法几乎是一样的,用来标记一个View想要从哪里移动到哪里。

startX,x方向从哪里开始移动。

startY,y方向从哪里开始移动。

dx,x方向移动多远。

dy,y方向移动多远。

duration,这个移动操作需要多少时间执行完,默认是250毫秒。



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



Scroller#computeScrollOffset()

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



要知道的是,它计算出的新位置是一个闭区间[x,y],而且会在你调用startScroll传入的时间内渐渐从你指定的intstartX和intstartY移动intdx和intdy的距离,所以我们每次调用Scroller#computeScrollOffset()后再调用View的scrollTo(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结合完善动画



privateScrollermScroller;

privateintmLastX;

privateintmLastY;



publicScrollLayout(Contextcontext){

this(context,null,0);

}



publicScrollLayout(Contextcontext,AttributeSetattrs){

this(context,attrs,0);

}



publicScrollLayout(Contextcontext,AttributeSetattrs,intdefStyleAttr){

super(context,attrs,defStyleAttr);

mScroller=newScroller(context);

}



@Override

publicbooleanonTouchEvent(MotionEventevent){

intx=(int)event.getRawX();

inty=(int)event.getRawY();



intaction=event.getAction();

switch(action){

caseMotionEvent.ACTION_DOWN:{

if(!mScroller.isFinished()){//如果上次的调用没有执行完就取消。

mScroller.abortAnimation();

}

mLastX=x;

mLastY=y;

returntrue;

}

caseMotionEvent.ACTION_MOVE:{

intdy=y-mLastY;

intdx=x-mLastX;



mLastX=x;

mLastY=y;



scrollBy(-dx,-dy);

returntrue;

}

caseMotionEvent.ACTION_UP:

caseMotionEvent.ACTION_CANCEL:{

//XY都从滑动的距离回去,最后一个参数是多少毫秒内执行完这个动作。

mScroller.startScroll(getScrollX(),getScrollY(),-getScrollX(),-getScrollY(),1000);

invalidate();

returntrue;

}

}

returnsuper.onTouchEvent(event);

}



/

这个方法在调用了invalidate()后被回调。

/

@Override

publicvoidcomputeScroll(){

if(mScroller.computeScrollOffset()){//计算新位置,并判断上一个滚动是否完成。

scrollTo(mScroller.getCurrX(),mScroller.getCurrY());

invalidate();//再次调用computeScroll。

}

}

第一步,在构造方法中初始化Scroller。

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

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

第四步,在ACTION_UP和ACTION_CANCEL时让View平滑滚动到最初位置。

根据上面Scroller的分析,这里可以调用Scroller#startScroll(startX,startY,dx,dy,duration)记录开始位置,和滑动的距离以及指定动画完成的时间。



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

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

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

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



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

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,来完成目标:



publicclassScrollPagerextendsViewGroup{

publicScrollPager(Contextcontext){

this(context,null,0);

}



publicScrollPager(Contextcontext,AttributeSetattrs){

this(context,attrs,0);

}



publicScrollPager(Contextcontext,AttributeSetattrs,intdefStyleAttr){

super(context,attrs,defStyleAttr);

}

}



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






xmlns:android="http://schemas.android.com/apk/res/android"

android:layout_width="match_parent"

android:layout_height="match_parent">




android:layout_width="match_parent"

android:layout_height="200dp"

android:gravity="center"

android:orientation="vertical">




android:layout_width="match_parent"

android:layout_height="wrap_content"

android:text="第一页"/>








android:layout_width="match_parent"

android:layout_height="200dp"

android:gravity="center"

android:orientation="vertical">




android:layout_width="match_parent"

android:layout_height="wrap_content"

android:text="第二页"/>








android:layout_width="match_parent"

android:layout_height="200dp"

android:gravity="center"

android:orientation="vertical">




android:layout_width="match_parent"

android:layout_height="wrap_content"

android:text="第三页"/>









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



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



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



@Override

protectedvoidonMeasure(intwidthMeasureSpec,intheightMeasureSpec){

super.onMeasure(widthMeasureSpec,heightMeasureSpec);

intchildCount=getChildCount();

//在Layout子view之前测量子view大小,在layout的时候才能调用getMeasuredWidth()和getMeasuwww.shanxiwang.netredHeight()。

for(inti=0;i
ViewchildView=getChildAt(i);

measureChild(childView,widthMeasureSpec,heightMeasureSpec);

}

}



@Override

protectedvoidonLayout(booleanchanged,intl,intt,intr,intb){

if(changed){

intchildCount=getChildCount();

for(inti=0;i
ViewchildView=getChildAt(i);

intchildW=childView.getMeasuredWidth();



//把所有子view放在水平方向,依次排开。

//left:0,w,2w,3w..

//top:0...

//right:w,2w,3w...

//topLh...

childView.layout(ichildW,0,childWi+childW,childView.getMeasuredHeight());

}

}

}



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



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





//手指每次移动时需要更新xy,记录上次手指所处的坐标。

privatefloatmLastX;



@Override

publicbooleanonTouchEvent(MotionEventevent){

floatx=event.getRawX();



intaction=event.getAction();

switch(action){

caseMotionEvent.ACTION_DOWN:

mLastX=x;

returntrue;

caseMotionEvent.ACTION_MOVE:

intdxMove=(int)(mLastX-x);

scrollBy(dxMove,0);

mLastX=x;

returntrue;

caseMotionEvent.ACTION_UP:

caseMotionEvent.ACTION_CANCEL:{

//松开时处理惯性滑动。

break;

}

}

returnsuper.onTouchEvent(event);

}



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



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



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



privateScrollermScroller;



//手指每次移动时需要更新xy,记录上次手指所处的坐标。

privatefloatmLastX;



publicScrollPager(Contextcontext){

this(context,null,0);

}



publicScrollPager(Contextcontext,AttributeSetattrs){

this(context,attrs,0);

}



publicScrollPager(Contextcontext,AttributeSetattrs,intdefStyleAttr){

super(context,attrs,defStyleAttr);

mScroller=newScroller(context);

}



@Override

publicbooleanonTouchEvent(MotionEventevent){

floatx=event.getRawX();



intaction=event.getAction();

switch(action){

caseMotionEvent.ACTION_DOWN:

if(!mScroller.isFinished()){//如果上次的调用没有执行完就取消。

mScroller.abortAnimation();

}

mLastX=x;

returntrue;

caseMotionEvent.ACTION_MOVE:

intdxMove=(int)(mLastX-x);

scrollBy(dxMove,0);

mLastX=x;

returntrue;

caseMotionEvent.ACTION_UP:

caseMotionEvent.ACTION_CANCEL:{

intsonIndex=(getScrollX()+getWidth()/2)/getWidth();



//如果滑动超过最后一页,就退回到最后一页。

intchildCount=getChildCount();

if(sonIndex>=childCount)

sonIndex=childCount-1;



//现在滑动的相对距离。

intdx=sonIndexgetWidth()-getScrollX();

//Y方向不变,X方向到目的地。

mScroller.startScroll(getScrollX(),0,dx,0,500);

invalidate();

break;

}

}

returnsuper.onTouchEvent(event);

}



@Override

publicvoidcomputeScroll(){

if(mScroller.computeScrollOffset()){

scrollTo(mScroller.getCurrX(),mScroller.getCurrY());

invalidate();

}

}



@Override

protectedvoidonMeasure(intwidthMeasureSpec,intheightMeasureSpec){

super.onMeasure(widthMeasureSpec,heightMeasureSpec);

intchildCount=getChildCount();

//在Layout子view之前测量子view大小,在onLayout的时候才能调用getMeasuredWidth()和getMeasuredHeight()。

for(inti=0;i
ViewchildView=getChildAt(i);

measureChild(childView,widthMeasureSpec,heightMeasureSpec);

}

}



@Override

protectedvoidonLayout(booleanchanged,intl,intt,intr,intb){

if(changed){

intchildCount=getChildCount();

for(inti=0;i
ViewchildView=getChildAt(i);

intchildW=childView.getMeasuredWidth();



//把所有子view放在水平方向,依次排开。

//left:0,w,2w,3w..

//top:0...

//right:w,2w,3w...

//topLh...

childView.layout(ichildW,0,childWi+childW,childView.getMeasuredHeight());

}

}

}



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



caseMotionEvent.ACTION_UP:

caseMotionEvent.ACTION_CANCEL:{

intsonIndex=(getScrollX()+getWidth()/2)/getWidth();



//如果滑动页面超过当前页面数,那么把屏index定为最大页面数的index。

intchildCount=getChildCount();

if(sonIndex>=childCount)

sonIndex=childCount-1;



//现在滑动的相对距离。

intdx=sonIndexgetWidth()-getScrollX();

//Y方向不变,X方向到目的地。

mScroller.startScroll(getScrollX(),0,dx,0,500);

invalidate();

break;

}



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



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



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



如果现在从0屏开始滑,滑了小半屏,此时的getScrollX()


然后intdx=sonIndexgetWidth()-getScrollX();,目标位置的距离sonIndexgetWidth()减掉已经滑动的距离getScrollX()得出的现在要滑动的相对距离。



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



献花(0)
+1
(本文系网络学习天...首藏)