Skip to content

Instantly share code, notes, and snippets.

@ijinxiao
Last active December 20, 2022 03:36
Show Gist options
  • Save ijinxiao/8073ffb5d299f16c12f8c13b60356375 to your computer and use it in GitHub Desktop.
Save ijinxiao/8073ffb5d299f16c12f8c13b60356375 to your computer and use it in GitHub Desktop.

ViewDragHelper为一个辅助实现View拖动的工具类,如果在自定义的ViewGroup中需要实现对内部View的拖动处理的话,使用ViewDragHelper处理会比较方便。

一. ViewDragHelper使用模式简单示例

在介绍ViewDragHelper实现原理前,先给出一个在ViewGroup中使用ViewDragHleper的简单模式:

    private void init() {
        mViewDragHelper = ViewDragHelper.create(this, 1f, viewDragCallBack);
    }
	
    private ViewDragHelper.Callback viewDragCallBack = new ViewDragHelper.Callback() {
        @Override
        public boolean tryCaptureView(View child, int pointerId) {
            return indexOfChild(child) == 0 && mViewDragHelper.getViewDragState() != ViewDragHelper.STATE_SETTLING;
        }

        @Override
        public void onViewReleased(View releasedChild, float xvel, float yvel) {
            int finalLeft = releasedChild.getWidth();
            int finalTop = releasedChild.getLeft();

            if (mViewDragHelper.settleCapturedViewAt(finalLeft, finalTop)) {
                postInvalidate();
            }

        }
        
        @Override
        public int getViewVerticalDragRange(View child) {
            return child.getHeight();
        }

        @Override
        public int clampViewPositionVertical(View child, int top, int dy) {
            return top;
        }

        @Override
        public int getViewHorizontalDragRange(View child) {
            return child.getWidth();
        }

        @Override
        public int clampViewPositionHorizontal(View child, int left, int dx) {
            return left;
        }
    };
    
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        return mViewDragHelper.shouldInterceptTouchEvent(ev);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        mViewDragHelper.processTouchEvent(event);
        return true;
    }

    @Override
    public void computeScroll() {
        if (mViewDragHelper.continueSettling(true)) {
            postInvalidate();
        }
    }

在需要处理内部View拖拽或是边缘滑动的ViewGroup中使用ViewDragHelper的基本模式就是:

  1. 在ViewGroup中构建一个ViewDragHelper实例,实现ViewDragHelper.Callback中的回调方法,以对推拽或边缘滑动行为进行限制以及对关键事件节点进行响应,如果需要检测边缘滑动,需要通过方法public void setEdgeTrackingEnabled(int edgeFlags)注册想要检测的边缘;
  2. 在ViewGroup的onInterceptTouchEvent()方法中调用并返回ViewDragHelper的shouldInterceptTouchEvent(MotionEvent ev)方法,在ViewGroup的onTouchEvent()方法中调用ViewDragHelper的public void processTouchEvent(MotionEvent ev)方法并返回true;
  3. 拖拽松手后如果要实现View的滑行,在onViewReleased()方法中调用public void flingCapturedView(int minLeft, int minTop, int maxLeft, int maxTop)public boolean settleCapturedViewAt(int finalLeft, int finalTop)方法或在任意地方调用public boolean smoothSlideViewTo(View child, int finalLeft, int finalTop)方法,同时需要调用postInvalidate()引起重绘,然后在computeScroll()方法中调用continueSettling()方法。

二. ViewDragHelper原理

1. ViewDragHelper的构建

使用静态方法构造ViewDragHelper对象:

    public static ViewDragHelper create(ViewGroup forParent, Callback cb)
    public static ViewDragHelper create(ViewGroup forParent, float sensitivity, Callback cb)

参数中forParent即为使用此ViewDragHelper的ViewGroup,而Callback为处理拖动的关键过程中的回调,是使用ViewDragHelper的关键部分。

在构造ViewDragHelper对象时,会同时初始化几个常量,其中包括:mEdgeSize,对于ViewGroup边缘触摸的检测范围,为20dp;mTouchSlop,被判定为滑动事件的移动门限,默认为8dp,但注意第二个构造函数可以传入一个sensitivity参数,此时实际的mTouchSlop值会除以这个sensitivity参数,因此通过sensitivity参数间接控制对滑动手势的敏感性;mMaxVelocitymMinVelocity,当让View快速飞动时的最高和最低速度,默认为8000dp/s和50dp/s,这里mMinVelovity可以在构造函数完之后使用方法 public void setMinVelocity(float minVel) 手动设置。

2. ViewDragHelper.Callback

Callback为联系ViewGroup和ViewDragHelper的桥梁,既包括对于ViewDragHelper处理拖动的关键时刻的回调,也包括了ViewDragHelper在处理时对ViewGroup中的View的可拖动性及可拖动范围等的限制。

