ViewDragHelper: 实现ViewGroup的子View拖动

4,525 阅读16分钟

自定义ViewGroup最常添加的功能就是子View的拖动,如果你的事件分发及处理的基本功非常扎实,那么完全可以自己实现这个功能。然而幸运的是,系统提供了一个工具类ViewDragHelper,它提供了这个功能实现的框架,这样就大大提高了开发的效率。

本文不仅仅告诉你这个工具类该怎么使用,而且也会分析它的设计原理。只有掌握原理了,才能在实际中做到以不变应万变。

本文需要你对事件的分发和处理有基本的认识,如果你还没掌握,可以参考我之前写的三篇文章

  1. 事件分发之View事件处理
  2. ViewGroup事件分发和处理源码分析
  3. 手把手教你如何写事件处理的代码

如果你对事件分发和处理的流程不熟悉,你可能从本文中只学到如何使用ViewDragHelper类,但是并不会掌握它的精华。

ViewDragHelper实现事件处理

既然ViewDragHelper是一个工具框架类,那么对事件的处理肯定也是做好了封装。假设有一个自定义ViewGroup类,名字叫做VDHLayout。我们来看下如何使用ViewDragHelper类实现事件的处理。

public class VDHLayout extends ViewGroup {
    ViewDragHelper mViewDragHelper;
    public VDHLayout(Context context) {
        this(context, null);
    }

    public VDHLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        // 创建ViewDragHelper对象,回调参数用来控制子View的拖动
        mViewDragHelper = ViewDragHelper.create(this, new ViewDragHelper.Callback() {
            @Override
            public boolean tryCaptureView(@NonNull View child, int pointerId) {
                return false;
            }
        });
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        measureChildren(widthMeasureSpec, heightMeasureSpec);
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        if (changed) {
            // 简单点,只操作第一个子View
            View first = getChildAt(0);
            first.layout(getPaddingLeft(), getPaddingTop(), getPaddingLeft() + first.getMeasuredWidth(),
                    getPaddingTop() + first.getMeasuredHeight());
        }
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        // 利用ViewDragHelper来判断是否需要截断
        return mViewDragHelper.shouldInterceptTouchEvent(ev);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        // 利用ViewDragHelper来处理子View的拖拽
        mViewDragHelper.processTouchEvent(event);
        return true;
    }
}

VDHLayout继承自ViewGroup,为了简单起见,只对它的第一个子View进行布局,这就是在onLayout()中的操作。

事件处理的代码是在onInterceptTouchEvent()onTouchEvent()方法中实现的,从代码中可以看到,分别用ViewDragHelper.shouldInterceptTouchEvent()ViewDragHelper.processTouchEvent()来处理事件。

实现ViewDragHelper的回调

现在,我们已经成功地用ViewDragHelper实现了事件的处理,那么子View的拖动是在哪里控制的呢?这个其实是在创建ViewDragHelper对象的时候,用传入的回调参数控制的。从代码中可以看到,我们只实现了回调中的一个方法tryCaptureView(),这个方法也是必须要实现的。

根据事件分发和处理的原理可知,VDHLayout的子View是否能处理ACTION_DOWN事件,关乎着VDHLayout的事件分发和处理的逻辑。ViewDragHelper的回调当然也是受这个的影响的,因此我将分两部分来讲解如何实现回调。

子View不处理事件

首先我们来看下子View不处理事件的情况。

根据View事件分发和处理的原理可知,如果一个View不设置任何监听事件,并且不可点击,也不可长按,那么这个View就不处理任何事件。

理论上讲的有点抽象,举个例子,例如 在XML布局中给VDHLayout添加一个ImageView控件

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

    <ImageView
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:src="@mipmap/ic_launcher_round" />

</com.bxll.vdhdemo.VDHLayout>

这个ImageView没有任何监听事件,默认不可点击也不可长按的,因此它就是一个不处理事件的子View。

现在以这个布局为例进行分析,当手指点击ImageView的时候,由于子View,也就是ImageView,不处理事件,所以ACTION_DOWN事件一定会先经过VDHLayout.onInterceptTouchEvent(),再经过VDHLayout.onTouchEvent()

