点击与滚动(滑动)

GestureDetector

       ⽤于在点击和⻓按之外,增加其它⼿势的监听,例如双击、滑动。通过在 View.onTouchEvent() ⾥调⽤ GestureDetector.onTouchEvent() ,以代理的形式来实现:

1
2
3
4
@Override
public boolean onTouchEvent(MotionEvent event) {
return gestureDetector.onTouchEvent(event);
}

GestureDetector 的默认监听器: OnGestureListener

       通过构造⽅法 GestureDetector(Context, OnGestureListener) 来配置:

1
gestureDetector = new GestureDetector(context, gestureListener);

       OnGestureListener 的⼏个回调⽅法如下,这些方法的返回值除了 onDown 有用外,其与方法的返回值(如果有)都没有用。

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
@Override
public boolean onDown(MotionEvent e) {
// 每次 ACTION_DOWN 事件出现的时候都会被调⽤,一般来说就一个作用,确认事件是不是要消费,
// 在这⾥返回 true 可以保证必然消费掉事件,这里不返回 true 后面的事件都收不到。
return true;
}

@Override
public void onShowPress(MotionEvent e) {
// ⽤户按下 100ms 不松⼿后会被调⽤,⽤于标记“可以显示按下状态了”
}

@Override
public boolean onSingleTapUp(MotionEvent e) {
// ⽤户单击时被调⽤
// 注意:如果关闭了长按状态,即 gestureDetectorCompat.setIsLongpressEnabled(false);
// 按下后抬起手指无论时间长短都会被调用
// 如果长按开启,默认开启,⻓按后松⼿不会调⽤。
// 双击的第⼆下时不会被调⽤
return false;
}

@Override
public boolean onScroll(MotionEvent downEvent, MotionEvent event, float distanceX, float distanceY) {
// ⽤户滑动时被调⽤ 类似 onMove
// 第⼀个事件是⽤户按下时的 ACTION_DOWN 事件,第⼆个事件是当前事件
// 偏移是按下时的位置 - 当前事件的位置(旧位置 - 新位置)
return false;
}

@Override
public void onLongPress(MotionEvent e) {
// ⽤户⻓按(按下 500ms 不松⼿)后会被调⽤
// 这个在 GestureDetector 中是 500ms 在 GestureDetectorCompat 中变成了 600ms
}

@Override
public boolean onFling(MotionEvent downEvent, MotionEvent event, float velocityX, float velocityY) {
// 第⼀个事件是⽤户按下时的 ACTION_DOWN 事件,第⼆个事件是当前事件 后,后面两个参数是抬手时的速度
// ⽤于滑动时迅速抬起时被调⽤,⽤于⽤户希望控件进⾏惯性滑动的场景
return false;
}

双击监听器: OnDoubleTapListener

       通过 GestureDetector.setOnDoubleTapListener(OnDoubleTapListener) 来配置:

1
gestureDetector.setOnDoubleTapListener(doubleTapListener);

       OnDoubleTapListener 的⼏个回调⽅法如下,这三个方法的返回值都没有用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Override
public boolean onSingleTapConfirmed(MotionEvent e) {
// ⽤户单击时被调⽤,和 onSingleTapUp() 的区别见 onSingleTapConfirmed() 与 onSingleTapUp() 的区别一节
return false;
}

@Override
public boolean onDoubleTap(MotionEvent e) {
// ⽤户双击时被调⽤,两次间隔不到300ms
// 注意:第⼆次触摸到屏幕时就调⽤,⽽不是抬起时
// 如果手指连续点击屏幕四次,该方法会触发两次
// 当两次按下间隔低于40ms也不会触发双击(比如用户手指抖动)
return false;
}

@Override
public boolean onDoubleTapEvent(MotionEvent e) {
// ⽤户双击第⼆次按下时、第⼆次按下后移动时、第⼆次按下后抬起时都会被调⽤
// 常⽤于“双击拖拽”的场景
return false;
}

监听器的简写说明

       注意到如果要实例化一个 GestureDetectorCompat 需要传入一个默认的监听器 OnGestureListener 并且实现它提供的好多方法,即使我们只需要实现双击功能,在增加 OnDoubleTapListener 时,也需要实现前者,类似如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