回调类:

  1. public void onViewDragStateChanged(int state) 当ViewGroup中的拖动状态改变时调用,拖动状态有:STATE_IDLE,没有View被拖拽或在飞行;STATE_DRAGGING,有View正被跟手拖拽;STATE_SETTLING,有View正滑行到固定位置。

  2. public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) 当被捕获的View位置改变时调用,位置改变可能是因为拖拽也可能是在滑行,left和top参数为View的左上角最新坐标,dx和dy为距离上次调用此方法时的横向和纵向移动距离。

  3. public void onViewCaptured(View capturedChild, int activePointerId) 当View成功被捕获时调用

  4. public void onViewReleased(View releasedChild, float xvel, float yvel) 当View从STATE_DRAGGING状态释放时调用,此时可能采用的处理有两种:一种是什么都不做,让View就留在拖拽到的位置,这时将进入STATE_IDLE状态;另一种是让View继续滑行到某个位置,这种操作通过在onViewReleased()方法中调用public boolean settleCapturedViewAt(int finalLeft, int finalTop)或者public void flingCapturedView(int minLeft, int minTop, int maxLeft, int maxTop)方法来实现,这时将进入STATE_SETTLING状态。

  5. public void onEdgeTouched(int edgeFlags, int pointerId) 当ViewGroup的某个已注册的边缘被触摸且当前无View被捕获的时候调用,edgeFlags为上下左右四个边的组合形式。ViewGroup共4个边缘,需手动调用方法public void setEdgeTrackingEnabled(int edgeFlags)对想要跟踪的边缘进行注册,经此方法注册的边缘才会被监控点击和拖拽

  6. public void onEdgeDragStarted(int edgeFlags, int pointerId) 当检测到从ViewGroup的某个已注册边缘开始拖动且当前无View被捕获的时候调用,对于常用的边缘拖动场景,虽然此时没有View被捕获,但此时可以手动调用captureChildView()方法来捕获一个位于界面之外的View。

限制类:

  1. public int getOrderedChildIndex(int index) 设置在ViewGroup中Z轴序号为index的View在ViewGroup中的序号,Callback中包含此方法是因为在ViewDragHelper尝试捕获View时,其会找到手指触摸点范围以下的最上面那个View,也就是Z轴序号最大的View,默认情况下View的Z轴序号与View在ViewGroup中的序号是一致的,如果需要进行手动设置,就使用此方法进行设置。参数中的index为Z轴index,返回值为View在ViewGroup中的index。比如,ViewGroup中共有4个View,则最上面一个View的Z轴序号为3,默认情况下ViewGroup中序号为3的ViewZ轴序号为3,但若此方法在输入3时返回2,则说明ViewGroup中序号为2的ViewZ轴序号为3,ViewGroup中序号为2的ViewZ轴位置最高。

  2. public abstract boolean tryCaptureView(View child, int pointerId) 当检测到用户可能想以pointId参数指定的触摸点捕获child指定的View时调用,返回值标明是否允许捕获。在此方法可以加入逻辑对可以捕获的View和pointer点进行限制。

  3. public int getViewHorizontalDragRange(View child) 设置被捕获的View在水平方向上可以移动的像素范围

  4. public int getViewVerticalDragRange(View child) 设置被捕获的View在竖直方向上可以移动的像素范围,注意3和4这两个范围默认为0,而水平和述职范围同时为0时View将不可以被捕获拖拽

  5. public int clampViewPositionHorizontal(View child, int left, int dx) 限制被捕获的View某次在水平方向上的真实移动范围,left参数为View希望移动到的x坐标,dx参数为View希望移动的范围,返回值为这次View将会移动到的x坐标,此方法默认返回0,则不允许任何移动,要允许移动,需override其实现,若对拖拽移动无任何限制,则返回left即可,若需要对移动范围有限制,则对left和dx进行逻辑判断后返回合适的限定值。

  6. public int clampViewPositionVertical(View child, int top, int dy) 限制被捕获的View某次在竖直方向上的真实移动范围,同上

  7. public boolean onEdgeLock(int edgeFlags) 设置是否要锁定edgeFlags所标志的ViewGroup的边缘拖拽滑动

3. Touch事件处理机制

(1)事件拦截

按照Touch事件的分发机制,点击ViewGroup时,点击事件经ViewGroup的dispatchTouchEvent(MotionEvent ev)方法首先会进入onInterceptTouchEvent(MotionEvent ev)方法进行处理,而为了使用ViewDragHelper辅助拖动,在ViewGroup的onInterceptTouchEvent()方法中需要进行的处理是调用并返回ViewDragHelper的shouldInterceptTouchEvent(MotionEvent ev)方法,即交由ViewDragHelper来判断是否需要拦截此事件。

由于在处理Touch事件时会多次尝试捕获View,分析具体点击事件先看看捕获View的方法:

   boolean tryCaptureViewForDrag(View toCapture, int pointerId) {
       if (toCapture == mCapturedView && mActivePointerId == pointerId) {
           // Already done!
           return true;
       }
       if (toCapture != null && mCallback.tryCaptureView(toCapture, pointerId)) {
           mActivePointerId = pointerId;
           captureChildView(toCapture, pointerId);
           return true;
       }
       return false;
   }
   
   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);
   }

tryCaptureViewForDrag()方法中,会先判断想要捕获的View及pointer刚好就是现在已经捕获的View和pointer,如果是一样的,说明已成功捕获,无需更多操作,然后可以看出判断条件基本就是callback中定义的tryCaptureView()方法,如果此方法中定义该View可捕获,则就会去捕获该View。

captureChildView()方法就是实际的捕获操作,会调用callback的onViewCaptured()方法,然后将状态设置为STATE_DRAGGING。另外需注意的是,captureChildView()方法是public方法,是可以直接调用的,而直接调用时将会绕过callback中定义的tryCaptureView()方法限制,直接捕获。

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

