一、概述
在自定义ViewGroup中,很多效果都包含用户手指去拖动其内部的某个View(eg:侧滑菜单等),针对具体的需要去写好onInterceptTouchEvent和onTouchEvent这两个方法是一件很不容易的事,需要自己去处理:多手指的处理、加速度检测等等。 好在官方在v4的支持包中提供了ViewDragHelper这样一个类来帮助我们方便的编写自定义ViewGroup。
本篇博客将重点介绍ViewDragHelper的使用,并且最终去实现一个类似DrawerLayout的一个自定义的ViewGroup。
首先我们通过一个简单的例子来看看其快捷的用法,分为以下几个步骤:
创建实例
触摸相关的方法的调用
ViewDragHelper.Callback实例的编写
(一) 自定义ViewGroup
class MyCallBack extends ViewDragHelper.Callback {
@Override
public boolean tryCaptureView(View child, int pointerId) {
return true;
}
@Override
public int clampViewPositionHorizontal(View child, int left, int dx) {
return left;
}
@Override
public int clampViewPositionVertical(View child, int top, int dy) {
return top;
}
@Override
public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
super.onViewPositionChanged(changedView, left, top, dx, dy);
}
@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
super.onViewReleased(releasedChild, xvel, yvel);
}
}
可以看到,上面整个自定义ViewGroup的代码非常简洁,遵循上述3个步骤:
-
获取实例
-
mDragger = ViewDragHelper.create(this, 1.0f, new MyCallBack());
创建实例需要3个参数,第一个就是当前的ViewGroup,第二个sensitivity,主要用于设置touchSlop:
helper.mTouchSlop = (int) (helper.mTouchSlop * (1 / sensitivity));
可见传入越大,mTouchSlop的值就会越小。第三个参数就是Callback,在用户的触摸过程中会回调相关方法,后面会细说。
2. touch分析和监听 ```java
-
@Override public boolean onInterceptTouchEvent(MotionEvent event) { return mDragger.shouldInterceptTouchEvent(event); } @Override public boolean onTouchEvent(MotionEvent event) { mDragger.processTouchEvent(event); return true; }
onInterceptTouchEvent中通过使用mDragger.shouldInterceptTouchEvent(event)来决定我们是否应该拦截当前的事件。onTouchEvent中通过mDragger.processTouchEvent(event)处理事件。就相当于事件的委托,托管。
3.. 实现自己的callBack
对于touch事件,常见的有down、move、up事件,对应的ViewDragHelper封装了不同的回调方法。
class MyCallBack extends ViewDragHelper.Callback {
@Override
public boolean tryCaptureView(View child, int pointerId) {
return true;
}
@Override
public int clampViewPositionHorizontal(View child, int left, int dx) {
return left;
}
@Override
public int clampViewPositionVertical(View child, int top, int dy) {
return top;
}
}
ViewDragHelper中拦截和处理事件时,会回调CallBack中的很多方法来决定一些事,比如:哪些子View可以移动、对移动的View的边界的控制等等。
上面复写的3个方法:
tryCaptureView()在发生touch事件的down事件的时候回调。
代表我是否捕获分析(返回true才会有效)view的touch事件;参数1:当前触摸的view(为当前viewGroup的孩子child);2:touch的id,触摸的当前child的id标识。如果这个方法返回值为false,表示已不对触摸的view进行分析,表示我ViewDragHelper不支持滑动该child的效果了,后面的方法也都无效。你可以根据传入的第一个view参数决定哪些可以捕获。
clampViewPositionHorizontal,clampViewPositionVertical水平移动的回调,垂直移动的回调,发生touch事件的move时回调。
比如看水平移动:当touch水平移动后的回调 参数1:分析的是哪个孩子组件child移动了;参数2:当前移动的childview的左上角的坐标,child的左侧的边距,控件移动到左边什么位置,值会根据移动变化;参数3:增量的x(记录相对上一次的变化量dx>0右滑,dx<0左滑)。这里的值是预期的值,预期的意思是说,手指向左移动了10的话(left=-10),控件此时无法移动,但是手指预期移动的这个值已经帮我们计算好了传入回调方法的第二个参数,return这个值它就会帮我们实现移动-10的效果。该方法的返回值表示:// 确定要移动多少,移动到什么位置去 一般写成return left;。【这个方法里面经常换主角,touch到哪个view这里的child就是哪个view】。可以在这里做边距的监测,做越界处理。
解释参数2:
解释参数3:
上面连续移动了两次,手指移动会产生两个值,每一次都会回调clampViewPositionHorizontal 方法,它会把手指移动过的距离-10,-20传入给left,我们把这个left返回的话就给我们完成了两次移动效果。
onViewPositionChanged(View changedView, int left, int top, int dx, int dy)当【控件位置】移动时的回调
参数意义:// @changedView: 哪个view移动了 // @left,top:view移动后的左上角的坐标 // @dx,dy: 移动的增量。参数解释的意思跟上面那个方法差不多。
这个方法跟上边clampViewPositionHorizontal差不多,都是移动回调,如果说clampViewPositionHorizontal用于处理越界以及确定位置的话,那么onViewPositionChanged一般用于移动view的布局重置,即根据参数做控件的移动。后续代码可以看到两者的各自责任,以及实现什么功能。
onViewReleased(View releasedChild, float xvel, float yvel)松开手时候的回调,up事件(手指释放)的回调。@releasedChild:松开了哪个view; @xvel,yvel:速率。该方法一般用于“松手回弹”效果的操作,即松开手,自定义控件往哪个位置回弹。之前自定义ViewPage实现测拉效果的时候偶可以看到回弹效果,那里是使用Scrollor实现的,而在ViewDragHelper有它的手段,稍后会用到。
二、API记忆:
ViewDragHelper提供了丰富的API,罗列如下:
1)、ViewDragHelper
public static ViewDragHelper create(ViewGroup forParent, float sensitivity, Callback cb)
创建ViewDragHelper实例,sensitivity越大,slodTop间距值越小,即对滑动的检测就越敏感,默认传1即可。
public void setEdgeTrackingEnabled(int edgeFlags)
设置允许父View边界监测,允许父View的某个边缘可以用来响应托拽事件。
public boolean shouldInterceptTouchEvent(MotionEvent ev)
在父view onInterceptTouchEvent方法中调用
public void processTouchEvent(MotionEvent ev)
在父view onTouchEvent方法中调用
public void captureChildView(View childView, int activePointerId)
在父View内捕获指定的子view用于拖曳,该方法可以绕过tryCaptureView,所以我们指定捕获哪一个View后,即使在tryCaptureView中并没有确定这个孩子控件也就是非返回true(返回false),但却不影响,仍然可以响应该控件的拖拽事件。
public boolean smoothSlideViewTo(View child, int finalLeft, int finalTop)
某个View自动滚动到指定的位置比如移动到left=finalLeft位置,初速度为0,可在任何地方调用,动画移动会回调continueSettling(boolean)方法,直到结束
public boolean settleCapturedViewAt(int finalLeft, int finalTop)
以松手前的滑动速度为初值,让捕获到的子View自动滚动到指定位置如finalLeft位置,只能在Callback的onViewReleased()中使用,其余同上
public void flingCapturedView(int minLeft, int minTop, int maxLeft, int maxTop)
以松手前的滑动速度为初值,让捕获到的子View在指定范围内fling惯性运动,只能在Callback的onViewReleased()中使用,其余同上
public boolean continueSettling(boolean deferCallbacks)
在调用settleCapturedViewAt()、flingCapturedView()和smoothSlideViewTo()时,该方法返回true,一般重写父view的computeScroll方法,进行该方法判断
public void abort()
中断动画
其中绿笔标注了常用方法,红笔标注了注意事项。
在ViewDragHelper的滑动中共有三个方法可以调用,smoothSlideViewTo、settleCapturedViewAt、flingCapturedView,动画移动会回调continueSettling(boolean)方法,在内部是用的ScrollerCompat来实现滑动的。
在computeScroll方法中判断continueSettling(boolean)的返回值,来动态刷新界面:
@Override
public void computeScroll() {
if (mViewDragHelper.continueSettling(true)) {
//效果等同于invalidate()---->会调用computeScroll.这里必须有刷新才可以
ViewCompat.postInvalidateOnAnimation(SweepView.this);
}
}
2)、ViewDragHelper.CallBack
public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy)
被拖拽的View位置变化时回调,changedView为位置变化的view,left、top变化后的x、y坐标,dx、dy为新位置与旧位置的偏移量
public void onViewDragStateChanged(int state)
当ViewDragHelper状态发生变化时回调(STATE_IDLE,STATE_DRAGGING,STATE_SETTLING自动滚动时)
public void onViewCaptured(View capturedChild, int activePointerId)
成功捕获到子View时或者手动调用captureChildView()时回调
public void onViewReleased(View releasedChild, float xvel, float yvel)
当前拖拽的view松手或者ACTION_CANCEL时调用,xvel、yvel为离开屏幕时的速率
public void onEdgeTouched(int edgeFlags, int pointerId)
当触摸到边界时回调
public boolean onEdgeLock(int edgeFlags)
true的时候会锁住当前的边界,false则unLock。锁定后的边缘就不会回调onEdgeDragStarted()
public void onEdgeDragStarted(int edgeFlags, int pointerId)
ACTION_MOVE且没有锁定边缘时触发,在此可手动调用captureChildView()触发从边缘拖动子View
public int getOrderedChildIndex(int index)
寻找当前触摸点View时回调此方法,如需改变遍历子view顺序可重写此方法。
public int getViewHorizontalDragRange(View child)
返回拖拽子View在相应方向上可以被拖动的最远距离,默认为0
public int getViewVerticalDragRange(View child)
返回拖拽子View在相应方向上可以被拖动的最远距离,默认为0
public abstract boolean tryCaptureView(View child, int pointerId)
是否分析(返回true才会有效)view的touch;参数1:触摸的view;2:touch的id。对触摸view判断,如果需要当前触摸的子View进行拖拽移动就返回true,否则返回false
public int clampViewPositionHorizontal(View child, int left, int dx)
拖拽的子View在所属方向上移动的位置,child为拖拽的子View,left为子view应该到达的x坐标,dx为挪动差值
public int clampViewPositionVertical(View child, int top, int dy)
同上,top为子view应该到达的y坐标
上面的API在后面的自定义View中或多或少的会用到一些。
三、案例演示:
使用ViewDragHelper完成一个简单的自定义View效果。
最终效果展示:
简单的为每个子View添加了不同的操作:
第一个View,就是演示简单的移动
第二个View,演示除了移动后,松手自动返回到原本的位置。(注意你拖动的越快,返回的越快)
第三个View,边界移动时对View进行捕获。
好了,看完效果图,来看下代码:
public class VDHLayout extends LinearLayout {
private ViewDragHelper mDragger;
private View mDragView;
private View mAutoBackView;
private View mEdgeTrackerView;
private Point mAutoBackOriginPos = new Point();
public VDHLayout(Context context, AttributeSet attrs) {
super(context, attrs);
mDragger = ViewDragHelper.create(this, 1.0f, new ViewDragHelper.Callback() {
@Override
public boolean tryCaptureView(View child, int pointerId) {
//mEdgeTrackerView禁止直接移动
return child == mDragView || child == mAutoBackView;
}
@Override
public int clampViewPositionHorizontal(View child, int left, int dx) {
return left;
}
@Override
public int clampViewPositionVertical(View child, int top, int dy) {
return top;
}
//手指释放的时候回调
@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
//mAutoBackView手指释放时可以自动回去
if (releasedChild == mAutoBackView) {
mDragger.settleCapturedViewAt(mAutoBackOriginPos.x, mAutoBackOriginPos.y);
invalidate();
}
}
//在边界拖动时回调
@Override
public void onEdgeDragStarted(int edgeFlags, int pointerId) {
mDragger.captureChildView(mEdgeTrackerView, pointerId);
}
});
//需要使用边界检测
mDragger.setEdgeTrackingEnabled(ViewDragHelper.EDGE_LEFT);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
return mDragger.shouldInterceptTouchEvent(event);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
mDragger.processTouchEvent(event);
return true;
}
@Override
public void computeScroll() {
if (mDragger.continueSettling(true)) {
invalidate();
}
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
mAutoBackOriginPos.x = mAutoBackView.getLeft();
mAutoBackOriginPos.y = mAutoBackView.getTop();
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
mDragView = getChildAt(0);
mAutoBackView = getChildAt(1);
mEdgeTrackerView = getChildAt(2);
}
}
布局文件:
<com.itydl.a11viewdraghelperstudy.view.VDHLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
>
<TextView
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_gravity="center"
android:layout_margin="10dp"
android:background="#44ff0000"
android:gravity="center"
android:text="I can be dragged !"/>
<TextView
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_gravity="center"
android:layout_margin="10dp"
android:background="#44556600"
android:gravity="center"
android:text="I can be dragged !"/>
<TextView
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_gravity="center"
android:layout_margin="10dp"
android:background="#44ff00ff"
android:gravity="center"
android:text="I can be dragged !"/>
</com.itydl.a11viewdraghelperstudy.view.VDHLayout>
第一个View正常的拖拽操作。
第二个View,我们在onLayout之后保存了最开启的位置信息,最主要还是重写了Callback中的onViewReleased,我们在onViewReleased中判断如果是mAutoBackView则调用settleCapturedViewAt回到初始的位置。大家可以看到紧随其后的代码是invalidate();因为其内部使用的是mScroller.startScroll,所以别忘了需要invalidate()以及结合computeScroll方法一起。也可以使用:ViewCompat.postInvalidateOnAnimation(SweepView.this);效果等同于invalidate()---->会调用computeScroll
第三个View,我们在onEdgeDragStarted回调方法中,主动通过captureChildView对其进行捕获,该方法可以绕过tryCaptureView,所以我们的tryCaptureView虽然并非返回true,但却不影响。注意如果需要使用边界检测需要添加mDragger.setEdgeTrackingEnabled(ViewDragHelper.EDGE_LEFT);。
参数可以传入很多值,分别表示左上右下边距,以及所有边距都包括几个值。设置之后,在指定的边距可以响应边距事件onEdgeDragStarted回调才可生效。
接下来我们修改下我们的布局文件,我们把我们的TextView全部加上clickable=true,意思就是子View可以消耗事件。再次运行,你会发现本来可以拖动的View不动了,(如果有拿Button测试的兄弟应该已经发现这个问题了)。
原因是什么呢?主要是因为,如果子View不消耗事件,那么整个手势(DOWN-MOVE*-UP)都是直接进入onTouchEvent,在onTouchEvent的DOWN的时候就确定了captureView。如果消耗事件,那么就会先走onInterceptTouchEvent方法,判断是否可以捕获,而在判断的过程中会去判断另外两个回调的方法:getViewHorizontalDragRange和getViewVerticalDragRange,只有这两个方法返回大于0的值才能正常的捕获。
所以,如果你用Button测试,或者给TextView添加了clickable = true ,都记得重写下面这两个方法:
@Override
public int getViewHorizontalDragRange(View child)
{
return getMeasuredWidth()-child.getMeasuredWidth();
}
@Override
public int getViewVerticalDragRange(View child)
{
return getMeasuredHeight()-child.getMeasuredHeight();
}
方法的返回值应当是该childView横向或者纵向的移动的范围,当前如果只需要一个方向移动,可以只复写一个。红笔是网上很多博客都是这么解释的,但是这两个方法具体含义有待后序了解更多吧,因为我发现只要是返回值>0,效果都是一样的。后面了解的更多了会补充这一块。
总结下,方法的大致的回调顺序:
shouldInterceptTouchEvent:
DOWN:
getOrderedChildIndex(findTopChildUnder)
->onEdgeTouched
MOVE:
getOrderedChildIndex(findTopChildUnder)
->getViewHorizontalDragRange &
getViewVerticalDragRange(checkTouchSlop)(MOVE中可能不止一次)
->clampViewPositionHorizontal&
clampViewPositionVertical
->onEdgeDragStarted
->tryCaptureView
->onViewCaptured
->onViewDragStateChanged
processTouchEvent:
DOWN:
getOrderedChildIndex(findTopChildUnder)
->tryCaptureView
->onViewCaptured
->onViewDragStateChanged
->onEdgeTouched
MOVE:
->STATE==DRAGGING:dragTo
->STATE!=DRAGGING:
onEdgeDragStarted
->getOrderedChildIndex(findTopChildUnder)
->getViewHorizontalDragRange&
getViewVerticalDragRange(checkTouchSlop)
->tryCaptureView
->onViewCaptured
->onViewDragStateChanged
从上面也可以解释,我们在之前TextView(clickable=false)的情况下,没有编写getViewHorizontalDragRange方法时,是可以移动的。因为直接进入processTouchEvent的DOWN,然后就onViewCaptured、onViewDragStateChanged(进入DRAGGING状态),接下来MOVE就直接dragTo了。
而当子View消耗事件的时候,就需要走shouldInterceptTouchEvent,MOVE的时候经过一系列的判断(getViewHorizontalDragRange,clampViewPositionVertical等),才能够去tryCaptureView。