gestureDetectorCompat = new GestureDetectorCompat(context, new GestureDetector.OnGestureListener() {
@Override
public boolean onDown(MotionEvent e) {
...
//省略了 onShowPress、onSingleTapUp、onScroll、onLongPress、onFling 等几个方法
});
gestureDetectorCompat.setOnDoubleTapListener(new GestureDetector.OnDoubleTapListener() {
@Override
public boolean onDoubleTap(MotionEvent e) {
...
//省略了 onSingleTapConfirmed、onDoubleTapEvent 等几个方法
});

       像上面代码写比较麻烦,有一个简单的方法提供,如下代码(一般也都这么写):

1
2
3
4
5
6
gestureDetectorCompat = new GestureDetectorCompat(context, new GestureDetector.SimpleOnGestureListener(){
@Override
public boolean onDoubleTap(MotionEvent e) {
return super.onDoubleTap(e);
}
});

       另外,上面的代码如果通过类来实现接口,写法如下:

1
2
3
4
5
6
7
public class XXX extends View implements GestureDetector.OnGestureListener, GestureDetector.OnDoubleTapListener {

...
gestureDetectorCompat = new GestureDetectorCompat(context, this);
gestureDetectorCompat.setOnDoubleTapListener(this);
...
}

       GestureDetector 的源码中有如下代码:

1
2
3
4
5
6
7
8
public GestureDetector(Context context, OnGestureListener listener, Handler handler) {
...
mListener = listener;
if (listener instanceof OnDoubleTapListener) {
setOnDoubleTapListener((OnDoubleTapListener) listener);
}
...
}

       可以看出,如果一个类实现了 OnDoubleTapListener ,源码会自动帮忙调用 setOnDoubleTapListener ,所以上面代码中的“gestureDetectorCompat.setOnDoubleTapListener(this);”可以省略,再次简写如下:

1
2
3
4
5
6
public class XXX extends View implements GestureDetector.OnGestureListener, GestureDetector.OnDoubleTapListener {

...
gestureDetectorCompat = new GestureDetectorCompat(context, this);
...
}

onSingleTapConfirmed() 与 onSingleTapUp()

       不考虑之前提到的长按,onSingleTapUp() 在手指抬起时便会响应,onSingleTapConfirmed() 是单击确认,确认用户想要单击而不是双击。双击是短时间内连续触摸两次,⽤户的⼀次点击不会⽴即调⽤ onSingleTapConfirmed() 这个⽅法,⽽是在⼀定时间后(300ms),确认⽤户没有进⾏双击,这个⽅法才会被调⽤。如果上面几节中的默认监听和双击监听都有设置,手指快速按下又抬起,首先 onSingleTapUp() 会被调用,300ms之后 onSingleTapConfirmed() 被调用。所以设置了双击监听器后如果想要做单击的监听需要使用 onSingleTapConfirmed() 方法,因为在双击事件中的第一次单击之后,onSingleTapUp() 会被调用。

onDoubleTap() 与 onDoubleTapEvent()

       具体含义如前面章节所示,当手指触摸抬起再次触摸,onDoubleTap() 和 onDoubleTapEvent() 都会触发,正如前面所示,onDoubleTapEvent() 在手指第二次按下后的移动与抬起都会触发,所以双击使用 onDoubleTap() 即可,如果使用 onDoubleTapEvent() ,还需要判断“down”事件。

scrollTo 与 scrollBy

       在一个 View 中,系统提供了 scrollTo、scrollBy 两种方式来改变一个 View 的位置,scrollTo(x,y) 表示移动到一个具体的坐标点(x,y),scrollBy(dx,dy) 表示移动的增量为 dx、dy。scrollTo() / scrollBy() 设置绘制时的偏移,通常⽤于滑动控件设置偏移。

       如果 ViewGroup 使用 to 和 by 的话,那所有的子 View都将移动,要是在 View 中使用的话,那么移动的就是 View 的内容了,举个例子,比如 TextView,content 就是它的文本,ImageView,drawable 就是对象。

       不能在 View 里面使用这个两个方法来拖动这个 view,应该在 View 所在的 ViewGroup 中使用方法来移动这个 view,例如:((View)getParent()).scrollBy(officeX,officeY)。详见 DragView4

scroll参数的正负值说明1

       上图,中间的矩形相当于屏幕,即可视区域,后面的 content 相当于画布,代表视图,可以看到,只有视图的中间部分目前是可视的,其它部分都不可见,可见区域中设置一个 button,它的坐标是(20,10),下面我们使用 scrollBy 方法来进行移动后,如图:

scroll参数的正负值说明2

       我们会发现,虽然设置 scrollBy(20.10),偏移量均为 XY 的正方向,但是屏幕的可视区域,Button 却向反方向移动了,这就是参考系选择的不同,而产生的不同效果。通过上面的分析可以发现,我们将 scrollBy 的参数 dx,dy 设置成正数,那么 content 将向坐标轴负方向移动,反之,则向正方向。

       scrollTo() / scrollBy() 是瞬时⽅法,不会⾃动使⽤动画。如果要⽤动画,需要配合 View.computeScroll() ⽅法。

Scroller 与 OverScroller

Scroller 使用

       使用scroller需要如下三个步骤:

初始化scroller

       首先,通过他的构造方法来创建一个scroller对象

1
2
//初始化mScroller
mScroller = new Scroller(context);

重写computeScroll,实现模拟滑动

       系统在绘制 View 的同时,会在 onDraw() 方法中调用这个方法,这个方法实际上就是使用了 ScrollTo() 方法,再结合 Scroller 对象,帮助获取到当前的滚动值,我们可以通过不断的瞬间移动一个小的距离来实现整体上的平滑移动效果。通常情况下,computeScroll 的代码可以利用标准的写法:

1
2
3
4
5
6
7
8
9
10
11
@Override
public void computeScroll() {
super.computeScroll();

//判断Scroller是否执行完毕
if(mScroller.computeScrollOffset()){
((View)getParent()).scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
}
//通过重绘来不断调用computeScroll
invalidate();
}

       Scroller类提供了 computeScrollOffset() 来判断是否完成了整个页面的滑动,同时也提供了 getCurrX(),getCurrY() 来获取当前滑动坐标。要注意的是 invalidate(),因为只能在 computeScroll 中获得模拟过程中的 scrollX,scrollY 坐标,但 computeScroll 方法是不会自动调用的,只能通过 invalidate—>OnDraw—>computeScroll 来间接调用,所以需要这个 invalidate,而当模拟过程结束的时候,computeScrollOffset 返回的是 false,结束循环。

startScroll开启模拟过程

       使用 Scroller 类的 startScroll() 方法来开启平滑过程,startScroll 有两个重载的方法:

1
2
public void startScroll(int startX,int startY,int dx,int dy,int duration)
public void startScroll(int startX,int startY,int dx,int dy)

       可以看到它们的区别是具有指定的持续时长。详见 DragView5

OverScroller 使用

1
scroller = new OverScroller(context);

       常⽤于 onFling() ⽅法中,调⽤ OverScroller.fling() ⽅法来启动惯性滑动的计算,如 ScalableImageView

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
// 初始化滑动
scroller.fling(startX, startY, velocityX, velocityY, minX, maxX, minY, maxY);
// 下⼀帧刷新
postOnAnimation(flingRunner);
return false;
}
...
@Override
public void run() {
// 计算此时的位置,并且如果滑动已经结束,就停⽌。 scroller.computeScrollOffset() 的返回值表示该动画是否还在进行中,结束返回 false 。
if (scroller.computeScrollOffset()) {
// 把此时的位置应⽤于界⾯
offsetX = scroller.getCurrX();
offsetY = scroller.getCurrY();
invalidate();
// 下⼀帧刷新
postOnAnimation(this);
}
}

       在触摸事件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// onTouchEvent() 中:
overScroller.startScroll(startX, startY, dx, dy);
postInvalidateOnAnimation();

......

// onTouchEvent() 外:
@Override
public void computeScroll() {
if (overScroller.computeScrollOffset()) { // 计算实时位置
scrollTo(overScroller.getCurrX(), overScroller.getCurrY()); // 更新界⾯
postInvalidateOnAnimation(); // 下⼀帧继续
}
}

postOnAnimation() 与 post()、postInvalidateOnAnimation()的区别

       post() 是立即去主线程执行,postOnAnimation() 也是去主线程执行,但是它会等到下一帧才执行。post() 的执行频率可能会比 postOnAnimation() 高,即同一帧执行多次。二者差别不大。另外还可以使用“ViewCompat.postOnAnimation(this,this);”去替换“postOnAnimation(this);”,前者会做兼容,根据系统的不同而使用“postOnAnimation()”还是“post()”。

       postInvalidateOnAnimation() 与 postOnAnimation() 相比,前者会自动调用 invalidate 方法,并且 computeScroll() 会在 draw 方法里面被自动调用,不需要实现 Runnable 接口。

OverScroller 与 Scroller 的区别

       使用 Scroller 也可替代 OverScroller 滑动,但是前者的初始速度很低,即便滑动很快展现出来的效果也是缓慢地移动。另外,OverScroller 的 fling() 可以多传入两个参数,它有过度滚动的参数,即滚动到边界还可以超越多少值。

VelocityTracker

       如果 GestureDetector 不能满⾜需求,或者觉得 GestureDetector 过于复杂,可以⾃⼰处理 onTouchEvent() 的事件。但需要使⽤ VelocityTracker 来计算⼿指移动速度。

使⽤⽅法

       在每个事件序列开始时(即 ACTION_DOWN 事件到来时),通过 VelocityTracker.obtain() 创建⼀个实例,或者使⽤ velocityTracker.clear() 把之前的某个实例重置;

       对于每个事件(包括 ACTION_DOWN 事件),使⽤
velocityTracker.addMovement(event) 把事件添加进 VelocityTracker ;

       在需要速度的时候(例如在 ACTION_UP 中计算是否达到 fling 速度),使⽤ velocityTracker.computeCurrentVelocity(1000, maxVelocity) 来计算实时速度,并通过 getXVelocity() / getYVelocity() 来获取计算出的速度。⽅法参数中的1000是指计算的时间⻓度,单位是 ms。例如这⾥填⼊1000,那么
getXVelocity() 返回的值就是每 1000ms (即⼀秒)时间内⼿指移动的像素数。第⼆个参数是速度上限,超过这个速度时,计算出的速度会回落到这个速度。例如这⾥填了 200,⽽实时速度是 300,那么实际的返回速度将是200。maxVelocity 可以通过 viewConfiguration.getScaledMaximumFlingVelocity() 来获取。使用详见 TwoViewPager

实现滑动的七种方法

       来看看如何使用系统提供的API来实现动态地修改一个View的坐标,即实现滑动效果。不管采用哪一种方式,其实现的思想基本是一致的,当触摸View时,系统记下当前触摸点坐标;当手指移动时,系统记下移动后的触摸点坐标,从而获取到相对于前一次坐标点的偏移量,并通过偏移量来修改View的坐标,这样不断重复,从而实现滑动过程。

layout方法

       在View进行绘制时,会调用onLayout()方法来设置显示的位置。同样,可以通过修改View的left、top、right、bottom四个属性来控制View的坐标。在每次回调onTouchEvent的时候,都来获取一下触摸点的坐标,代码如下所示:

1
2
int x = (int) event.getX();
int y = (int) event.getY();

       接着,在ACTION_DOWN事件中记录触摸点的坐标,代码如下所示:

1
2
3
4
5
case MotionEvent.ACTION_DOWN:
// 记录触摸点坐标
lastX = x;
lastY = y;
break;

       最后,可以在ACTION_MOVE事件中计算偏移量,并将偏移量作用到Layout的left、top、right、bottom基础上,增加计算出来的偏移量,代码如下所示:

1
2
3
4
5
6
7
8
9
10
case MotionEvent.ACTION_MOVE:
// 计算偏移量
int offsetX = x - lastX;
int offsetY = y - lastY;
// 在当前left、top、right、bottom的基础上加上偏移量
layout(getLeft() + offsetX,
getTop() + offsetY,
getRight() + offsetX,
getBottom() + offsetY);
break;

       这样每次移动后,View都会调用Layout方法来对自己重新布局,从而达到移动View的效果。详见 DragView0

       在上面的代码中,使用的是getX()、getY()方法来获取坐标值,即通过视图坐标来获取偏移量。当然,同样可以使用getRawX()、getRawY()来获取坐标,并使用绝对坐标来计算偏移量,代码如下所示:

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
@Override
public boolean onTouchEvent(MotionEvent event) {
int rawX = (int) event.getRawX();
int rawY = (int) event.getRawY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
// 记录触摸点坐标
lastX = rawX;
lastY = rawY;
break;
case MotionEvent.ACTION_MOVE:
// 计算偏移量
int offsetX = rawX - lastX;
int offsetY = rawY - lastY;
// 在当前left、top、right、bottom的基础上加上偏移量
layout(getLeft() + offsetX,
getTop() + offsetY,
getRight() + offsetX,
getBottom() + offsetY);
// 重新设置初始坐标
lastX = rawX;
lastY = rawY;
break;
}
return true;
}

       使用绝对坐标系,非常需要注意的是在每次执行完ACTION_MOVE的逻辑后,一定要重新设置初始坐标,这样才能准确地获取偏移量。详见 DragView1