如果是ACTION_DOWN事件,首先会清除之前记录的打点位置,重新开始记录,随后,如果当前有已捕获的View,且处于STATE_SETTLING状态,则会尝试着再去捕获他;然后,如果判断此次点击事件发生在某个已注册的边缘范围,则会调用相应的onEdgeTouched()方法。

shouldInterceptTouchEvent()方法的返回值为mDragState==STATE_DRAGGING,也就是说只有成功捕获了View才会返回true。而在ACTION_DOWN事件中,只会尝试捕获处于STATE_SETTLING状态的View,实际来说场景不是很通用,因此很大概率会返回false。ViewGroup的onInterceptTouchEvent()方法返回false后,根据事件分发流程,将会进入ViewGroup的各级View的dispatchTouchEvent()中,如果没有View消费此次ACTION_DOWN事件,则会进入ViewGroup的onTouchEvent()事件;而如果有View消费此次ACTION_DOWN事件,则此次ACTION_DOWN事件处理完成,下次ACTION_MOVE继续经由ViewGroup的dispatchTouchEvent()方法进入onInterceptTouchEvent()方法进行处理。

ACTION_MOVE
    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);

                    // If pointer is invalid then skip the ACTION_MOVE.
                    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];

                    final View toCapture = findTopChildUnder((int) x, (int) y);
                    final boolean pastSlop = toCapture != null && checkTouchSlop(toCapture, dx, dy);
                    if (pastSlop) {
                        // check the callback's
                        // getView[Horizontal|Vertical]DragRange methods to know
                        // if you can move at all along an axis, then see if it
                        // would clamp to the same value. If you can't move at
                        // all in every dimension with a nonzero range, bail.
                        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;
                    }

                    if (pastSlop && tryCaptureViewForDrag(toCapture, pointerId)) {
                        break;
                    }
                }
                saveLastMotion(ev);
                break;
            }

在ACTION_MOVE事件中,如果有多个触摸点,将会对多个触摸点依次进行处理。对于每个触摸点的处理,分为尝试捕获View和检测ViewGroup边缘移动两个方面:1. 对于触摸点下存在View的,将会检查触摸点的有效移动范围,这里将会使用到Callback的getViewHorizontalDragRange()getViewVerticalDragRang()方法判断View运行移动的范围是多少,然后通过clampViewPositionHorizontal()clampViewPositionVertical()方法判断此次move操作对于View的有效操作距离是多少,如果根据规则,View的横向和纵向可移动距离均为0的话,会直接结束此次处理,而只要横向或纵向有一个可移动距离大于0,且实际产生了大于TouchSlop的有效移动距离,则会尝试捕获View;2. 对于每个达到移动距离的触摸点,如果之前有记录此触摸点为ViewGroup边缘,且此边缘之前已注册了检测,同时经Callback中的onEdgeLock()方法检测未被锁定,则会调用Callback的onEdgeDragStarted()标明此边缘检测到了滑动。

同样的,如果在ACTION_MOVE事件处理中成功捕获了View,ViewGroup的onInterceptTouchEvent()方法返回了true,则以后的所有事件交由ViewGroup的onTouchEvent()处理。而如果没能捕获View,和ACTION_DOWN一样,根据事件分发流程,将会进入ViewGroup的各级View的dispatchTouchEvent()中,如果没有View消费此次ACTION_DOWN事件,则会进入ViewGroup的onTouchEvent()方法;而如果有View消费此次ACTION_DOWN事件,则此次ACTION_DOWN事件处理完成,下次ACTION_MOVE继续经由ViewGroup的dispatchTouchEvent()方法进入onInterceptTouchEvent()方法进行处理。

可以看出,对于检测边缘滑动来说,ACTION_DOWN和ACTION_MOVE事件中分别负责检测onEdgeTouched()和onEdgeDragStarted()条件的达成;而对于捕获View来说,ACTION_DOWN和ACTION_MOVE事件都会试图捕获有效的View,如果一直没有捕获,ACTION_MOVE事件中一直会尝试捕获,如果捕获成功,则和ViewGroup的onInterceptTouchEvent()的方法就没什么关系了,Touch事件将交由ViewGroup的onTouchEvent()方法处理。

对于ACTION_UP和ACTION_CANCEL两种事件,所做的处理为清空之前记录的打点信息。

  (2)事件处理

如果ViewGroup的onInterceptTouchEvent()方法返回了true,或是ViewGroup的onInterceptTouchEvent()方法返回了false但没有子View消费点击事件,点击事件都会交由ViewGroup的onTouchEvent()方法处理,而为使用ViewDragHelper实现View的拖拽,需要在ViewGroup的onTouchEvent()方法中调用ViewDragHelper的public void processTouchEvent(MotionEvent ev)方法来实际处理Touch事件,且在ViewGroup的onTouchEvent()方法中需要返回true以使之后的事件都交由ViewGroup的onTouchEvent()方法处理。

ACTION_DOWN
    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);

                // Since the parent is already directly processing this touch event,
                // there is no reason to delay for a slop before dragging.
                // Start immediately if possible.
                tryCaptureViewForDrag(toCapture, pointerId);

                final int edgesTouched = mInitialEdgesTouched[pointerId];
                if ((edgesTouched & mTrackingEdges) != 0) {
                    mCallback.onEdgeTouched(edgesTouched & mTrackingEdges, pointerId);
                }
                break;
            }