根据事件处理的经验,真正的处理逻辑其实都在VDHLayout.onTouchEvent()中,它的实现如下

    public boolean onTouchEvent(MotionEvent event) {
        // 利用ViewDragHelper来处理子View的拖拽
        mViewDragHelper.processTouchEvent(event);
        return true;
    }

由于VDHLayout要通过触摸事件控制子View拖动,因此在onTouchEvent()中必须要返回true

可以看到,是用ViewDragHelper.processTouchEvent()来实现VDHLayout.onTouchEvent()的,现在来看看ViewDragHelper.processTouchEvent()是如何处理ACTION_DOWN事件的

    public void processTouchEvent(@NonNull MotionEvent ev) {
        // ...

        switch (action) {
            case MotionEvent.ACTION_DOWN: {
                final float x = ev.getX();
                final float y = ev.getY();
                final int pointerId = ev.getPointerId(0);
                // 1. 找到事件作用于哪个子View
                final View toCapture = findTopChildUnder((int) x, (int) y);
                // 保存坐标值
                saveInitialMotion(x, y, pointerId);
                // 2. 尝试捕获这个用于拖动的子View
                tryCaptureViewForDrag(toCapture, pointerId);
                // 边缘触摸回调
                final int edgesTouched = mInitialEdgesTouched[pointerId];
                if ((edgesTouched & mTrackingEdges) != 0) {
                    mCallback.onEdgeTouched(edgesTouched & mTrackingEdges, pointerId);
                }
                break;
            }

            // ...
        }
    }

