Android中长按拖动还是比较常见的.比如Launcher中的图标拖动及屏幕切换,ListView中item顺序的改变,新闻类App中新闻类别的顺序改变等.下面就这个事件做一下分析.
Android Long Click Event
就目前而言,Android中实现长按事件响应有几种方式,包括:
- 设置View.OnLongClickListener监听器
- 通过GestureDetector.OnGestureListener间接获取长按事件
- 实现View.OnTouchListener,然后在回调中通过MotionEvent判断是否触发了长按事件
下面分别介绍这三种方式.
View.OnLongClickListener
对于Android中的任何一个View,都可以实现长按事件监听,并回调这个事件.在View
类里,定义了OnLongClickListener
.
1 | /** |
默认情况下,View类是不支持长按的,由LONG_CLICKABLE
这个标记控制.如果设置了监听器,则会默认打开支持长按的开关,并回调上面的boolean onLongClick(View v)
方法.从注释的返回值中可以看到,如果这个回调消费了长按事件,则返回true
,否则返回false
.这和View
类中的各种触摸事件传递是一致的.
1 | /** |
其中, getListenerInfo()
返回一个包含了一个View类中所有的监听器事件的静态内部类ListenerInfo
.
简单实例
1 | ImageView imageView = new ImageView(this); |
GestureDetector.OnGestureListener
GestureDetector
提供了丰富的手势识别功能.除了支持长按事件监听外,还支持多种手势事件监听.在GestureDetector.OnGestureListener
这个监听器中,提供了6种手势监听回调:
- boolean onDown(MotionEvent e);
- void onShowPress(MotionEvent e);
- boolean onSingleTapUp(MotionEvent e);
- boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY);
- void onLongPress(MotionEvent e);
- boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY);
几乎包含了一次界面触摸操作所能想到的所有操作.其中,可以通过void onLongPress(MotionEvent e)
来实现长按监听.
简单实例
1 | package com.amap.mock.activity; |
GestureDetector长按事件原理解析
在上面的例子中,我们看到在GestureDetector
这个类中,实现了onTouchEvent()
方法,直接代替View
类中的onTouchEvent()
方法,即可实现触摸事件的检测.下面是GestureDetector.onTouchEvent()
的部分关键源码:
1 | public boolean onTouchEvent(MotionEvent ev) { |
在onTouchEvent()
方法中,很明显是通过Handler来传递触摸事件并触发相关的回调的.因为Handler是通过一个串行的队列来处理消息的,可以防止并发触摸操作时产生行为逻辑的混乱.在此方法中,可以看到对MotionEvent事件的处理,就长按事件来说,分为:
- MotionEvent.ACTION_DOWN
- MotionEvent.ACTION_MOVE
- MotionEvent.ACTION_UP
在MotionEvent.ACTION_DOWN
阶段,程序做了3件事:
- 判断双击事件(我们不关心是否双击,因为没有设置这个监听器,也不是本文讨论的重点);
- 进行初始化操作.包括:
- mDownFocusX = mLastFocusX = focusX;
mDownFocusY = mLastFocusY = focusY; // 记录焦点坐标,用于判断在按下的过程中是否发生了手指的移动 - mAlwaysInTapRegion = true; // 按下了相应的区域,判断单击事件并制定后来的事件响应机制
- mAlwaysInBiggerTapRegion = true; // 按下了相应的大区域,判断双击事件
- mStillDown = true; // 用于判断用户是轻轻触摸了一下还是一直按下
- mInLongPress = false; // 判断是否正在长按
- mDeferConfirmSingleTap = false; // 用于处理是否是一次
TAP
事件
- mDownFocusX = mLastFocusX = focusX;
- 通过发送延时消息来判断触不触发长按事件:
1 | if (mIsLongpressEnabled) { |
默认的TAP_TIMEOUT
是100ms,LONGPRESS_TIMEOUT
是500ms.这两个参数在ViewConfiguration.java
类中有定义,并且暂时不提供API更改触发值.
1 | /** |
接下来,在MotionEvent.ACTION_MOVE
阶段,程序判断比较简单.
如果正在长按或者是在上下文中点击,则跳出循环;
1
2
3if (mInLongPress || mInContextClick) {
break;
}判断是不是双击事件(
mAlwaysInTapRegion
);- 如果不是双击事件,则判断是不是还在之前触摸的那个区域(
mAlwaysInTapRegion
);如果是,由于触发了ACTION_MOVE
事件,那么说明手指已经移动过了N个单位距离,这时候,需要判断这个距离是不是大于某个阈值mTouchSlopSquare
,其中mTouchSlopSquare=configuration.getScaledTouchSlop()^2
,
在配置文件中默认值为8dip
.如果大于这个阈值,则说明移动确实发生了,这时候:- handled = mListener.onScroll(mCurrentDownEvent, ev, scrollX, scrollY); //设置滚动监听回调
- mLastFocusX = focusX;
- mLastFocusY = focusY; // 重新设置触摸焦点
- mAlwaysInTapRegion = false; // 重置触摸区域判断
- mHandler.removeMessages(TAP);
- mHandler.removeMessages(SHOW_PRESS);
- mHandler.removeMessages(LONG_PRESS); // 移除所有触摸相关的消息事件
- 如果以上两项都不符合,那么则确定为滚动事件,并重置焦点:
- handled = mListener.onScroll(mCurrentDownEvent, ev, scrollX, scrollY);
- mLastFocusX = focusX;
- mLastFocusY = focusY;
处理完成移动事件后,到了MotionEvent.ACTION_UP
阶段,程序主要判断当前处于哪个阶段,然后分别针对这个阶段做事件清除,资源回收,重置各种触摸状态.由于程序比较简单,就不再详细分析这个阶段的消息了.
View.OnTouchListener
看了GestureDetector.onTouchEvent
的源码后,是不是觉得长按事件检测与处理很简单?接下来的这种方法就是借鉴了第二种方法来实现的,主要原理就是利用Handler发送延时消息来判断是不是触发了长按事件.不过我是在MotionEvent.ACTION_MOVE
阶段来判断长按事件,这样做的原因留给后面来分析.先看代码:
1 | package com.amap.mock.activity; |
这段代码可能没有GestureDetector
这个类写的这么规范和完整,但至少能够实现长按触发并且实现事件的回调.使用这个类有以下两个限制:
- 在
LongPressHandler
这个类的构造函数中,设置了OnTouchListener
监听器,因此如果这个View在其他地方也设置了同样的监听器,有可能不起作用,以最后一个初始化该监听器的类其作用为标准; - 必须设置
View
为可点击的.即View.setClickable(true)
.显然,不可点击的话就没有长按事件了.
长按事件小结
经过上面的分析,我们通过三种方式实现了长按事件的检测及事件回调处理,分别是View.OnLongClickListener
,GestureDetector.OnGestureListener
以及View.OnTouchListener
.
如果仅仅是考虑长按事件,那么直接设置View.OnLongClickListener
监听器是最方便的实现;如果需要监听多种触摸事件,那么显然GestureDetector.OnGestureListener
是理想的选择,并且在GestureDetector
类内部已经实现了一个简单的监听器实现GestureDetector.SimpleOnGestureListener
,这个类没有实现任何功能,需要子类覆盖相应的方法来响应事件回调;如果要实现长按拖拽呢,显然以上两个类是没有办法满足要求的,因此,扩展View.OnTouchListener
类是个不错的选择,在文章最后,会介绍如何扩展来实现长按拖拽事件.
Android Drag Event
拖拽事件和长按事件一样,是直接得到View
类支持的.在View.ListenerInfo
类中,定义了View.OnDragListener
监听器,不过需要配合上边的View.OnLongClickListener
来使用,否则单单有这个监听器是不起作用的.目前,实现拖拽事件的方法有两种:
- 设置
View.OnDragListener
和View.OnLongClickListener
监听器,在长按事件响应时开始拖拽,通过回调判断拖拽事件 - 通过
View.layout(int,int,int,int)
方法直接修改View的位置
View.OnDragListener
先来看看View.OnDragListener
的定义:
1 | /** |
恩,虽然仅仅是个接口,然而有许多注意事项.这个接口会在屏幕响应拖拽事件时调用,并且会在View类中的View.onDrag(event)
方法之前调用.如果需要系统继续调用View.onDrag(event)
方法,那么这个监听器回调应该返回false
,让事件传递到下一层.
前面说了,仅仅设置View.OnDragListener
监听器是不够的,因为系统并不会主动去触发这个事件监听,而是通过View.startDrag(ClipData, DragShadowBuilder, Object, int)
这个方法,这个方法会在View类的顶层根视图ViewRootImpl
中处理拖拽事件,注意,ViewRootImpl
并非继承自View
.下面是一个简单的例子.
简单实例
1 | package com.amap.mock.activity; |
首先需要监听长按事件,然后在触发长按事件后,便可以开始拖动了.拖动的时候会回调View.onDrag()
方法.其中,在DragEvent
中定义了几个动作,表示拖动过程:
DragEvent.ACTION_DRAG_STARTED
调用View.startDrag()
并获得拖动的阴影后进入这个阶段
DragEvent.ACTION_DRAG_ENTERED
系统会把带有这个类型的拖拽事件发送给当前布局中所有的View对象的拖拽事件监听器,如果要继续接收拖拽事件,包括可能的放下事件,View对象的拖拽事件监听器必须返回true.
DragEvent.ACTION_DRAG_LOCATION
当接收到ACTION_DRAG_ENTERED
事件,并且拖动的影子与原来的View还有重叠的区域时,进入这个状态,只要还在拖动并且符合要求,则这个状态是会被调用多次的.
DragEvent.ACTION_DRAG_EXITED
当接收到ACTION_DRAG_ENTERED
事件及至少一次ACTION_DRAG_LOCATION
事件,并且拖动的影子与原来的View没有重叠的区域,即影子与View分离时,进入这个状态,此状态只会在不重叠的一瞬间调用一次.
DragEvent.ACTION_DROP
当用户在一个View对象之上释放了拖拽影子,这个对象的拖拽事件监听器就会收到这种操作类型。如果这个监听器在响应ACTION_DRAG_STARTED
拖拽事件中返回了true,那么这种操作类型只会发送给一个View对象。如果用户在没有被注册监听器的View对象上释放了拖拽影子,或者用户没有在当前布局的任何部分释放操作影子,这个操作类型就不会被发送。如果View对象成功的处理放下事件,监听器要返回true,否则应该返回false。
DragEvent.ACTION_DRAG_ENDED
当系统结束拖拽操作时,View对象拖拽监听器会接收这种事件操作类型。这种操作类型之前不一定是ACTION_DROP
事件。如果系统发送了一个ACTION_DROP
事件,那么接收ACTION_DRAG_ENDED
操作类型不意味着放下操作成功了。监听器必须调用getResult()方法来获得响应ACTION_DROP
事件中的返回值。如果ACTION_DROP
事件没有被发送,那么getResult()会返回false。
View.layout(int,int,int,int)
上面的方法有个致命的弱点,那就是图标没办法放到指定拖动的点,而只能实现拖动的效果.因为Android系统在设计的时候,View.OnDragListener
并不是用来进行图标拖动的,而是文字的复制粘贴,我们只是强行地将它作用于图标的拖拽.但上面这种方案也是可以解决这个问题的,那就是先移除原来的图标,然后再在新的位置重绘图标,不过这略显麻烦了,对于内存吃紧的Android系统来说,这无疑是雪上加霜.
下面我们通过重新布局图标的Layout来实现拖动效果.要实现这种效果,就要用到上面介绍的长按事件中的第三种方案,采用设置View.OnTouchListener
监听器来监听触摸事件,并在MotionEvent.ACTION_MOVE
中处理拖动.
对于上面的LongPressHandler
类,还需要做小小的修改,因为上面这个类触发了一次长按回调后,就顺便移除了这个回调,后面的触摸事件就接收不了监听了.解决方案也很简单,在下面这段代码中,把移除监听注销掉就可以了.
1 | case MotionEvent.ACTION_MOVE: |
然后,我们在主类中调用这个方法的回调,在回调里进行图标的拖拽.
1 | package com.amap.mock.activity; |
其中,Activity的布局:
1 | "1.0" encoding="utf-8" xml version= |
这段代码的关键思想在于,在拖动时,动态计算当前位置的坐标,然后调用View.layout()
方法对这个View重新布局.有几个需要注意的地方:
- 记得算上动态栏的高度,可以通过
getResources().getDimensionPixelSize(resourceId);
得到某个资源的像素值; - 需要计算图像的中心点,否则移动的距离以你图标的左上角来计算;
- 在上面的源码中,我还做了一件事,就是获取图标的布局,重新设置Margin值.其实如果你除了拖动以外不进行别的操作的话,没必要进行这样的设置.但如果你需要重绘这个按钮的话,那么重绘的时候是按照旧的LayoutParams来绘制的,这样会造成图标又回到了原来的地方.我在设置FloatingActionButton的时候就遇到了这样的问题,具体代码可参考Github源码.
- 如果不想长按拖动,而是直接拖动,那么修改长按触发阈值
LONG_PRESS_TIME_THRESHOLD
,或者通过public LongPressHandler(View view, long holdTime)
构造函数来实例化LongPressHandler
就可以了.
触摸事件小结
经过上面的分析,我们通过两种方式实现了触摸事件的实现及事件回调处理,分别是View.OnDragListener接口和View.layout()方法.
实际上触摸事件是和长按事件分不开的,只是触发时间的长短阈值设置不同罢了.在第一种方法中,通过调用View.startDrag()
方法触发拖拽事件,通过设置View.OnDragListener
设置事件回调,便可以在回调中处理拖拽事件.但是这种方法的应用场景并不是图标拖拽,而是文字的复制粘贴,原始的视图是不会移动的.一般我们会通过覆盖View.onTouchEvent()
或者设置View.OnTouchListener
监听器来监听滑动事件,并在MotionEvent.ACTION_MOVE
状态中处理拖拽问题,这便是第二种方案的思想.
Source Code
长按拖拽的源码在Github上,欢迎star&fork:)
Reference
http://grepcode.com/file/repo1.maven.org/maven2/org.robolectric/android-all/5.0.0_r2-robolectric-1/android/view/View.java
http://www.ablanxue.com/prone_4213_1.html
http://www.yiibai.com/android/android_drag_and_drop.html
http://developer.android.com/guide/topics/ui/drag-drop.html
http://www.jcodecraeer.com/a/anzhuokaifa/developer/2013/0311/1003.html