processTouchEvent()方法中对ACTION_DOWN事件的处理和shouldInterceptTouchEvent()方法中的几乎一致,区别在于shouldInterceptTouchEvent()方法中只有尝试捕获处于STATE_SETTLING状态的View,而processTouchEvent()方法中会尝试对任何状态下的View都进行捕获。

ACTION_MOVE
    case MotionEvent.ACTION_MOVE: {
                if (mDragState == STATE_DRAGGING) {
                    // If pointer is invalid then skip the ACTION_MOVE.
                    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 {
                    // Check to see if any pointer is now over a draggable view.
                    final int pointerCount = MotionEventCompat.getPointerCount(ev);
                    for (int i = 0; i < pointerCount; i++) {
                        final int pointerId = MotionEventCompat.getPointerId(ev, i);

                        // If pointer is invalid then skip the ACTION_MOVE.
                        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) {
                            // 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;
            }

而对于ACTION_MOVE的处理则根据当前所处状态的不同而改变,如果当前处于STATE_DRAGGING状态,则此处的处理为完成View的拖动,View的拖动使用方法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) {
            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);
        }
    }

可以看出,View被拖动的位置需要通过Callback的clampViewPositionHorizontal()clampViewPositionVertical()限制,然后通过offset方法来实现View的移动,然后,如果View发生了移动,Callback方法的onViewPositionChanged()方法会被调用。注意这里View的移动是不受getViewHorizontalDragRange()getViewVerticalDragRange()方法的限制的,可能会对方法的命名产生误解,这两个方法仅在捕获View时checkTouchSlop方法中应用。

如果在处理ACTION_MOVE事件时未处于STATE_DRAGGING状态,所做的处理则是继续尝试捕获View与检测ViewGroup边缘的滑动。捕获View方面此处与shouldInterceptTouchEvent()方法中的ACTION_MOVE处理的不同之处在于,在这里检测有效移动的标准只包括了Callback中getViewHorizontalDragRange()的限制,而未受到clampViewPositionHorizontal()的限制。检测ViewGroup边缘滑动方面则与shouldInterceptTouchEvent()方法中处理方法一致。

ACTION_UP&ACTION_CANCEL
    case MotionEvent.ACTION_UP: {
                if (mDragState == STATE_DRAGGING) {
                    releaseViewForPointerUp();
                }
                cancel();
                break;
            }

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

处理ACTION_UP和ACTION_CANCEL事件时都是调用dispatchViewReleased()方法,区别在与ACTION_UP时会传入松手时View横向和纵向移动的速度,而ACTION_CANCEL时速度视为0。在dispatchViewReleased()方法中,会调用Callback的onViewReleased()方法,如前所述,在onViewReleased()方法中可以有两种处理方法,一种是调用方法使得View进入STATE_SETTLING状态滑行到某个位置,具体实现方法下文再说;另一种就是什么也不做,使得View停止到当前状态,转为STATE_IDLE状态。

    private void dispatchViewReleased(float xvel, float yvel) {
        mReleaseInProgress = true;
        mCallback.onViewReleased(mCapturedView, xvel, yvel);
        mReleaseInProgress = false;

        if (mDragState == STATE_DRAGGING) {
            // onViewReleased didn't call a method that would have changed this. Go idle.
            setDragState(STATE_IDLE);
        }
    }
ACTION_POINTER_UP
    case MotionEventCompat.ACTION_POINTER_UP: {
                final int pointerId = MotionEventCompat.getPointerId(ev, actionIndex);
                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.
                        releaseViewForPointerUp();
                    }
                }
                clearMotionHistory(pointerId);
                break;
            }

如果有多个手指触摸在View上,且当前发生ACTION_POINTER_UP事件的触摸点为之前捕获View的触摸点,则此时会尝试检测其他的触摸点是否能够捕获这一View,如果使用其他的触摸点捕获这一View,则View仍处于STATE_DRAGGING状态,只是捕获的触摸点进行了更换;如果未能有能够继续捕获此View的其他触摸点,则和ACTION_UP事件一样,调用releaseViewForPointerUp()方法释放View。

至此,对ViewDragHelper中对Touch事件处理的分析解释了如果确定能否捕获View、如果捕获View、捕获View后如何移动等问题,而对于捕获View后松手时如何使View继续滑行,有几种方式:

4. View的滑行(STATE_SETTLING)

之前我们看到,在dispatchViewReleased()方法中会调用onViewReleased()方法,在onViewReleased()方法中可以调用public void flingCapturedView(int minLeft, int minTop, int maxLeft, int maxTop)public boolean settleCapturedViewAt(int finalLeft, int finalTop)方法使View继续滑行。