首先通过findTopChildUnder()方法找到手指按下的那个子View

    public View findTopChildUnder(int x, int y) {
        final int childCount = mParentView.getChildCount();
        for (int i = childCount - 1; i >= 0; i--) {
            // getOrderedChildIndex()回调决定了获取哪个子View
            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,然而我们可以发现,回调方法getOrderedChildIndex()决定了到底是哪个子View被找到。从这里可以看出,手指操作的并不一定都是最上面的子View。

找到了ACTION_DOWN作用的子View后,就通过tryCaptureViewForDrag()来尝试捕获这个子View

    boolean tryCaptureViewForDrag(View toCapture, int pointerId) {
        if (toCapture == mCapturedView && mActivePointerId == pointerId) {
            // Already done!
            return true;
        }
        // 通过回调判断这个子View是否能被捕获
        if (toCapture != null && mCallback.tryCaptureView(toCapture, pointerId)) {
            mActivePointerId = pointerId;
            captureChildView(toCapture, pointerId);
            return true;
        }
        return false;
    }

首先通过tryCaptureView()回调方法判断子View是否能够被捕获,被捕获的子View才能被用来拖动。

如果能够被捕获,那么就调用captureChildView()通知子View被捕获

    public void captureChildView(@NonNull View childView, int activePointerId) {
        // mCapturedView代表被用来拖动的目标
        mCapturedView = childView;
        mActivePointerId = activePointerId;
        // 回调通知View被捕获 
        mCallback.onViewCaptured(childView, activePointerId);
        // 设置为拖动状态
        setDragState(STATE_DRAGGING);
    }

captureChildView()是通过onViewCaptured()进行回调,通知子View已经被捕获。

现在,来总结下ViewDragHelper.processTouchEvent()ACTION_DOWN事件的处理中,回调做了哪些事事情(只列举主要的回调)

  1. 通过getOrderedChildIndex()回调,判断ACTION_DOWN作用于哪个子View。
  2. 通过tryCaptureView()回调,判断子View是否能被捕获。
  3. 通过onViewCaptured()回调,通知哪个子View被捕获。

ACTION_DOWN处理完了,现在我们来看看ACTION_MOVE事件如何处理的。

由于子View不处理事件,ACTION_MOVE事件交由VDHLayout.onTouchEvent()处理,也就是交给了ViewDragHelper.processTouchEvent()处理。

    public void processTouchEvent(@NonNull MotionEvent ev) {
        // ...

        switch (action) {
            // ...

            case MotionEvent.ACTION_MOVE: {
                if (mDragState == STATE_DRAGGING) {
                    // 判断手指是否有效
                    if (!isValidPointerForActionMove(mActivePointerId)) break;

                    final int index = ev.findPointerIndex(mActivePointerId);
                    final float x = ev.getX(index);
                    final float y = ev.getY(index);
                    // 获取x,y轴上拖动的距离差
                    final int idx = (int) (x - mLastMotionX[mActivePointerId]);
                    final int idy = (int) (y - mLastMotionY[mActivePointerId]);
                    // 对于目标View执行拖动
                    dragTo(mCapturedView.getLeft() + idx, mCapturedView.getTop() + idy, idx, idy);

                    saveLastMotion(ev);
                } else {
                    // ...
                }
                break;
            }

            // ...
        }
    }

ViewDragHelper.processTouchEvent()ACTION_MOVE的处理中,首先计算在x,y轴上移动的距离差,然后通过dragTo()方法拖动刚刚捕获的子View。

我们注意下dragTo()第一个参数和第二个参数,它指的是目标View(被捕获的子View)理论上要移动到的坐标点。

    private void dragTo(int left, int top, int dx, int dy) {
        // clampedX, clampedY表示目标View要拖动到的终点坐标
        int clampedX = left;
        int clampedY = top;
        // 获取目标View的起始坐标
        final int oldLeft = mCapturedView.getLeft();
        final int oldTop = mCapturedView.getTop();
        if (dx != 0) {
            // 如果拖动的距离大于0,通过回调获取目标View最终要拖动到的x坐标值
            clampedX = mCallback.clampViewPositionHorizontal(mCapturedView, left, dx);
            // 目标View在水平方向移动
            ViewCompat.offsetLeftAndRight(mCapturedView, clampedX - oldLeft);
        }
        if (dy != 0) {
            // 如果拖动的距离大于0,通过回调获取目标View最终要拖动到的x坐标值
            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;
            // 回调通知,目标View实际移动到(clampedX, clampedY),以及x,y轴实际移动的距离差为clampedDx, clampedDy
            mCallback.onViewPositionChanged(mCapturedView, clampedX, clampedY,
                    clampedDx, clampedDy);
        }
    }

x,y方向上,只要任意一个方向上手指拖动的距离大于0, 那么就通过clampViewPositionHorizontal()/clampViewPositionVertical()回调方法,计算目标View实际需要拖动到的终点坐标。

通过回调计算出来终点坐标后,就把目标View移动到这个计算出来的坐标点上。

最后,只要x,y方向上拖动距离大于0,那么就通过onViewPositionChanged()回调方法,通知目标View实际拖动到哪个坐标,以及实际拖动的距离差。

现在我们明白了,ViewDragHelper.processTouchEvent()处理ACTION_MOVE,实际上就是处理目标View的拖动,它用到了如下回调

  1. clampViewPositionHorizontal()clampViewPositionVertical()回调,用来计算目标View拖动的实际坐标。
  2. onViewPositionChanged()回调,通知目标View实际被拖动到哪个坐标,以及在x,y轴上拖动的实际距离差。

实现子View不处理事件回调

有了前面的理论基础,现在我们来实现下回调,让不处理事件的子View能够被拖动,而且只允许在水平方向上被拖动。

        mViewDragHelper = ViewDragHelper.create(this, new ViewDragHelper.Callback() {
            @Override
            public boolean tryCaptureView(@NonNull View child, int pointerId) {
                // 为简单起见,所有的View都可以被拖动
                return true;
            }

            /**
             * 控制目标View在x方向的移动。
             */
            @Override
            public int clampViewPositionVertical(@NonNull View child, int top, int dy) {
                // 不允许垂直方向移动
                return 0;
            }

            /**
             * 控制目标View在y方向的移动。
             */
            @Override
            public int clampViewPositionHorizontal(@NonNull View child, int left, int dx) {
                // 水平方向移动不能超出父View范围
                return Math.min(Math.max(0, left), getWidth() - child.getWidth());
            }
        });

由于我们不允许垂直方向的拖动,因此clampViewPositionHorizontal()要返回0,clampViewPositionVertical()的返回值要控制在VDHLayout范围内滑动。效果如下

VDH_H

在前面的分析中还有其它的一些回调,可以根据实际项目要求进行复写实现。

子View处理事件

现在来分析子View能够处理事件的情况。让子View能处理事件最简单的方式是设置它可以点击,例如

<com.bxll.vdhdemo.VDHLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ImageView
        android:clickable="true"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:src="@mipmap/ic_launcher_round" />

</com.bxll.vdhdemo.VDHLayout>

当利用这个布局再次运行程序的时候,你会发现原来可以拖动的ImageView不能被拖动了。这是因为事件的处理逻辑改变了,从而ViewDragHelper的实现逻辑也改变了。

由于子View能处理事件,因此对于ACTION_DOWN事件,就只会经过VDHLayout.onInterceptTouchEvent()方法,而并不会经过VDHLayout.onTouchEvent()方法。从前面的代码实现可知,VDHLayout.onInterceptTouchEvent()是由ViewDragHelper.shouldInterceptTouchEvent()实现。然而ViewDragHelper.shouldInterceptTouchEvent()方法对于ACTION_DOWN只是简单一些简单处理,并不会截断事件。

因此我们需要分析ACTION_MOVE是如何被ViewDragHelper.shouldInterceptTouchEvent()截断的。

    public boolean shouldInterceptTouchEvent(@NonNull MotionEvent ev) {
        // ...

        switch (action) {
            // ...

            case MotionEvent.ACTION_MOVE: {
                // ...
                
                final int pointerCount = ev.getPointerCount();
                // 只考虑单手指操作
                for (int i = 0; i < pointerCount; i++) {
                    final int pointerId = ev.getPointerId(i);

                    // If pointer is invalid then skip the ACTION_MOVE.
                    if (!isValidPointerForActionMove(pointerId)) continue;

                    final float x = ev.getX(i);
                    final float y = ev.getY(i);
                    final float dx = x - mInitialMotionX[pointerId];
                    final float dy = y - mInitialMotionY[pointerId];
                    
                    final View toCapture = findTopChildUnder((int) x, (int) y);
                    // 1. 判断是否达到拖动的标准
                    final boolean pastSlop = toCapture != null && checkTouchSlop(toCapture, dx, dy);
                    // 一个不截断的情况:如果拖动标准,却没有实际的拖动距离,那就不截断事件
                    if (pastSlop) {
                        //获取新,旧坐标值
                        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);
                        // 通过回调获取x,y方向的拖动范围
                        final int hDragRange = mCallback.getViewHorizontalDragRange(toCapture);
                        final int vDragRange = mCallback.getViewVerticalDragRange(toCapture);
                        // 没有实际的拖动距离就不截断事件
                        if ((hDragRange == 0 || (hDragRange > 0 && newLeft == oldLeft))
                                && (vDragRange == 0 || (vDragRange > 0 && newTop == oldTop))) {
                            break;
                        }
                    }
                    // 报告边缘动
                    reportNewEdgeDrags(dx, dy, pointerId);
                    if (mDragState == STATE_DRAGGING) {
                        // Callback might have started an edge drag
                        break;
                    }
                    
                    // 2. 如果达到拖动的临界距离,那么就尝试捕获子View
                    if (pastSlop && tryCaptureViewForDrag(toCapture, pointerId)) {
                        break;
                    }
                }
                saveLastMotion(ev);
                break;
            }

        
        }
        // 如果成功捕获子View,那么状态就会被设置为STATE_DRAGGING,也就代表截断事件
        return mDragState == STATE_DRAGGING;
    }

ViewDragHelper.shouldInterceptTouchEvent()考虑了多手指的情况,为了简化分析,只考虑单手指的情况。

第一步,判断是否达到拖动的条件,有两个条件

  1. 事件必须要作用于某个子View
  2. checkTouchSlop()返回true

根据事件处理的经验,如果要截断ACTION_MOVE事件,必须要有条件地截断。

checkTouchSlop()方法用来判断是否达到的拖动的临界距离

    private boolean checkTouchSlop(View child, float dx, float dy) {
        if (child == null) {
            return false;
        }
        // 通过回调方法判断x,y方向是否允许拖动
        final boolean checkHorizontal = mCallback.getViewHorizontalDragRange(child) > 0;
        final boolean checkVertical = mCallback.getViewVerticalDragRange(child) > 0;

        // 如果x或y方向允许拖动,根据拖动的距离计算是否达到拖动的临界值
        if (checkHorizontal && checkVertical) {
            return dx * dx + dy * dy > mTouchSlop * mTouchSlop;
        } else if (checkHorizontal) {
            return Math.abs(dx) > mTouchSlop;
        } else if (checkVertical) {
            return Math.abs(dy) > mTouchSlop;
        }
        // 如果x和y方向都不允许拖动,那就永远不可能达到拖动临界值
        return false;
    }

首先通过getViewHorizontalDragRange()getViewVerticalDragRange()获取x,y方向拖动范围,只要这个范围大于0,就代表可以在x,y方向上拖动。然后根据哪个方向可以拖动,就相应的计算拖动的距离是否达到了临界距离。

现在回到shouldInterceptTouchEvent()方法的第二步,当达到了拖动条件后,就调用tryCaptureViewForDrag()尝试捕获目标View,这个方法在前面已经分析过,它会首先回调tryCaptureView()确定目标View是否能被拖动,如果能拖动,再回调onViewCaptured()通知目标View已经捕获,最后设置状态为STATE_DRAGGING

当状态设置为了STATE_DRAGGING后,那么ViewDragHelper.shouldInterceptTouchEvent()返回值就是true,也就是说VDHLayout.onInterceptTouchEvent()截断了ACTION_MOVE事件。

VDHLayout.onInterceptTouchEvent()截断了ACTION_MOVE事件后,后续的ACTION_MOVE事件就交给了VDHLayout.onTouchEvent()方法,也就是交给了ViewDragHelper.processTouchEvent()处理。这个方法之前分析过,就是处理目标View的拖动。

那么现在我们来总结下ViewDragHelper.shouldInterceptTouchEvent()在处理ACTION_MOVE截断的时候,用到哪些关键回调

  1. getViewHorizontalDragRange()getViewVerticalDragRange()方法判断x,y方向上是否可以拖动。返回值大于0表示可以拖动。

实现View处理事件的回调

经过刚才的分析,我们知道,对于一个能处理事件的子View,如果想让它能被拖动,必须复写getViewHorizontalDragRange()getViewVerticalDragRange()回调,用于告诉ViewDragHelper,在相应的方向上允许被拖动。

那么现在,我们就来解决子View(能处理事件)不能拖动的问题,我们仍然只让子View在水平方向上被拖动

       mViewDragHelper = ViewDragHelper.create(this, new ViewDragHelper.Callback() {
            @Override
            public boolean tryCaptureView(@NonNull View child, int pointerId) {
                // 为简单起见,所有的View都可以被拖动
                return true;
            }

            /**
             * 控制目标View在x方向的移动。
             */
            @Override
            public int clampViewPositionVertical(@NonNull View child, int top, int dy) {
                // 不允许垂直方向移动
                return 0;
            }

            /**
             * 控制目标View在y方向的移动。
             */
            @Override
            public int clampViewPositionHorizontal(@NonNull View child, int left, int dx) {
                // 水平方向移动不能超出父View范围
                return Math.min(Math.max(0, left), getWidth() - child.getWidth());
            }

            @Override
            public int getViewHorizontalDragRange(@NonNull View child) {
                // 由于只允许目标View在VDHLayout中水平拖动,因此水平拖动范围就是VDHLayout的宽度减去目标View宽度
                return getWidth() - child.getWidth();
            }

            @Override
            public int getViewVerticalDragRange(@NonNull View child) {
                // 由于不允许垂直方向拖动,因此拖动范围也就是0
                return 0;
            }
        });
    }

