ViewDragHelper 是一个关于拖动view的辅助类,一般view的位移有几种方法,一般都是监听view的 onTouchEvent() 事件,我们需要作出一系列判断,如果再加上边界值的判断,比如滑到父控件左边时提示用户到了边缘,滑到了右边时要结束当前Activity,这种情况我们需要进一步判断,添加回调,这样写下来,一方面是麻烦,另外一方面是代码繁琐,不利于扩展并不利于复用,为了解决这些方面的问题,ViewDragHelper 就产生了。
比如说一个ImageView,随着手指的拖动而移动,可以自定义布局如下
public class DragLayout extends FrameLayout {
private ViewDragHelper mDragger;
public DragLayout(Context context) {
this(context, null);
}
public DragLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public DragLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initViewDragHelper();
}
private void initViewDragHelper() {
mDragger = ViewDragHelper.create(this,mCallback);
}
ViewDragHelper.Callback mCallback = new 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 boolean onInterceptTouchEvent(MotionEvent ev) {
return mDragger.shouldInterceptTouchEvent(ev);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
mDragger.processTouchEvent(event);
return true;
}
}
<com.desigin.view.DragLayout
android:id="@+id/ll_drag"
android:layout_width="match_parent"
android:layout_height="match_parent"
>
<ImageView
android:layout_gravity="center_horizontal"
android:layout_width="50dp"
android:layout_height="50dp"
android:background="@drawable/ratingbar"
android:scaleType="centerInside" />
</com.desigin.view.DragLayout>
如此即可实现拖动功能了。
关于 ViewDragHelper 中 Callback 的方法的含义,网上一搜一大片,今天我们着重通过源码分析一下。 看一下 ViewDragHelper 的构造,是通过一个静态方法把当前ViewGroup和callBack 回调当做参数穿进去,创建一个 ViewDragHelper 对象;我们知道,View的触摸事件分发机制中,ViewGroup 有个拦截的方法 onInterceptTouchEvent(),消费事件的方法 onTouchEvent(),这里要注意一点
public boolean onInterceptTouchEvent(MotionEvent ev) {
return mDragger.shouldInterceptTouchEvent(ev);
}
public boolean onTouchEvent(MotionEvent event) {
mDragger.processTouchEvent(event);
return true;
}
onInterceptTouchEvent() 返回的是 ViewDragHelper 中 shouldInterceptTouchEvent() 返回的值,判断是否拦截触摸事件,交割给自己的 onTouchEvent() 方法;onTouchEvent() 方法中是最后的返回值是 true,表示消费掉该触摸事件; 我们来看看这几个方法
public boolean shouldInterceptTouchEvent(MotionEvent ev) {
final int action = MotionEventCompat.getActionMasked(ev);
final int actionIndex = MotionEventCompat.getActionIndex(ev);
if (action == MotionEvent.ACTION_DOWN) {
cancel();
}
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain();
}
mVelocityTracker.addMovement(ev);
switch (action) {
case MotionEvent.ACTION_DOWN: {
final float x = ev.getX();
final float y = ev.getY();
final int pointerId = MotionEventCompat.getPointerId(ev, 0);
saveInitialMotion(x, y, pointerId);
final View toCapture = findTopChildUnder((int) x, (int) y);
// Catch a settling view if possible.
if (toCapture == mCapturedView && mDragState == STATE_SETTLING) {
tryCaptureViewForDrag(toCapture, pointerId);
}
final int edgesTouched = mInitialEdgesTouched[pointerId];
if ((edgesTouched & mTrackingEdges) != 0) {
mCallback.onEdgeTouched(edgesTouched & mTrackingEdges, pointerId);
}
break;
}
case MotionEventCompat.ACTION_POINTER_DOWN: {
...
break;
}
case MotionEvent.ACTION_MOVE: {
...
break;
}
case MotionEventCompat.ACTION_POINTER_UP: {
final int pointerId = MotionEventCompat.getPointerId(ev, actionIndex);
clearMotionHistory(pointerId);
break;
}
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL: {
cancel();
break;
}
}
return mDragState == STATE_DRAGGING;
}
每次触摸事件触发时,当事件未 ACTION_DOWN 时,调用 cancel() 方法, 这个方法是把数据清空还原,便于这一次事件机制中的使用; VelocityTracker 对象使用了缓存技术,类似于 消息机制 Handler 中的 Message 缓存机制,这里使用的是 SynchronizedPool 容器,它是 Pools 的内部类,v4包中有这个类,感兴趣的可以看看,使用了数组来缓存对象,做到复用;
看看switch中 ACTION_DOWN 的逻辑,显示获取到手指在该容器控件中相对自身左上角的位置,MotionEventCompat.getPointerId(ev, 0) 是给触摸点分一个key值,saveInitialMotion() 把当前手指坐标保存到 mInitialMotionX、mLastMotionX、mInitialEdgesTouched 中,getEdgesTouched(int x, int y) 方法是计算这次触摸点是在容器控件的哪个边缘,EDGE_LEFT 是用位移的方式来标识的; findTopChildUnder((int) x, (int) y) 方法给我们展示了一个如何判断当前点击到容器中哪个子控件的方法,先是获取子控件的个数,然后倒序遍历,看看点击点是否在子控件的范围内,在获取子控件时这里对外暴露了一个回调
public View findTopChildUnder(int x, int y) {
final int childCount = mParentView.getChildCount();
for (int i = childCount - 1; i >= 0; i--) {
final View child = mParentView.getChildAt(mCallback.getOrderedChildIndex(i));
if (x >= child.getLeft() && x < child.getRight() &&
y >= child.getTop() && y < child.getBottom()) {
return child;
}
}
return null;
}
// Callback
public int getOrderedChildIndex(int index) {
return index;
}
mCallback.getOrderedChildIndex(i) 这个回调,默认的值是传进去的值,如果有两个view重叠了,这样mParentView.getChildAt(mCallback.getOrderedChildIndex(i)) 获取的就是上层的view,因为 for 循环时倒序的,如果我们想获取下面的view,那么我们需要在自定义的 mCallback 中重写这个方法
@Override
public int getOrderedChildIndex(int index) {
return getChildCount() - 1 - index;
}
即可,这样就获取底层的view;第一次触摸事件时,mCapturedView 为 null,忽略,后面再分析;接着就是获取 edgesTouched 值,判断是否执行 mCallback.onEdgeTouched() 回调,如果我们的触摸点是在容器的边缘,满足 getEdgesTouched(int x, int y) 中的条件,则会执行 onEdgeTouched() 回调,告知触发边缘了。 平常中也就 ACTION_DOWN 比较重要,省略的两个基本用不到,最后两个都是 cancel() 方法,比较简单。
public void processTouchEvent(MotionEvent ev) {
...
switch (action) {
case MotionEvent.ACTION_DOWN: {
final float x = ev.getX();
final float y = ev.getY();
final int pointerId = MotionEventCompat.getPointerId(ev, 0);
final View toCapture = findTopChildUnder((int) x, (int) y);
saveInitialMotion(x, y, pointerId);
tryCaptureViewForDrag(toCapture, pointerId);
final int edgesTouched = mInitialEdgesTouched[pointerId];
if ((edgesTouched & mTrackingEdges) != 0) {
mCallback.onEdgeTouched(edgesTouched & mTrackingEdges, pointerId);
}
break;
}
case MotionEventCompat.ACTION_POINTER_DOWN: {
...
if (mDragState == STATE_IDLE) {
final View toCapture = findTopChildUnder((int) x, (int) y);
tryCaptureViewForDrag(toCapture, pointerId);
final int edgesTouched = mInitialEdgesTouched[pointerId];
if ((edgesTouched & mTrackingEdges) != 0) {
mCallback.onEdgeTouched(edgesTouched & mTrackingEdges, pointerId);
}
} else if (isCapturedViewUnder((int) x, (int) y)) {
tryCaptureViewForDrag(mCapturedView, pointerId);
}
break;
}
case MotionEvent.ACTION_MOVE: {
if (mDragState == STATE_DRAGGING) {
if (!isValidPointerForActionMove(mActivePointerId)) break;
final int index = MotionEventCompat.findPointerIndex(ev, mActivePointerId);
final float x = MotionEventCompat.getX(ev, index);
final float y = MotionEventCompat.getY(ev, index);
final int idx = (int) (x - mLastMotionX[mActivePointerId]);
final int idy = (int) (y - mLastMotionY[mActivePointerId]);
dragTo(mCapturedView.getLeft() + idx, mCapturedView.getTop() + idy, idx, idy);
saveLastMotion(ev);
} else {
final int pointerCount = MotionEventCompat.getPointerCount(ev);
for (int i = 0; i < pointerCount; i++) {
final int pointerId = MotionEventCompat.getPointerId(ev, i);
if (!isValidPointerForActionMove(pointerId)) continue;
final float x = MotionEventCompat.getX(ev, i);
final float y = MotionEventCompat.getY(ev, i);
final float dx = x - mInitialMotionX[pointerId];
final float dy = y - mInitialMotionY[pointerId];
reportNewEdgeDrags(dx, dy, pointerId);
if (mDragState == STATE_DRAGGING) {
break;
}
final View toCapture = findTopChildUnder((int) x, (int) y);
if (checkTouchSlop(toCapture, dx, dy) &&
tryCaptureViewForDrag(toCapture, pointerId)) {
break;
}
}
saveLastMotion(ev);
}
break;
}
case MotionEventCompat.ACTION_POINTER_UP: {
final int pointerId = MotionEventCompat.getPointerId(ev, actionIndex);
if (mDragState == STATE_DRAGGING && pointerId == mActivePointerId) {
int newActivePointer = INVALID_POINTER;
final int pointerCount = MotionEventCompat.getPointerCount(ev);
for (int i = 0; i < pointerCount; i++) {
final int id = MotionEventCompat.getPointerId(ev, i);
if (id == mActivePointerId) {
continue;
}
final float x = MotionEventCompat.getX(ev, i);
final float y = MotionEventCompat.getY(ev, i);
if (findTopChildUnder((int) x, (int) y) == mCapturedView &&
tryCaptureViewForDrag(mCapturedView, id)) {
newActivePointer = mActivePointerId;
break;
}
}
if (newActivePointer == INVALID_POINTER) {
releaseViewForPointerUp();
}
}
clearMotionHistory(pointerId);
break;
}
case MotionEvent.ACTION_UP: {
if (mDragState == STATE_DRAGGING) {
releaseViewForPointerUp();
}
cancel();
break;
}
case MotionEvent.ACTION_CANCEL: {
if (mDragState == STATE_DRAGGING) {
dispatchViewReleased(0, 0);
}
cancel();
break;
}
}
}
processTouchEvent(MotionEvent ev) 方法,switch 之前的代码和 shouldInterceptTouchEvent() 方法一样,忽略;ACTION_DOWN 中,前面的也忽略,直接看 tryCaptureViewForDrag()方法
boolean tryCaptureViewForDrag(View toCapture, int pointerId) {
if (toCapture == mCapturedView && mActivePointerId == pointerId) {
return true;
}
if (toCapture != null && mCallback.tryCaptureView(toCapture, pointerId)) {
mActivePointerId = pointerId;
captureChildView(toCapture, pointerId);
return true;
}
return false;
}
第一行的if判断,是为了防止同类型的view对象,重复执行下面的代码而做的判断;toCapture 就是我们手指点中的view,这里有个 mCallback.tryCaptureView(toCapture, pointerId)回调,我们的例子中返回的是固定的 true,所以只要在该容器中的view,都可以被拖拽;如果有A和B两个view,我们只想让A移动而B不移动,则我们实现tryCaptureView()方法中,判断toCapture 是否是A即可,ViewGroup 中绘制完子控件后会执行 onFinishInflate() 方法,可以在这个方法中通过 findViewById 或 getChildAt() 来找到A;再看看if中的 captureChildView() 方法
public void captureChildView(View childView, int activePointerId) {
if (childView.getParent() != mParentView) {
throw new IllegalArgumentException("captureChildView: parameter must be a descendant " +
"of the ViewDragHelper's tracked parent view (" + mParentView + ")");
}
mCapturedView = childView;
mActivePointerId = activePointerId;
mCallback.onViewCaptured(childView, activePointerId);
setDragState(STATE_DRAGGING);
}
void setDragState(int state) {
mParentView.removeCallbacks(mSetIdleRunnable);
if (mDragState != state) {
mDragState = state;
mCallback.onViewDragStateChanged(state);
if (mDragState == STATE_IDLE) {
mCapturedView = null;
}
}
}
把点中的view赋值给 mCapturedView,然后回调 mCallback.onViewCaptured() 方法,告知手指选中view了,可以在里面做些逻辑,比如记录该view的原始位置,setDragState() 中改变 mDragState 的值,同时回调 mCallback.onViewDragStateChanged() 表示拖拽状态的值改变了。 ACTION_DOWN 中下面的代码是边缘触发事件,不做分析。
接下里看看 ACTION_MOVE 的逻辑,如果点中了要移动的控件, ACTION_DOWN 中 saveInitialMotion(x, y, pointerId) 保存了 ev.getX() 和 ev.getY() 的值,ACTION_MOVE 中则计算出偏移量 idx 和 idy,然后调用
dragTo(mCapturedView.getLeft() + idx, mCapturedView.getTop() + idy, idx, idy) 方法来对控件进行位移,然后继续调用 saveLastMotion() 方法保存当前触摸点的值
private void dragTo(int left, int top, int dx, int dy) {
int clampedX = left;
int clampedY = top;
final int oldLeft = mCapturedView.getLeft();
final int oldTop = mCapturedView.getTop();
if (dx != 0) {
clampedX = mCallback.clampViewPositionHorizontal(mCapturedView, left, dx);
ViewCompat.offsetLeftAndRight(mCapturedView, clampedX - oldLeft);
}
if (dy != 0) {
clampedY = mCallback.clampViewPositionVertical(mCapturedView, top, dy);
ViewCompat.offsetTopAndBottom(mCapturedView, clampedY - oldTop);
}
if (dx != 0 || dy != 0) {
final int clampedDx = clampedX - oldLeft;
final int clampedDy = clampedY - oldTop;
mCallback.onViewPositionChanged(mCapturedView, clampedX, clampedY,
clampedDx, clampedDy);
}
}
看方法中,clampedX 本身的值移动后的值,这里有再次赋值的方法 clampedX = mCallback.clampViewPositionHorizontal(mCapturedView, left, dx),这是个回调,这个方法控制的是view 的要移到的位置的值,如果我们回调中原封不动的返回
@Override
public int clampViewPositionHorizontal(View child, int left, int dx) {
return left;
}
则 clampedX 值和传入的 left 值一样,然后是 ViewCompat.offsetTopAndBottom() 方法,偏移量是 clampedY - oldTop ,这样x轴上就能自由移动了,offsetTopAndBottom() 也是同样的道理,下面的if判断,回调了 mCallback.onViewPositionChanged() 方法,这个方法中是告诉开发者 view 在不停的位移,可以在这个里面做一些监听事件的触发;上文中例子是控件可以到处移动,如果我们想让view只能横着滑动,竖直方向不变,则可以这样做
private Point mInitPosition = new Point();
@Override
public void onViewCaptured(View capturedChild, int activePointerId) {
mInitPosition.y = capturedChild.getTop();
}
@Override
public int clampViewPositionVertical(View child, int top, int dy) {
return mInitPosition.y;
}
在手指按下触发 onViewCaptured() 回调记录 view 的距离父控件顶部的距离,然后再拖动回调 clampViewPositionVertical() 中返回view的top值,这样view在竖直方向上就不会滑动了。如果 mDragState != STATE_DRAGGING 这里面的逻辑,重点看 reportNewEdgeDrags(dx, dy, pointerId) 方法,里面判断是否触发边缘控制回调 onEdgeDragStarted(),如果想让边缘触发生效,需要在创建 ViewDragHelper 时,调用 setEdgeTrackingEnabled() 方法赋值,允许哪些边缘触发。
ACTION_UP 中执行 releaseViewForPointerUp() 方法,最终执行 dispatchViewReleased() 方法;ACTION_CANCEL 中也是执行 dispatchViewReleased(0, 0) 方法;同时都执行了cancel() 方法
private void dispatchViewReleased(float xvel, float yvel) {
mReleaseInProgress = true;
mCallback.onViewReleased(mCapturedView, xvel, yvel);
mReleaseInProgress = false;
if (mDragState == STATE_DRAGGING) {
setDragState(STATE_IDLE);
}
}
执行了 mCallback.onViewReleased(mCapturedView, xvel, yvel) 回调,意思是手指松开了,我们可以在这个方法中做一些操作,比如把滑动的view归回原位,触发一些回调事件等等,这里也执行了 setDragState() 方法,修改标识。
ACTION_POINTER_DOWN 方法,这个触发的时机是多点触摸时触发,上面代码老规矩,保存数据,pointerId 是多点触摸的索引;如果没有滑动,触发多点触摸事件,执行 if (mDragState== STATE_IDLE) 中判断,此时的逻辑和单点按下的逻辑一样;如果view正在滑动中,又有一个点按下,此时 isCapturedViewUnder((int) x, (int) y) 判断是否点中了该view,如果点中了, tryCaptureViewForDrag() 回调事件触发,onViewCaptured() 回调会再次触发,所以如果要在这个里面保存view的原始位置,要做一番判断,防止保存错误了。 ACTION_POINTER_UP事件时多点触摸有一个点消失了的回调,这里面没有重要的,就是点中view的手指抬起时,也会触发 onViewCaptured() 回调。
如果我们在 tryCaptureView() 方法中返回false 还想让view可以滑动,可以调用 mDragger.captureChildView(view, pointerId) 方法,至于原因,看源码,这个方法一般是在边缘触发的方法中调用,这个就是 ACTION_MOVE 方法中 if(mDragState != STATE_DRAGGING)的逻辑。如果我们想在滑动view手指松开后,view 回到原位值,可以调用settleCapturedViewAt()方法和 invalidate(),settleCapturedViewAt() 内部调用的 forceSettleCapturedViewAt() 方法
private boolean forceSettleCapturedViewAt(int finalLeft, int finalTop, int xvel, int yvel) {
final int startLeft = mCapturedView.getLeft();
final int startTop = mCapturedView.getTop();
final int dx = finalLeft - startLeft;
final int dy = finalTop - startTop;
if (dx == 0 && dy == 0) {
mScroller.abortAnimation();
setDragState(STATE_IDLE);
return false;
}
final int duration = computeSettleDuration(mCapturedView, dx, dy, xvel, yvel);
mScroller.startScroll(startLeft, startTop, dx, dy, duration);
setDragState(STATE_SETTLING);
return true;
}
通过获取它现在的位置及要滑到的位置,算出x和y轴的偏移量,然后通过了 mScroller.startScroll(startLeft, startTop, dx, dy, duration) 这个方法来实现位移,看到这,mScroller 是 ScrollerCompat 对象,是对低版本 Scroller 做了适配,明白 Scroller 的偏移就明白了这里的写法,同时ViewGroup容器中需要重写 computeScroll() 方法,在它中
@Override
public void computeScroll() {
if (mDragger.continueSettling(true)) {
ViewCompat.postInvalidateOnAnimation(this);
}
}
public boolean continueSettling(boolean deferCallbacks) {
if (mDragState == STATE_SETTLING) {
boolean keepGoing = mScroller.computeScrollOffset();
final int x = mScroller.getCurrX();
final int y = mScroller.getCurrY();
final int dx = x - mCapturedView.getLeft();
final int dy = y - mCapturedView.getTop();
if (dx != 0) {
ViewCompat.offsetLeftAndRight(mCapturedView, dx);
}
if (dy != 0) {
ViewCompat.offsetTopAndBottom(mCapturedView, dy);
}
if (dx != 0 || dy != 0) {
mCallback.onViewPositionChanged(mCapturedView, x, y, dx, dy);
}
if (keepGoing && x == mScroller.getFinalX() && y == mScroller.getFinalY()) {
mScroller.abortAnimation();
keepGoing = false;
}
if (!keepGoing) {
if (deferCallbacks) {
mParentView.post(mSetIdleRunnable);
} else {
setDragState(STATE_IDLE);
}
}
}
return mDragState == STATE_SETTLING;
}
真正的位移是在 continueSettling() 中执行的,通过 mScroller 获取 getCurrX() 当前x轴所在的位置,计算出差值,然后调用view的 offsetLeftAndRight() 方法;y轴也是同理。当位移结束时,执行 mSetIdleRunnable 方法,调用 setDragState(STATE_IDLE) ,修改状态值。 smoothSlideViewTo() 方法与它类似,区别是可以指定要移动的view。
最后说一句,view位移时,会不停的触发 onViewPositionChanged() 回调,所以如果在这个方法中判断view的位移区间进而控制view的UI显示时,比如,view滑到中间偏左是显示太阳,中间偏右时显示月亮,这是我们最好做个判断,如果view已经是太阳了,并且还在中间偏左滑动,此时就没必要重新给UI设置背景了,加个判断可以优化代码。