flingCapturedView()
    public void flingCapturedView(int minLeft, int minTop, int maxLeft, int maxTop) {
        if (!mReleaseInProgress) {
            throw new IllegalStateException("Cannot flingCapturedView outside of a call to " +
                    "Callback#onViewReleased");
        }

        mScroller.fling(mCapturedView.getLeft(), mCapturedView.getTop(),
                (int) VelocityTrackerCompat.getXVelocity(mVelocityTracker, mActivePointerId),
                (int) VelocityTrackerCompat.getYVelocity(mVelocityTracker, mActivePointerId),
                minLeft, maxLeft, minTop, maxTop);

        setDragState(STATE_SETTLING);
    }
    
    public boolean settleCapturedViewAt(int finalLeft, int finalTop) {
        if (!mReleaseInProgress) {
            throw new IllegalStateException("Cannot settleCapturedViewAt outside of a call to " +
                    "Callback#onViewReleased");
        }

        return forceSettleCapturedViewAt(finalLeft, finalTop,
                (int) VelocityTrackerCompat.getXVelocity(mVelocityTracker, mActivePointerId),
                (int) VelocityTrackerCompat.getYVelocity(mVelocityTracker, mActivePointerId));
    }

可以看出,这两个方法都需要mReleaseInProgress变量为true才会生效,而根据dispatchViewReleased()方法,mReleaseInProgress只有在onViewReleased()方法中才为true,也就是说,这两个方法只有在onViewReleased()方法中调用才会生效。

flingCapturedView()方法使用了Scroller的fling()方法,此方法提供了View的当前位置以及当前速度,通过当前速度计算速度衰减为0所需的时间,然后根据当前位置以及当前速度的衰减计算出View最终停留的位置,然后考虑到参数minLeft、maxLeft、minTop、maxTop对最终位置的限制,将View在计算的时间内移动到计算出的最终位置处。

根据Scroller的原理,和Scroller的startScroll()方法类似,fling()方法只是确定好了开始位置、结束位置、开始速度、开始时间,然后启动了差值器的计算,真正的移动需要调用invalidate()方法启动重绘,在绘制时调用computeScroll()方法来判断当前Scroller计算出的View移动状态,然后手动移动View,如果Scroller仍在运作,View还未移动到最终位置,则在computeScroll()方法中再调用postInvalidate()方法启动下一次重绘,直至Scroller停止工作。而ViewDragHelper将在computeScroll()方法中判断Scroller状态并实现View移动的代码抽取成了一个public boolean continueSettling(boolean deferCallbacks) 方法:

    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()) {
                // Close enough. The interpolator/scroller might think we're still moving
                // but the user sure doesn't.
                mScroller.abortAnimation();
                keepGoing = false;
            }

            if (!keepGoing) {
                if (deferCallbacks) {
                    mParentView.post(mSetIdleRunnable);
                } else {
                    setDragState(STATE_IDLE);
                }
            }
        }

        return mDragState == STATE_SETTLING;
    }

在此方法中,首先计算Scroller的状态,如果Scroller仍在运作,则通过offsetLeftAndRight()和offsetTopAndBottom()方法将View移动到Scroller当前计算出的位置,同时调用Callback的onViewPositionChanged()方法;如果Scroller已经停止运作,则停止动画,将状态置为STATE_IDLE。

如上所述,一般在ViewGroup中使用continueSettling()方法的方式为:

    @Override
    public void computeScroll() {
        if (mViewDragHelper.continueSettling(true)) {
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }
settleCapturedViewAt()

flingCapturedView()方法是指定了View的初始位置和初始速度,以某种加速度按照速度衰减的算法计算出滑行时间,再同样考虑速度衰减计算出最终位置,而settleCapturedViewAt()方法采取的方式则是直接指定了初始位置、最终位置、初始速度,不考虑加速度,基本以路程/速度的形式计算出滑行时间。settleCapturedViewAt()方法使用private boolean forceSettleCapturedViewAt(int finalLeft, int finalTop, int xvel, int yvel)方法来实现View的滑行。

    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) {
            // Nothing to do. Send callbacks, be done.
            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;
    }

    private int computeSettleDuration(View child, int dx, int dy, int xvel, int yvel) {
        xvel = clampMag(xvel, (int) mMinVelocity, (int) mMaxVelocity);
        yvel = clampMag(yvel, (int) mMinVelocity, (int) mMaxVelocity);
        final int absDx = Math.abs(dx);
        final int absDy = Math.abs(dy);
        final int absXVel = Math.abs(xvel);
        final int absYVel = Math.abs(yvel);
        final int addedVel = absXVel + absYVel;
        final int addedDistance = absDx + absDy;

        final float xweight = xvel != 0 ? (float) absXVel / addedVel :
                (float) absDx / addedDistance;
        final float yweight = yvel != 0 ? (float) absYVel / addedVel :
                (float) absDy / addedDistance;

        int xduration = computeAxisDuration(dx, xvel, mCallback.getViewHorizontalDragRange(child));
        int yduration = computeAxisDuration(dy, yvel, mCallback.getViewVerticalDragRange(child));

        return (int) (xduration * xweight + yduration * yweight);
    }

    private int computeAxisDuration(int delta, int velocity, int motionRange) {
        if (delta == 0) {
            return 0;
        }

        final int width = mParentView.getWidth();
        final int halfWidth = width / 2;
        final float distanceRatio = Math.min(1f, (float) Math.abs(delta) / width);
        final float distance = halfWidth + halfWidth *
                distanceInfluenceForSnapDuration(distanceRatio);

        int duration;
        velocity = Math.abs(velocity);
        if (velocity > 0) {
            duration = 4 * Math.round(1000 * Math.abs(distance / velocity));
        } else {
            final float range = (float) Math.abs(delta) / motionRange;
            duration = (int) ((range + 1) * BASE_SETTLE_DURATION);
        }
        return Math.min(duration, MAX_SETTLE_DURATION);
    }