offsetLeftAndRight()与offsetTopAndBottom()

       这个方法相当于系统提供的一个对左右、上下移动的API的封装。当计算出偏移量后,只需要使用如下代码就可以完成View的重新布局,效果与使用Layout方法一样,代码如下所示:

1
2
3
4
// 同时对left和right进行偏移
offsetLeftAndRight(offsetX);
// 同时对top和bottom进行偏移
offsetTopAndBottom(offsetY);

       这里的offsetX、offSetY与在Layout方法中计算offset方法一样。详见 DragView2

LayoutParams

       LayoutParams保存了一个View的布局参数。因此可以在程序中,通过改变LayoutParams来动态地修改一个布局的位置参数,从而达到改变View位置的效果。可以很方便地在程序中使用getLayoutParams()来获取一个View的LayouParams。当然,计算偏移量的方法与在Layout方法中计算offset也是一样。当获取到偏移量之后,就可以通过setLayoutParams来改变其LayoutParams,代码如下所示:

1
2
3
4
LinearLayout.LayoutParams layoutParams = (LinearLayout.LayoutParams) getLayoutParams();
layoutParams.leftMargin = getLeft() + offsetX;
layoutParams.topMargin = getTop() + offsetY;
setLayoutParams(layoutParams);

       需要注意的是,通过getLayoutParams()获取LayoutParams时,需要根据View所在父布局的类型来设置不同的类型,比如这里将View放在LinearLayout中,那么就可以使用LinearLayout.LayoutParams。类似地,如果在RelativeLayout中,就要使用RelativeLayout.LayoutParams。当然,这一切的前提是你必须要有一个父布局,不然系统不法获取LayoutParams。

       在通过改变LayoutParams来改变一个View的位置时,通常改变的是这个View的Margin属性,所以除了使用布局的LayoutParams之后,还可以使用ViewGroup.MarginLayoutParams来实现这样一个功能,代码如下所示:

1
2
3
4
ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) getLayoutParams();
layoutParams.leftMargin = getLeft() + offsetX;
layoutParams.topMargin = getTop() + offsetY;
setLayoutParams(layoutParams);

       可以发现,使用ViewGroup.MarginLayoutParams更加方便,不需要考虑父布局的类型,当然它们的本质都是一样的。详见 DragView3

scrollTo和scrollBy

       详见上面scrollTo与scrollBy一节,类似如下代码:

1
2
3
int offsetX = x - lastX;
int offsetY = y - lastY;
((View) getParent()).scrollBy(-offsetX, -offsetY);

       类似地,在使用绝对坐标时,也可以通过scrollTo方法来实现这一效果。

Scroller

       详见上面Scroller与OverScroller一节。

属性动画

       略。

ViewDragHelper

       详见ViewDragHelper 详解

参考资料:
腾讯课堂 HenCoder
《Android 群英传》徐宜生 第5章 Android Scroll 分析

Fork me on GitHub