由于我们只允许水平方向拖动,因此getViewVerticalDragRange()返回的垂直方向的拖动范围就是0,getViewHorizontalDragRange()返回的水平方向的拖动范围就是getWidth() - child.getWidth()

边缘触摸

ViewDragHelper有一个边缘触摸功能,这个边缘触摸的功能比较简单,因此我并不打算从源码进行分析,而只是从API角度进行说明。

要向触发边缘滑动功能,首先要调用ViewDragHelper.setEdgeTrackingEnabled(int edgeFlags)方法,设置哪个边缘允许跟踪。参数有如下几个可用值

    public static final int EDGE_LEFT = 1 << 0;

    public static final int EDGE_RIGHT = 1 << 1;

    public static final int EDGE_TOP = 1 << 2;

    public static final int EDGE_BOTTOM = 1 << 3;

    public static final int EDGE_ALL = EDGE_LEFT | EDGE_TOP | EDGE_RIGHT | EDGE_BOTTOM;

边缘触摸的回调有如下几个

        /**
         * Called when one of the subscribed edges in the parent view has been touched
         * by the user while no child view is currently captured.
         */
        public void onEdgeTouched(int edgeFlags, int pointerId) {}
        
        /**
         * Called when the given edge may become locked. This can happen if an edge drag
         * was preliminarily rejected before beginning, but after {@link #onEdgeTouched(int, int)}
         * was called. This method should return true to lock this edge or false to leave it
         * unlocked. The default behavior is to leave edges unlocked.
         */
        public boolean onEdgeLock(int edgeFlags) {
            return false;
        }    
        
        /**
         * Called when the user has started a deliberate drag away from one
         * of the subscribed edges in the parent view while no child view is currently captured.
         */
        public void onEdgeDragStarted(int edgeFlags, int pointerId) {}        