根据代码可看出,在存在初始速度时,滑行的时间与初始速度、需要滑行的距离、ViewGroup的宽度有关;而在无初始速度时,滑行的时间与需要滑行的距离、getViewHorizontalDragRange()getViewVerticalDragRange()方法限定的移动范围、基本滑行时间(256ms)有关,如果要调整滑行的时间,可以调整这些相关参数。另外,这里计算出的时间受到定义的最长滑行时间限制,ViewDragHelper中定义的最长滑行时间为600ms。

由于settleCapturedViewAt()是使用scroller.startScroll()来实现View的移动,因此也需要调用invalidate()引起重绘,然后在computeScroll()方法中调用continueSettling()方法。

smoothSlideViewTo()

除了flingCapturedView()方法和settleCapturedViewAt()方法外,还有一个方法public boolean smoothSlideViewTo(View child, int finalLeft, int finalTop)可以让View滑行,与前两种方法不一样的是,此方法可以在任意位置调用,而不限定于onViewReleased()方法内。

    public boolean smoothSlideViewTo(View child, int finalLeft, int finalTop) {
        mCapturedView = child;
        mActivePointerId = INVALID_POINTER;

        boolean continueSliding = forceSettleCapturedViewAt(finalLeft, finalTop, 0, 0);
        if (!continueSliding && mDragState == STATE_IDLE && mCapturedView != null) {
            // If we're in an IDLE state to begin with and aren't moving anywhere, we
            // end up having a non-null capturedView with an IDLE dragState
            mCapturedView = null;
        }

        return continueSliding;
    }

smoothSlideViewTo()方法和settleCapturedViewAt()方法实现一样,都是使用forceSettleCapturedViewAt()以指定最终位置的形式计算滑行时间来实现滑行,只是使用smoothSlideViewTo()方法时传入的初始速度为0。

5. Callback方法调用时机总结

回调类:

  1. public void onViewDragStateChanged(int state) 此方法仅在setDragState()方法中被调用,而setDragState()方法的调用时机包括:1. captureChildView()将状态设置为STATE_DRAGGING;2. 在flingCapturedView()和forceSettleCapturedViewAt()中将状态设置为STATE_SETTLING;3.在 dispatchViewReleased()中如果未采取滑行处理的话将状态设置为STATE_IDLE,在continueSettling()方法中判断Scroller已完成工作时将状态设置为STATE_IDLE,调用abort()强行停止ViewDragHelper当前的工作,将状态设置为STATE_IDLE。
  2. public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) 可能调用时机为:1. 处于STATE_DRAGGING时,在processTouchEvent()方法处理ACTION_MOVE事件时使用dragTo()方法调用;2. 处于STATE_SETTLING时,continueSettling()方法中调用;3. 调用abort()强行停止ViewDragHelper当前的工作,如果当时处于STATE_SETTLING,会停止Scroller的工作,将View直接移动到目的地,此时会调用此方法
  3. public void onViewCaptured(View capturedChild, int activePointerId) captureChildView()中调用,而processTouchEvent()方法和shouldInterceptTouchEvent()方法处理ACTION_DOWN和ACTION_MOVE时,processTouchEvent()方法处理ACTION_POINTER_UP时都可能捕获View,也可手动调用captureChildView()方法直接捕获View
  4. public void onViewReleased(View releasedChild, float xvel, float yvel) 在dispatchViewReleased()方法中被调用,而在processTouchEvent()方法处理ACTION_UP或ACTION_CANCEL事件时,若处于STATE_DRAGGING状态,dispatchViewReleased()被调用
  5. public void onEdgeTouched(int edgeFlags, int pointerId) 在processTouchEvent()方法和shouldInterceptTouchEvent()方法处理ACTION_DOWN事件时,如果该pointer对应了某个注册过的边缘,则会调用此方法
  6. public void onEdgeDragStarted(int edgeFlags, int pointerId) 在processTouchEvent()方法和shouldInterceptTouchEvent()方法处理ACTION_MOVE事件时,如果该pointer对应了某个注册过的边缘,且该边缘未被onEdgeLock()方法锁定,且边缘移动距离超出了移动门限时则会调用此方法。不过此方法只会调用一次,之后同系列的ACTION_MOVE事件处理中不再调用。

