为了吸引大家的注意力,先给大家看一张动图:
相信这种效果大家都见过吧?我第一次见到这样的效果时,心里也痒痒的,急于想实现这种功能,后来因为拖延症的问题,就一直没有去弄这件事。现在这段时间,工作比较轻闲,所以对自己几年 Android 生涯所运用的技术做一些总结与思考。拖拽这种功能正好可以形成一个主题。如题目所示,今天博文的目标就是介绍与分析 ViewDragHelper 这个类。
读者阅读本文后将会有如下收获:
1. 不借助于 ViewDragHelper 实现基本的拖拽效果。
2. 借助于 ViewDragHelper 轻松实现复杂的拖拽效果。
3. 分析 ViewDragHelper 源码说明它能实现拖拽的原因(放心,不会头晕,只涉及一点点源码)。
初识 ViewDragHelper
在我 Android 职业生涯的第一个月,第一个项目和 Launcher 有关,需要直接阅读系统的 Launcher 的代码,当时是 Launcher2 的工程,这个工程是 Android 系统的门面,但是代码量巨大,作为菜鸟而言,工作难度可想而知。很多地方直接看不懂,比如各种 Callback,比如涉及拖拽的 DragController 。
Launcher 中的拖拽主要是针对 APP 在桌面上的 ICON 和 Widget。
而 Launcher2 关于拖拽的代码,当时的我自认为是看不懂的,我当时的想法时能看懂的是高手。
但是,正因为如此我对拖拽这一功能才会有深深的恐惧感。
我当时立下志向————有朝一日,我一定会拥有这个能力的。
后来,官方为了便于开发,提供了一个方便的辅助类 ViewDragHelper 放到 Support V4 这个兼容包中,正因为如此,我的目标就更进了一步。
不借助于 ViewDragHelper 实现拖拽的功能
主动思考比被动接受的学习效果要好一点,被动接受的弊端在于看书的时候,我们以为自己懂了,产生“已经学会”这种错觉,结果是一段时间再来检验,发现实践效果相去甚远。
所以,我们学习新的知识最好要加入自己的主动思考,因为这样别人的知识才会被自己真正吸收,构建到自己的知识体系当中,成为自己的知识组件。
那么,对于拖拽这个功能,我们可以先抛开 ViewDragHelper 这个类不管。
我们先想一想如果是自己亲自编码,我们将怎么样开始呢?
动作分解
我们先可以将拖拽这个动作分解:
1. 触摸。
2. 移动。
角色分析
首先,我们博文分析的目标是 ViewGroup 中的拖拽,而并非是一个 View 中的拖拽。View 中拖拽实际上就是针对内容拖拽。用 scrollBy() 方法就可以解决,它等同于滑动或者滚动的概念,这个不在于本文讨论范围之内,如果对于这部分感兴趣的同学可以阅读我这篇博文《不再迷惑,也许之前你从未真正懂得 Scroller 及滑动机制》。
很容易观察得到,ViewGroup 中拖拽涉及的角色可能包括:
1. ViewGroup。
2. 它的子 View,也就是某些 childView。
交互分析
- 手指触摸在 ViewGroup 上。
- 如果触摸的坐标正好落在某个 childView 上面。拖拽开始。
- 手指开始移动,childView 位置坐标改变。拖拽进行。
- 手指释放后,childView 落在新的位置或者回弹到指定的某处,拖拽结束。
编码
涉及到触摸的话,ViewGroup 自然要在 onTouchEvent() 和 onInterceptTouchEvent() 两个方法中处理。
onInterceptTouchEvent() 主要是用来决定是否拦截 childView 的触摸操作,这里面为了方便演示,统一处理为 true,也就是拦截。
onTouchEvent() 在这个方法中,ViewGroup 用来处理触摸的具体流程。也就是对应上图的触摸、移动、释放手指。
在 Android 中 MotionEvent 封装了触摸时的各种状态。所以我们主要处理的状态有以下:
1. MotionEvent.ACTION_DOWN: 在这个状态时,标记手指按下屏幕。我们需要判断当前触摸的地方是否落在 childview 的显示区域,如果是则标记拖拽状态开始,我们需要记录手指的触摸位置为原始坐标。
2. MotionEvent.ACTION_MOVE: 这个状态自然代表手指的移动过程,这个时候我们仍然需要记录手指触摸新的坐标,然后如果是在触摸开始的状态,则将 childview 进行位置偏移,偏移量就是新坐标与原始坐标的偏差。
3. MotionEvent.ACTION_UP,MotionEvent.ACTION_CANCLE:这两者都是表明手指离开了屏幕,这个时候如果一个 childview 正在拖拽,那么需要标记拖拽状态结束,至于 View 根据实际需要,通常是停留在新的坐标或者是回弹到原来的地方。
知道了流程,我们就可以开始编码,我们可以新建一个 ViewGroup 命名为 DragViewGroup,为了简便起见,让它继承自 FrameLayout。之后实现它的 onInterceptTouchEvent() 和 onTouchEvent()。
public class DragViewGroup extends FrameLayout {
private static final String TAG = "TestViewGroup";
// 记录手指上次触摸的坐标
private float mLastPointX;
private float mLastPointY;
//用于识别最小的滑动距离
private int mSlop;
// 用于标识正在被拖拽的 child,为 null 时表明没有 child 被拖拽
private View mDragView;
// 状态分别空闲、拖拽两种
enum State {
IDLE,
DRAGGING
}
State mCurrentState;
public DragViewGroup(Context context) {
this(context,null);
}
public DragViewGroup(Context context, AttributeSet attrs) {
this(context, attrs,0);
}
public DragViewGroup(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mSlop = ViewConfiguration.getWindowTouchSlop();
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
return true;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
int action = event.getAction();
switch (action){
case MotionEvent.ACTION_DOWN:
if ( isPointOnViews(event)) {
//标记状态为拖拽,并记录上次触摸坐标
mCurrentState = State.DRAGGING;
mLastPointX = event.getX();
mLastPointY = event.getY();
}
break;
case MotionEvent.ACTION_MOVE:
int deltaX = (int) (event.getX() - mLastPointX);
int deltaY = (int) (event.getY() - mLastPointY);
if (mCurrentState == State.DRAGGING && mDragView != null
&& (Math.abs(deltaX) > mSlop || Math.abs(deltaY) > mSlop)) {
//如果符合条件则对被拖拽的 child 进行位置移动
ViewCompat.offsetLeftAndRight(mDragView,deltaX);
ViewCompat.offsetTopAndBottom(mDragView,deltaY);
mLastPointX = event.getX();
mLastPointY = event.getY();
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
if ( mCurrentState == State.DRAGGING ){
// 标记状态为空闲,并将 mDragView 变量置为 null
mCurrentState = State.IDLE;
mDragView = null;
}
break;
}
return true;
}
/**
* 判断触摸的位置是否落在 child 身上
*
* */
private boolean isPointOnViews(MotionEvent ev) {
boolean result = false;
Rect rect = new Rect();
for (int i = 0;i < getChildCount();i++) {
View view = getChildAt(i);
rect.set((int)view.getX(),(int)view.getY(),(int)view.getX()+(int)view.getWidth()
,(int)view.getY()+view.getHeight());
if (rect.contains((int)ev.getX(),(int)ev.getY())){
//标记被拖拽的child
mDragView = view;
result = true;
break;
}
}
return result && mCurrentState != State.DRAGGING;
}
}
注释写得很清楚,流程之前也分析过。现在我们来进行验证,验证的前置条件就是放 3 个 View 到 DragViewGroup 中,然后检测能不能够手指移动它。布局代码比较简单,我就不张贴了。直接看效果。
可以看到,基本的拖拽的功能实现了,但是有个细节需要优化,当 3 个 child 显示重叠时,触摸它的公共区域,总是最底层的 child 被响应,这有点反人类,正常的操作应该是最上层的最先被响应。那么怎么优化呢?
在上面代码中 mDragView 用来标记可以被拖拽的 child,我们在 isPointOnViews() 方法中找到最先适配的 child 然后赋值,但是由于 FrameLayout 的特性,最上面的 child 其实在 ViewGroup 的索引位置最靠后。
private boolean isPointOnViews(MotionEvent ev) {
boolean result = false;
Rect rect = new Rect();
for (int i = 0;i < getChildCount();i++) {
View view = getChildAt(i);
rect.set((int)view.getX(),(int)view.getY(),(int)view.getX()+(int)view.getWidth()
,(int)view.getY()+view.getHeight());
if (rect.contains((int)ev.getX(),(int)ev.getY())){
//标记被拖拽的child
mDragView = view;
result = true;
break;
}
}
return result && mCurrentState != State.DRAGGING;
}
因此,我们可以做一小小改动就能修正这个问题,那就是遍历 children 的时候,逆序进行。这样先从顶层检查找到最适配触摸位置的地方,代码如下:
private boolean isPointOnViews(MotionEvent ev) {
boolean result = false;
Rect rect = new Rect();
for (int i = getChildCount() - 1;i >= 0;i--) {
View view = getChildAt(i);
rect.set((int)view.getX(),(int)view.getY(),(int)view.getX()+(int)view.getWidth()
,(int)view.getY()+view.getHeight());
if (rect.contains((int)ev.getX(),(int)ev.getY())){
//标记被拖拽的child
mDragView = view;
result = true;
break;
}
}