注释已经很清楚的解释了这几个回调的时机,我献丑来翻译下

  1. onEdgeTouched(): 当没有子View被捕获,并且允许边缘触摸,当用户触摸边缘时回调。
  2. onEdgeLock(): 用来锁定锁定哪个边缘。这个回调是在onEdgeTouched()之后,开始拖动之前调用的。
  3. onEdgeDragStarted(): 当没有子View被捕获,并且允许边缘触摸,当用户已经开始拖动的时候回调。

系统控件DrawerLayout就是利用ViewDragHelper的边缘滑动功能实现的。由于篇幅原因,我就不用例子来展示边缘触摸的功能如何使用了。

ViewDragHelper实现View滑动

ViewDragHelper还有一个View定义的功能,利用OverScroller实现。有如下几个方法


    /**
     * Settle the captured view at the given (left, top) position.
     * The appropriate velocity from prior motion will be taken into account.
     * If this method returns true, the caller should invoke {@link #continueSettling(boolean)}
     * on each subsequent frame to continue the motion until it returns false. If this method
     * returns false there is no further work to do to complete the movement.
     */    
    public boolean settleCapturedViewAt(int finalLeft, int finalTop) {}
    
    /**
     * Animate the view <code>child</code> to the given (left, top) position.
     * If this method returns true, the caller should invoke {@link #continueSettling(boolean)}
     * on each subsequent frame to continue the motion until it returns false. If this method
     * returns false there is no further work to do to complete the movement.
     */
    public boolean smoothSlideViewTo(@NonNull View child, int finalLeft, int finalTop) {}
    
    /**
     * Settle the captured view based on standard free-moving fling behavior.
     * The caller should invoke {@link #continueSettling(boolean)} on each subsequent frame
     * to continue the motion until it returns false.
     */
    public void flingCapturedView(int minLeft, int minTop, int maxLeft, int maxTop) {}