限制类:

  1. public int getOrderedChildIndex(int index) 该方法在findTopChildUnder()方法中被调用,而findTopChildUnder()方法在processTouchEvent()方法和shouldInterceptTouchEvent()方法处理ACTION_DOWN和ACTION_MOVE时,processTouchEvent()方法处理ACTION_POINTER_UP时尝试捕获View时被调用
  2. public abstract boolean tryCaptureView(View child, int pointerId) 在processTouchEvent()方法和shouldInterceptTouchEvent()方法处理ACTION_DOWN和ACTION_MOVE时,processTouchEvent()方法处理ACTION_POINTER_UP时尝试捕获View时被调用
  3. public int getViewHorizontalDragRange(View child) 此方法的调用时机为:1. 在processTouchEvent()方法和shouldInterceptTouchEvent()方法处理ACTION_MOVE时,需要检测移动范围是否达到移动标准,才能尝试捕获View,而需要判断getViewHorizontalDragRange()>0且横向移动距离超过mTouchSlop才算达标。这里需注意的是,横向和纵向只要一个达标即能捕获View,而且getViewHorizontalDragRange()>0的条件仅在此限定捕获View,捕获View后通过dragTo()方法拖拽View并不受此限制,所以getViewHorizontalDragRange()<=0的情况下若View满足纵向移动条件而被捕获,则之后也可以拖拽View在横向方向上移动;2. forceSettleCapturedViewAt()方法中计算滑行时间时,如果初始速度为0,则会调用此方法来计算滑行时间。
  4. public int getViewVerticalDragRange(View child) 同上
  5. public int clampViewPositionHorizontal(View child, int left, int dx) 此方法的调用时机为:1. 在shouldInterceptTouchEvent()方法处理ACTION_MOVE时,需要检测移动范围是否达到移动标准,才能尝试捕获View,除了上述getViewHorizontalDragRange()方法外,移动的距离还需要先通过此方法截断后再参加判断。这里需注意的是,只有shouldInterceptTouchEvent()方法处理ACTION_MOVE时需要先截断再判断,而在processTouchEvent()方法中则只要直接拿原始移动距离判断即可。2. 捕获View后通过dragTo()方法拖拽View时,View被拖拽到的位置需通过此方法截断后生效。
  6. public int clampViewPositionVertical(View child, int top, int dy) 同上
  7. public boolean onEdgeLock(int edgeFlags) 在processTouchEvent()方法和shouldInterceptTouchEvent()方法处理ACTION_MOVE事件,判断是否满足调用onEdgeDragStarted()方法条件时调用。注意在处理ACTION_DOWN时间,判断是否满足调用onEdgeTouched()方法条件时并不需要满足此方法限制。

三. ViewDragHelper的使用示例

简要介绍两种常见的使用场景:

1. SlideStackView

点点业务的SlideStackView主要使用ViewDragHelper来处理卡片的拖拽。其所实现的ViewDragHelper.Callback方法为:

  1. onViewPositionChanged() 当首张卡片被拖拽或是滑行时,后面的卡片大小、透明度、位置均需要相应改变,这些处理在此方法中完成,对首张卡片的移动距离做了一个门限,当首张卡片的移动距离达到此门限时,后面的卡片UI变化均会完成,而在此门限之内时,则根据移动距离与门限的比率来进行相应比率的UI变化。

  2. onViewReleased() 点点业务中需要拖拽松手时继续进行处理,如果拖拽的距离或是松手时的卡片速度超出了预设门限,需要卡片滑行到屏幕两边,而如果拖拽的距离及速度不够,则需要滑行到卡片原来的位置。 因此,在此方法中,对拖拽距离和速度进行了判断,如果未超出门限,则滑行到原位置,如果超过了门限,按照松手时卡片相对原位置的斜率计算出卡片最终需要移动到的位置。卡片滑行向两端时,调用了相应的回调函数,以在业务中根据左滑右滑做出相应处理。

  3. tryCaptureView() 点点业务中,对于捕获View的限制为:1. 仅允许第一张卡片被捕获;2. 卡片未填充数据为INVISIBLE状态时不可被捕获;3. 某些特殊情况,如卡片正在播放滑动前的动画时不能被捕获。因此在此方法中主要对这几种情况进行了限制。

  4. getViewHorizontalDragRange()getViewVerticalDragRange() SlideStackView中返回了64,理论上这两个方法只要不都返回0,不会影响卡片的拖动;由于SlideStackView广泛采用了smothSlideViewTo()方法,这两个方法的返回值也影响了卡片滑行的速度。

  5. clampViewPositionHorizontal(View child, int left, int dx)clampViewPositionVertical(View child, int top, int dy) 分别返回了left和top,即不会View的推拽和滑行目的地作任何限制。

点点业务除了拖拽卡片外,还需要有点击按钮直接让卡片滑行的需求,这种情况下直接采用了在OnClickListener中调用smothSlideViewTo()方法直接让卡片滑行到某个指定位置的方式。

SlideStackView除了基本的View拖拽和滑行逻辑外,一个比较重要的地方在于卡片的回收,SlideStackView采取的卡片回收方式是当卡片滑行到指定位置后,将其重新移动到中间位置,然后将下面的几个卡片依次提升到最上的位置,以改变各个卡片在ViewGroup中的Z轴位置,使得被回收的卡片Z轴位置最低,然后再对被回收的卡片进行数据再填充。

SlideStackView判断卡片滑行完成的时机是在computeScroll()方法中,如果continueSettling()方法返回true,说明Scroller仍在工作,需要继续postInvalidate();如果continueSettling()方法返回false,说明Scroller工作已完成,卡片已滑行到指定位置。

    @Override
    public void computeScroll() {
        if (mDragHelper.continueSettling(true)) {
            ViewCompat.postInvalidateOnAnimation(this);
        } else {
            // 动画结束
            synchronized (this) {
                if (mDragHelper.getViewDragState() == ViewDragHelper.STATE_IDLE) {
                    orderViewStack();
                }
            }
        }
    }

