手势滑动之玩转onTouchEvent()与Scroller
版权声明:本文转自严振杰的博客:http://blog.yanzhenjie.com
看到好的文章就忍不住转载 = =,当然还是想收藏了,可是csdn有没有收藏功能,只好自己动手收藏啦。安卓触摸手势觉得十分重要,根据手势触摸可以配合写出满意的动画,给用户一种我在与机器进行交互的感觉。当然触摸事件的分发机制也必懂啦,这个就不说啥了。不废话了。赶紧收藏干货准备过冬。
——— 西塞罗
智慧是人类创造的源泉啊,有时候就在感慨为什么人类这么强大,能创造出来智能手机操作系统。哈哈,题外话了。研究系统不现实,还是潜心研究智能系统软件吧。智慧。
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,先来看看布局:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
这是Java代码:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
这个很好理解了,点击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
,并让内容居中:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
布局就是这样的,根据上面的分析我们实现ScrollLayout
的具体代码,请看:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
那么这里再来说明两个方法:
-
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(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 startX
和int startY
移动int dx
和int dy
的距离,所以我们每次调用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结合完善动画
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 第一步,在构造方法中初始化
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
,来完成目标:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
然后我们把布局写好,放三个Layout
,高度为100dp
,宽度都为match_parent
:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
布局蛮简单了,就是一个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大小
,因此我们有了下面的代码:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
onMeasure()
没神马好解释的,就是挨个测量子View
的大小,如果细节不懂可以自行搜索。那么onLayout()
中调用子View
的View#layout()
方法把子View
布局到ScrollPager
上,并且依次横向排开。
然后我们把’onTouchEvent()’中的滑动处理一下:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
这里我们只是没有处理ACTION_UP
和ACTION_CANCEL
事件,我们来运行一把看看:
哦哟,出来了,可是没有像ViewPager
那样松开时自动动切换到某一页,所以我们还要处理ACTION_UP
和ACTION_CANCEL
事件。
要想有松开时平滑滑动到某一页,我们分析一下,肯定是需要Scroller
的,然后还要重写View#computeScroll()
方法,下面是完成的代码:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
- 79
- 80
- 81
- 82
- 83
- 84
- 85
- 86
- 87
- 88
- 89
- 90
- 91
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
- 79
- 80
- 81
- 82
- 83
- 84
- 85
- 86
- 87
- 88
- 89
- 90
- 91
这里需要解释的只有这一段代码:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
当手指松开的时候怎么平滑过度到某一页呢?
-
先来看
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()
得出的现在要滑动的相对距离。
此时运行一把,我们将得到正确的效果: