Android 事件系列-4 ViewDragHelper

Posted by xflyme on April 9, 2017

前言

自定义 ViewGroup 有时候需要对子 View 进行拖拽处理,Android 提供了一个很好的工具类 ViewDragHelper,本节我们学习一下它。

Demo

我们先看一个 Demo,首先定义一个 ViewGroup,它继承了 LinearLayout 源码如下:

public class DragLayout extends LinearLayout {

    static final String TAG = "DragLayout";

    private ViewDragHelper mDragger;

    private ViewDragHelper.Callback callback;

    private ImageView iv1;
    private ImageView iv2;

    private Point initPointPosition = new Point();

    @Override
    protected void onFinishInflate() {
        iv1 = (ImageView) this.findViewById(R.id.iv1);
        iv2 = (ImageView) this.findViewById(R.id.iv2);
        super.onFinishInflate();

    }

    public DragLayout(Context context) {
        super(context);

    }

    public DragLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        callback = new DraggerCallBack();
        //第二个参数就是滑动灵敏度的意思 可以随意设置
        mDragger = ViewDragHelper.create(this, 1.0f, callback);
    }

    class DraggerCallBack extends ViewDragHelper.Callback {

        //这个地方实际上函数返回值为true就代表可以滑动 为false 则不能滑动
        @Override
        public boolean tryCaptureView(View child, int pointerId) {
            if (child == iv2) {
                return false;
            }
            return true;
        }


        //这个地方实际上left就代表 你将要移动到的位置的坐标。返回值就是最终确定的移动的位置。
        // 我们要让view滑动的范围在我们的layout之内
        //实际上就是判断如果这个坐标在layout之内 那我们就返回这个坐标值。
        //如果这个坐标在layout的边界处 那我们就只能返回边界的坐标给他。不能让他超出这个范围
        //除此之外就是如果你的layout设置了padding的话,也可以让子view的活动范围在padding之内的.

        @Override
        public int clampViewPositionHorizontal(View child, int left, int dx) {
            //取得左边界的坐标
            final int leftBound = getPaddingLeft();
            //取得右边界的坐标
            final int rightBound = getWidth() - child.getWidth() - getPaddingRight();
            //这个地方的含义就是 如果left的值 在leftbound和rightBound之间 那么就返回left
            //如果left的值 比 leftbound还要小 那么就说明 超过了左边界 那我们只能返回给他左边界的值
            //如果left的值 比rightbound还要大 那么就说明 超过了右边界,那我们只能返回给他右边界的值
            return Math.min(Math.max(left, leftBound), rightBound);
        }

        //纵向的注释就不写了 自己体会
        @Override
        public int clampViewPositionVertical(View child, int top, int dy) {
            final int topBound = getPaddingTop();
            final int bottomBound = getHeight() - child.getHeight() - getPaddingBottom();
            return Math.min(Math.max(top, topBound), bottomBound);
        }

        @Override
        public void onViewReleased(View releasedChild, float xvel, float yvel) {
            //松手的时候 判断如果是这个view 就让他回到起始位置
            /*if (releasedChild == iv1) {
                //这边代码你跟进去去看会发现最终调用的是startScroll这个方法 所以我们就明白还要在computeScroll方法里刷新
                mDragger.settleCapturedViewAt(initPointPosition.x, initPointPosition.y);
                invalidate();
            }*/
        }
    }

    @Override
    public void computeScroll() {
        if (mDragger.continueSettling(true)) {
            invalidate();
        }
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        return super.dispatchTouchEvent(ev);
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        super.onLayout(changed, l, t, r, b);
        //布局完成的时候就记录一下位置
        Log.e(TAG,"onLayout");
        initPointPosition.x = iv1.getLeft();
        initPointPosition.y = iv1.getTop();
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {

        switch (ev.getAction()){
            case MotionEvent.ACTION_DOWN:

                break;
        }

        //决定是否拦截当前事件
        return mDragger.shouldInterceptTouchEvent(ev);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        //处理事件
        mDragger.processTouchEvent(event);
        return true;
    }


}

布局文件:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <me.xfly.viewdragdemo.DragLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:paddingLeft="0dp"
        android:paddingRight="15dp"
        android:paddingTop="0dp"
        android:paddingBottom="15dp"
        android:orientation="vertical">

        <ImageView
            android:id="@+id/iv1"
            android:layout_width="100dp"
            android:layout_height="100dp"
            android:background="#ff0000"
            android:layout_gravity="center_horizontal"
            android:src="@drawable/aaa"></ImageView>

        <ImageView
            android:id="@+id/iv2"
            android:layout_width="100dp"
            android:layout_height="100dp"
            android:layout_gravity="center_horizontal"
            android:src="@drawable/bbb"></ImageView>


    </me.xfly.viewdragdemo.DragLayout>

</LinearLayout>

实现效果如下: 图1

ViewDragHelper 的 API

  • ViewDragHelper create (ViewGroup forParent, ViewDragHelper.Callback cb) 一个静态的创建方法。
    • 参数一 ViewGroup 自身
    • 参数二 一个回掉,用于实现真正的事件处理
  • shouldInterceptTouchEvent(MotionEvent ev) 判断是否要拦截事件,在 onInterceptTouchEvent 中调用。由 ViewDragHelper 来判断是否要拦截事件。
    • 参数 MotionEvent 代表产生的事件
  • processTouchEvent(MotionEvent event) 处理相应事件的方法,在 onTouchEvent 中调用的时候要将返回结果设置为true,否则事件不会被消费。

基本上使用上面这三个 API 就能实现拖拽效果了。其他的如果写后续进阶系列在详细学习。

ViewDragHelper.Callback的API

  • tryCaptureView(View child, int pointerId) 这是一个抽象方法,必须要实现,这个方法返回 true 的时候才生效。
    • 参数一:捕获的 View 也就是你拖动的那个 View。
    • 参数二:手指的 Id。
  • onViewDragStateChanged(int state) 当状态改变的时候回掉。
    • state 状态,有三种 STATE_IDLE 闲置状态,STATE_DRAGGING 正在拖动,STATE_SETTLING 放置到某个位置
  • onViewPositionChanged(View changedView, int left, int top, int dx, int dy) 被拖动的 View 位置发生改变的时候回掉
    • 参数一 View changedView 被拖动的子 View
    • 参数二 int left 距离左边的距离
    • 参数三 int top 距离顶部的距离
    • 参数四 int dx x 轴上运动的距离
    • 参数五 int dy y 轴上运动的距离
  • onViewCaptured(View capturedChild, int activePointerId) 捕获 View 时调用的方法
    • 参数一 View capturedChild 被捕获的 View
    • 参数二 int activePointerId 有效的手指 Id
  • onViewReleased(View releasedChild, float xvel, float yvel) View 被释放的时候调用。
    • 参数一 View releasedChild 被释放的子 View
    • 参数二 x 轴的速率
    • 参数三 y 轴的速率
  • clampViewPositionVertical(View child, int top, int dy) 计算拖动时垂直位置的方法。
    • View child 被拖动的子 View
    • int top 距离顶部的距离
    • int dy y 轴上的变化量
  • clampViewPositionHorizontal(View child, int left, int dx) 计算拖动时水平位置的方法。
    • View child 被拖动的子 View
    • int left 距离左边的距离
    • int dx x 轴上的变化量

比较常用的方法有这么多,但是不需要全部实现,像我们上面的 Demo 只实现了其中的三个方法。

源码解析

ViewGroup 处理事件的第一步是判断要不要拦截,当我们用 ViewDragHelper 进行拖动事件处理的时候,是否拦截事件也委托给 ViewDragHelper 处理。这里真正的处理方法是 shouldInterceptTouchEvent 我们看一下源码: ViewDragHelper#shouldInterceptTouchEvent

public boolean shouldInterceptTouchEvent(MotionEvent ev) {
        //获取action
        final int action = MotionEventCompat.getActionMasked(ev);
        //获取action对应的index
        final int actionIndex = MotionEventCompat.getActionIndex(ev);

        //如果是按下的action则重置一些信息,包括各种事件点的数组
        if (action == MotionEvent.ACTION_DOWN) {
            // Reset things for a new event stream, just in case we didn't get
            // the whole previous stream.
            cancel();
        }
        //初始化mVelocityTracker
        if (mVelocityTracker == null) {
            mVelocityTracker = VelocityTracker.obtain();
        }
        mVelocityTracker.addMovement(ev);

        //根据action来做相应的处理
        switch (action) {
            case MotionEvent.ACTION_DOWN: {
                final float x = ev.getX();
                final float y = ev.getY();
                //获取这个事件对应的pointerId,一般情况下只有一个手指触摸时为0
                //两个手指触摸时第二个手指触摸返回的pointerId为1,以此类推
                final int pointerId = MotionEventCompat.getPointerId(ev, 0);
                //保存点的数据
                //TODO (1)
                saveInitialMotion(x, y, pointerId);
                //获取当前触摸点下最顶层的子View
                //TODO (2)
                final View toCapture = findTopChildUnder((int) x, (int) y);

                //如果toCapture是已经捕获的View,而且正在处于被释放状态
                //那么就重新捕获
                if (toCapture == mCapturedView && mDragState == STATE_SETTLING) {
                    tryCaptureViewForDrag(toCapture, pointerId);
                }

                //如果触摸了边缘,回调callback的onEdgeTouched()方法
                final int edgesTouched = mInitialEdgesTouched[pointerId];
                if ((edgesTouched & mTrackingEdges) != 0) {
                    mCallback.onEdgeTouched(edgesTouched & mTrackingEdges, pointerId);
                }
                break;
            }

            //当又有一个手指触摸时
            case MotionEventCompat.ACTION_POINTER_DOWN: {
                final int pointerId = MotionEventCompat.getPointerId(ev, actionIndex);
                final float x = MotionEventCompat.getX(ev, actionIndex);
                final float y = MotionEventCompat.getY(ev, actionIndex);

                //保存触摸信息
                saveInitialMotion(x, y, pointerId);

                //因为同一时间ViewDragHelper只能操控一个View,所以当有新的手指触摸时
                //只讨论当无触摸发生时,回调边缘触摸的callback
                //或者正在处于释放状态时重新捕获View
                if (mDragState == STATE_IDLE) {
                    final int edgesTouched = mInitialEdgesTouched[pointerId];
                    if ((edgesTouched & mTrackingEdges) != 0) {
                        mCallback.onEdgeTouched(edgesTouched & mTrackingEdges, pointerId);
                    }
                } else if (mDragState == STATE_SETTLING) {
                    // Catch a settling view if possible.
                    final View toCapture = findTopChildUnder((int) x, (int) y);
                    if (toCapture == mCapturedView) {
                        tryCaptureViewForDrag(toCapture, pointerId);
                    }
                }
                break;
            }

            //当手指移动时
            case MotionEvent.ACTION_MOVE: {
                if (mInitialMotionX == null || mInitialMotionY == null) break;

                // First to cross a touch slop over a draggable view wins. Also report edge drags.
                //得到触摸点的数量,并循环处理,只处理第一个发生了拖拽的事件
                final int pointerCount = MotionEventCompat.getPointerCount(ev);
                for (int i = 0; i < pointerCount; i++) {
                    final int pointerId = MotionEventCompat.getPointerId(ev, i);
                    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];
                    //获取当前触摸点下最顶层的子View
                    final View toCapture = findTopChildUnder((int) x, (int) y);
                    //如果找到了最顶层View,并且产生了拖动(checkTouchSlop()返回true)
                    //TODO (3)
                    final boolean pastSlop = toCapture != null && checkTouchSlop(toCapture, dx, dy);
                    if (pastSlop) {
                        //根据callback的四个方法getView[Horizontal|Vertical]DragRange和
                        //clampViewPosition[Horizontal|Vertical]来检查是否可以拖动
                        final int oldLeft = toCapture.getLeft();
                        final int targetLeft = oldLeft + (int) dx;
                        final int newLeft = mCallback.clampViewPositionHorizontal(toCapture,
                                targetLeft, (int) dx);
                        final int oldTop = toCapture.getTop();
                        final int targetTop = oldTop + (int) dy;
                        final int newTop = mCallback.clampViewPositionVertical(toCapture, targetTop,
                                (int) dy);
                        final int horizontalDragRange = mCallback.getViewHorizontalDragRange(
                                toCapture);
                        final int verticalDragRange = mCallback.getViewVerticalDragRange(toCapture);
                        //如果都不允许移动则跳出循环
                        if ((horizontalDragRange == 0 || horizontalDragRange > 0
                                && newLeft == oldLeft) && (verticalDragRange == 0
                                || verticalDragRange > 0 && newTop == oldTop)) {
                            break;
                        }
                    }
                    //记录并回调是否有边缘触摸
                    reportNewEdgeDrags(dx, dy, pointerId);
                    if (mDragState == STATE_DRAGGING) {
                        // Callback might have started an edge drag
                        break;
                    }
                    //如果产生了拖动则调用tryCaptureViewForDrag()
                    //TODO (4)
                    if (pastSlop && tryCaptureViewForDrag(toCapture, pointerId)) {
                        break;
                    }
                }
                //保存触摸点的信息
                saveLastMotion(ev);
                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;
            }
        }

        //如果mDragState等于正在拖拽则返回true
        return mDragState == STATE_DRAGGING;
    }

上面就是整个shouldInterceptTouchEvent()的实现,上面的注释也足够清楚了,我们这里就先不分析某一种触摸事件。

ViewDragHepler#processTouchEvent

 public void processTouchEvent(MotionEvent ev) {
        final int action = MotionEventCompat.getActionMasked(ev);
        final int actionIndex = MotionEventCompat.getActionIndex(ev);

        ...省去部分代码
        switch (action) {
            case MotionEvent.ACTION_DOWN: {
                ...省去部分代码
                break;
            }

            case MotionEventCompat.ACTION_POINTER_DOWN: {
                ...省去部分代码
                break;
            }

            case MotionEvent.ACTION_MOVE: {
                //如果现在已经是拖拽状态
                if (mDragState == STATE_DRAGGING) {
                    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]);

                    //拖拽至指定位置
                    //TODO (5)
                    dragTo(mCapturedView.getLeft() + idx, mCapturedView.getTop() + idy, idx, idy);

                    saveLastMotion(ev);
                } else {
                    // Check to see if any pointer is now over a draggable view.
                    //如果还不是拖拽状态,就检测是否经过了一个View
                    final int pointerCount = MotionEventCompat.getPointerCount(ev);
                    for (int i = 0; i < pointerCount; i++) {
                        final int pointerId = MotionEventCompat.getPointerId(ev, i);
                        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) {
                            // Callback might have started an edge drag.
                            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);
                //如果当前点正在被拖拽,则再剩余还在触摸的点钟寻找是否正在View上
                if (mDragState == STATE_DRAGGING && pointerId == mActivePointerId) {
                    // Try to find another pointer that's still holding on to the captured view.
                    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) {
                            // This one's going away, skip.
                            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) {
                        // We didn't find another pointer still touching the view, release it.
                        //如果没找到则释放View
                        //TODO (6)
                        releaseViewForPointerUp();
                    }
                }
                clearMotionHistory(pointerId);
                break;
            }

            case MotionEvent.ACTION_UP: {
                //如果是拖拽状态的释放则调用
                //releaseViewForPointerUp()
                if (mDragState == STATE_DRAGGING) {
                    releaseViewForPointerUp();
                }
                cancel();
                break;
            }

            case MotionEvent.ACTION_CANCEL: {
                if (mDragState == STATE_DRAGGING) {
                    dispatchViewReleased(0, 0);
                }
                cancel();
                break;
            }
        }
    }

以上是 ViewDragHelper 中的两个关键方法,他们也调用了其他的一些方法,我们来看一下比较关键的几个:

ViewDragHelper#findTopChildUnder

 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;
    }

代码很简单,就是根据 x 和 y 坐标找到目标 View ,注意这里回掉了 callback 中的 getOrderedChildIndex(int index) 方法,它的默认实现是返回当前的 index,如果我们要更改目标 index 可以在 callback 中覆写这个方法。

ViewDragHelper#dragTo

 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) {
            //回调callback来决定View最终被拖拽的x方向上的偏移量
            clampedX = mCallback.clampViewPositionHorizontal(mCapturedView, left, dx);
            //移动View
            ViewCompat.offsetLeftAndRight(mCapturedView, clampedX - oldLeft);
        }
        if (dy != 0) {
            //回调callback来决定View最终被拖拽的y方向上的偏移量
            clampedY = mCallback.clampViewPositionVertical(mCapturedView, top, dy);
            //移动View
            ViewCompat.offsetTopAndBottom(mCapturedView, clampedY - oldTop);
        }

        if (dx != 0 || dy != 0) {
            final int clampedDx = clampedX - oldLeft;
            final int clampedDy = clampedY - oldTop;
            //回调callback
            mCallback.onViewPositionChanged(mCapturedView, clampedX, clampedY,
                    clampedDx, clampedDy);
        }
    }

因为dragTo()方法是在processTouchEvent()中的MotionEvent.ACTION_MOVE case被调用所以当程序运行到这里时View就会不断的被拖动了。如果一旦手指释放则最终会调用releaseViewForPointerUp()方法

ViewDragHelper#releaseViewForPointerUp

 private void releaseViewForPointerUp() {
        //计算出当前x和y方向上的加速度
        mVelocityTracker.computeCurrentVelocity(1000, mMaxVelocity);
        final float xvel = clampMag(
                VelocityTrackerCompat.getXVelocity(mVelocityTracker, mActivePointerId),
                mMinVelocity, mMaxVelocity);
        final float yvel = clampMag(
                VelocityTrackerCompat.getYVelocity(mVelocityTracker, mActivePointerId),
                mMinVelocity, mMaxVelocity);
        dispatchViewReleased(xvel, yvel);
    }

计算完加速度之后就调用了 dispatchViewReleased():

ViewDragHelper#dispatchViewReleased

  private void dispatchViewReleased(float xvel, float yvel) {
        //设定当前正处于释放阶段
        mReleaseInProgress = true;
        //回调callback的onViewReleased()方法
        mCallback.onViewReleased(mCapturedView, xvel, yvel);
        mReleaseInProgress = false;

        //设定状态
        if (mDragState == STATE_DRAGGING) {
            // onViewReleased didn't call a method that would have changed this. Go idle.
            //如果onViewReleased()中没有调用任何方法,则状态设定为STATE_IDLE
            setDragState(STATE_IDLE);
        }
    }

所以最后释放后的处理交给了callback中的onViewReleased()方法,如果我们什么都不做,那么这个被拖拽的View就是停止在当前位置,或者我们可以调用ViewDragHelper提供给我们的这几个方法:

  • settleCapturedViewAt(int finalLeft, int finalTop) 以松手前的滑动速度为初速动,让捕获到的View自动滚动到指定位置。只能在Callback的onViewReleased()中调用。
  • flingCapturedView(int minLeft, int minTop, int maxLeft, int maxTop) 以松手前的滑动速度为初速动,让捕获到的View在指定范围内fling。只能在Callback的onViewReleased()中调用。
  • smoothSlideViewTo(View child, int finalLeft, int finalTop) 指定某个View自动滚动到指定的位置,初速度为0,可在任何地方调用。