从注释可以可以看出,这个三个方法都需要在下一帧刷新的时候调用continueSettling(),这个就与OverScroller的用法是一致的。

现在,来利用settleCapturedViewAt()方法实现一个功能,让拖动的View被释放后,回到原点。

当拖动的View被释放后,会回调onViewReleased()方法

public void onViewReleased(@NonNull View releasedChild, float xvel, float yvel) {
    if (mViewDragHelper.settleCapturedViewAt(0, 0)) {
        invalidate();
    }
}

由于利用的是OverScroller来实现的,因此必须调用进行重绘。重绘的时候,会调用控件的computeScroll()方法,在这里调用刚才说讲的continueSettling()方法

public void computeScroll() {
    if (mViewDragHelper.continueSettling(true)) {
        invalidate();
    }
}

continueSettling()也是对OverScroller逻辑的封装,如果返回true就代表这个定位操作还在进行中,因此还需要继续调用重绘操作。

想了解其中的原理,你一定要熟悉OverScroller的原理。

如此一来就可以实现如下效果

Settling

结束

很多绚丽的视图拖动操作,往往都是用ViewDragHelper实现的,这个工具类简直是一个集大成之作,我们需要完全掌握它,这样我们才能游刃有余地在自定义ViewGroup中实现各种牛逼的View拖动效果。