由于卡片的滑行和回收是需要时间的,如果快速拖动,总共4张卡片很容易就被滑完且来不及回收,因此可以做的一种方式就是在一个卡片未回收前不允许下一张卡片的拖动,但是这样可能会造成不太好的用户体验,另一种解决的方法是在尝试拖动下一张卡片时,如果上一张卡片还未滑行完成,则对其进行强行回收,这样使得每次拖动时之前滑行的卡片都已回收,不至于出现滑完卡片的情况,SlideStackView中采用了第二种方法。

需要强制回收卡片,则需要停止卡片的滑行动画,然后重置其Z轴顺序和状态。由于未在ViewDragHelper中找到直接停止滑行动画的方法,当前采取的方式是直接强制将ViewDragHelper的状态从STATE_SETTLING置为STATE_IDLE,因为在continueSettling()方法中所做的判断,如果不是STATE_SETTLING状态,不会做出移动处理,且返回falese,标识着滑行完成。

2. DrawerLayout

DrawerLayout为v4包中自带的为实现从屏幕左边或右边拖出抽屉的外层ViewGroup,在DrawerLayout中放入的首个View一般为全屏的layout_container,然后可以定义最多两个抽屉View,通过layout_gravity属性设定其可以从左边还是右边拖出。

SlideStackView主要侧重于View的拖拽和滑行,而DrawerLayout主要侧重于使用ViewDragHelper来实现边缘拖动的检测。DrawerLayout定义了一个DrawerListener来在ViewDragHelper处理拖动的关键节点进行回调。

    public interface DrawerListener {
        public void onDrawerSlide(View drawerView, float slideOffset);
        public void onDrawerOpened(View drawerView);
        public void onDrawerClosed(View drawerView);
        public void onDrawerStateChanged(@State int newState);
    }

由于DrawerLayout可以存放左右两个抽屉,DrawerLayout中维护了两个ViewDragHelper实例,DrawerLayout中实现的ViewDragHelper.Callback方法有:

  1. public void onViewDragStateChanged(int state) DrawerLayout在此方法中调用了updateDrawerState()方法,由于DrawerLayout中维护了两个ViewDragHelper实例,在此方法中,会将两个ViewDragHelper的状态综合为一个,如果有任意一个ViewDragHelper状态为STATE_DRAGGING,则将综合状态设置为STATE_DRAGGING,或者有任意一个ViewDragHelper状态为STATE_SETTLING,则将综合状态设置为STATE_SETTLING,否则状态为STATE_IDLE. 另外,在updateDrawerState()方法中,会调用DrawerListener中的onDrawerStateChanged()方法,如果新的状态为STATE_IDLE,还会判断Drawer现在是否显示在界面中,如果完全不在,说明抽屉刚关闭,此时会调用DrawerListener的onDrawerClosed()方法;如果现在完全显示在界面上,说明抽屉刚完全打开,此时会调用DrawerListener的onDrawerOpened()方法。

  2. public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) 此方法中,会根据left值和changedView的宽度计算出当前Drawer露出部分的比例,并将此露出比例存储,调用DrawerListener的onDrawerSlide()方法,实际上onViewDragStateChanged()中STATE_IDLE状态时对Drawer是否完全露出或是完全收起的判断就是使用此露出比例数据来判断,在本方法中,如果露出比例变为0,则会将Drawer设置为INVISIBLE。

  3. public void onViewCaptured(View capturedChild, int activePointerId) 此方法中,根据捕获的Drawer,会关闭另一侧的Drawer

  4. public void onViewReleased(View releasedChild, float xvel, float yvel) 此处会根据当前Drawer和位置进行不同的处理,以左侧Drawer为例,如果当前横向移动速度大于0,或者是横向移动速度为0但是Drawer露出比例超过一半,会将Drawer滑行到完全展开,否则将Drawer滑行懂啊完全关闭,Drawer的滑行用settleCapturedViewAt()方法实现。

  5. public void onEdgeTouched(int edgeFlags, int pointerId) 在此方法中会postDelay一个peekDrawer()操作,peekDrawer()方法会让Drawer向外外展一定距离以让用户意识到Drawer的存在,以左侧为例,预期外展后Drawer的left位置为-getWidteh+peekDistance,如果当前左侧Drawer位置在此left位置左边,就使用smoothSlideViewTo()方法将Drawer移动到此位置。同时该方法中会关闭其他侧的Drawer,并向DrawLayout的child view发送一个ACTION_CANCEL事件。

  6. public void onEdgeDragStarted(int edgeFlags, int pointerId) 由于在Drawer关闭的状态是无法通过对触摸事件的处理而捕获Drawer的,因此在此方法中的处理是使用captureChildView()方法直接捕获边缘侧的Drawer。

  7. public abstract boolean tryCaptureView(View child, int pointerId) 此方法的限定条件为尝试捕获的View为Drawer,且当前未锁定。(DrawerLayout可以设置对Drawer的锁定状态,可以锁定在关闭状态或开启状态)

  8. public int getViewHorizontalDragRange(View child) 此处的限制为:如果是Drawer,则可横向移动范围为drawer.getWidth(),否则为0

  9. public int getViewVerticalDragRange(View child) 纵向移动范围为0

  10. public int clampViewPositionHorizontal(View child, int left, int dx) 以左侧Drawer为例,横向移动范围被限定在-child.getWidth()和0之间

  11. public int clampViewPositionVertical(View child, int top, int dy) 此处返回了child.getTop(),即不允许纵向的移动

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment