神奇的 ViewDragHelper,让你轻松定制拥有拖拽能力的 ViewGroup

为了吸引大家的注意力,先给大家看一张动图:
这里写图片描述
相信这种效果大家都见过吧?我第一次见到这样的效果时,心里也痒痒的,急于想实现这种功能,后来因为拖延症的问题,就一直没有去弄这件事。现在这段时间,工作比较轻闲,所以对自己几年 Android 生涯所运用的技术做一些总结与思考。拖拽这种功能正好可以形成一个主题。如题目所示,今天博文的目标就是介绍与分析 ViewDragHelper 这个类。

读者阅读本文后将会有如下收获:
1. 不借助于 ViewDragHelper 实现基本的拖拽效果。
2. 借助于 ViewDragHelper 轻松实现复杂的拖拽效果。
3. 分析 ViewDragHelper 源码说明它能实现拖拽的原因(放心,不会头晕,只涉及一点点源码)。

初识 ViewDragHelper

在我 Android 职业生涯的第一个月,第一个项目和 Launcher 有关,需要直接阅读系统的 Launcher 的代码,当时是 Launcher2 的工程,这个工程是 Android 系统的门面,但是代码量巨大,作为菜鸟而言,工作难度可想而知。很多地方直接看不懂,比如各种 Callback,比如涉及拖拽的 DragController 。

Launcher 中的拖拽主要是针对 APP 在桌面上的 ICON 和 Widget。
这里写图片描述

而 Launcher2 关于拖拽的代码,当时的我自认为是看不懂的,我当时的想法时能看懂的是高手。

但是,正因为如此我对拖拽这一功能才会有深深的恐惧感。

我当时立下志向————有朝一日,我一定会拥有这个能力的。

后来,官方为了便于开发,提供了一个方便的辅助类 ViewDragHelper 放到 Support V4 这个兼容包中,正因为如此,我的目标就更进了一步。
这里写图片描述

不借助于 ViewDragHelper 实现拖拽的功能

主动思考比被动接受的学习效果要好一点,被动接受的弊端在于看书的时候,我们以为自己懂了,产生“已经学会”这种错觉,结果是一段时间再来检验,发现实践效果相去甚远。

所以,我们学习新的知识最好要加入自己的主动思考,因为这样别人的知识才会被自己真正吸收,构建到自己的知识体系当中,成为自己的知识组件。

那么,对于拖拽这个功能,我们可以先抛开 ViewDragHelper 这个类不管。

我们先想一想如果是自己亲自编码,我们将怎么样开始呢?

动作分解

我们先可以将拖拽这个动作分解:
1. 触摸。
2. 移动。

角色分析

首先,我们博文分析的目标是 ViewGroup 中的拖拽,而并非是一个 View 中的拖拽。View 中拖拽实际上就是针对内容拖拽。用 scrollBy() 方法就可以解决,它等同于滑动或者滚动的概念,这个不在于本文讨论范围之内,如果对于这部分感兴趣的同学可以阅读我这篇博文《不再迷惑,也许之前你从未真正懂得 Scroller 及滑动机制》

很容易观察得到,ViewGroup 中拖拽涉及的角色可能包括:
1. ViewGroup。
2. 它的子 View,也就是某些 childView。

交互分析

  1. 手指触摸在 ViewGroup 上。
  2. 如果触摸的坐标正好落在某个 childView 上面。拖拽开始。
  3. 手指开始移动,childView 位置坐标改变。拖拽进行。
  4. 手指释放后,childView 落在新的位置或者回弹到指定的某处,拖拽结束。

这里写图片描述

编码

涉及到触摸的话,ViewGroup 自然要在 onTouchEvent() 和 onInterceptTouchEvent() 两个方法中处理。
onInterceptTouchEvent() 主要是用来决定是否拦截 childView 的触摸操作,这里面为了方便演示,统一处理为 true,也就是拦截。

onTouchEvent() 在这个方法中,ViewGroup 用来处理触摸的具体流程。也就是对应上图的触摸、移动、释放手指。

在 Android 中 MotionEvent 封装了触摸时的各种状态。所以我们主要处理的状态有以下:
1. MotionEvent.ACTION_DOWN: 在这个状态时,标记手指按下屏幕。我们需要判断当前触摸的地方是否落在 childview 的显示区域,如果是则标记拖拽状态开始,我们需要记录手指的触摸位置为原始坐标。
2. MotionEvent.ACTION_MOVE: 这个状态自然代表手指的移动过程,这个时候我们仍然需要记录手指触摸新的坐标,然后如果是在触摸开始的状态,则将 childview 进行位置偏移,偏移量就是新坐标与原始坐标的偏差。
3. MotionEvent.ACTION_UP,MotionEvent.ACTION_CANCLE:这两者都是表明手指离开了屏幕,这个时候如果一个 childview 正在拖拽,那么需要标记拖拽状态结束,至于 View 根据实际需要,通常是停留在新的坐标或者是回弹到原来的地方。

知道了流程,我们就可以开始编码,我们可以新建一个 ViewGroup 命名为 DragViewGroup,为了简便起见,让它继承自 FrameLayout。之后实现它的 onInterceptTouchEvent() 和 onTouchEvent()。

public class DragViewGroup extends FrameLayout {
    private static final String TAG = "TestViewGroup";

    // 记录手指上次触摸的坐标
    private float mLastPointX;
    private float mLastPointY;

    //用于识别最小的滑动距离
    private int mSlop;
    // 用于标识正在被拖拽的 child,为 null 时表明没有 child 被拖拽
    private View mDragView;

    // 状态分别空闲、拖拽两种
    enum State {
        IDLE,
        DRAGGING
    }

    State mCurrentState;

    public DragViewGroup(Context context) {
        this(context,null);
    }

    public DragViewGroup(Context context, AttributeSet attrs) {
        this(context, attrs,0);
    }


    public DragViewGroup(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mSlop = ViewConfiguration.getWindowTouchSlop();
    }


    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        return true;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int action = event.getAction();

        switch (action){
            case MotionEvent.ACTION_DOWN:
                if ( isPointOnViews(event)) {
                    //标记状态为拖拽,并记录上次触摸坐标
                    mCurrentState = State.DRAGGING;
                    mLastPointX = event.getX();
                    mLastPointY = event.getY();
                }
                break;

            case MotionEvent.ACTION_MOVE:
                int deltaX = (int) (event.getX() - mLastPointX);
                int deltaY = (int) (event.getY() - mLastPointY);
                if (mCurrentState == State.DRAGGING && mDragView != null
                        && (Math.abs(deltaX) > mSlop || Math.abs(deltaY) > mSlop)) {
                    //如果符合条件则对被拖拽的 child 进行位置移动
                    ViewCompat.offsetLeftAndRight(mDragView,deltaX);
                    ViewCompat.offsetTopAndBottom(mDragView,deltaY);
                    mLastPointX = event.getX();
                    mLastPointY = event.getY();
                }
                break;

            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                if ( mCurrentState == State.DRAGGING ){
                    // 标记状态为空闲,并将 mDragView 变量置为 null
                    mCurrentState = State.IDLE;
                    mDragView = null;
                }
                break;
        }
        return true;
    }

    /**
     * 判断触摸的位置是否落在 child 身上
     *
     * */
    private boolean isPointOnViews(MotionEvent ev) {
        boolean result = false;
        Rect rect = new Rect();
        for (int i = 0;i < getChildCount();i++) {
            View view = getChildAt(i);
            rect.set((int)view.getX(),(int)view.getY(),(int)view.getX()+(int)view.getWidth()
                ,(int)view.getY()+view.getHeight());

            if (rect.contains((int)ev.getX(),(int)ev.getY())){
                //标记被拖拽的child
                mDragView = view;
                result = true;
                break;
            }
        }

        return  result && mCurrentState != State.DRAGGING;
    }
}

注释写得很清楚,流程之前也分析过。现在我们来进行验证,验证的前置条件就是放 3 个 View 到 DragViewGroup 中,然后检测能不能够手指移动它。布局代码比较简单,我就不张贴了。直接看效果。

这里写图片描述

可以看到,基本的拖拽的功能实现了,但是有个细节需要优化,当 3 个 child 显示重叠时,触摸它的公共区域,总是最底层的 child 被响应,这有点反人类,正常的操作应该是最上层的最先被响应。那么怎么优化呢?

在上面代码中 mDragView 用来标记可以被拖拽的 child,我们在 isPointOnViews() 方法中找到最先适配的 child 然后赋值,但是由于 FrameLayout 的特性,最上面的 child 其实在 ViewGroup 的索引位置最靠后。

private boolean isPointOnViews(MotionEvent ev) {
    boolean result = false;
    Rect rect = new Rect();
    for (int i = 0;i < getChildCount();i++) {
        View view = getChildAt(i);
        rect.set((int)view.getX(),(int)view.getY(),(int)view.getX()+(int)view.getWidth()
            ,(int)view.getY()+view.getHeight());

        if (rect.contains((int)ev.getX(),(int)ev.getY())){
            //标记被拖拽的child
            mDragView = view;
            result = true;
            break;
        }
    }

    return  result && mCurrentState != State.DRAGGING;
}

因此,我们可以做一小小改动就能修正这个问题,那就是遍历 children 的时候,逆序进行。这样先从顶层检查找到最适配触摸位置的地方,代码如下:

private boolean isPointOnViews(MotionEvent ev) {
    boolean result = false;
    Rect rect = new Rect();
    for (int i = getChildCount() - 1;i >= 0;i--) {
        View view = getChildAt(i);
        rect.set((int)view.getX(),(int)view.getY(),(int)view.getX()+(int)view.getWidth()
            ,(int)view.getY()+view.getHeight());

        if (rect.contains((int)ev.getX(),(int)ev.getY())){
            //标记被拖拽的child
            mDragView = view;
            result = true;
            break;
        }
    }

    return  result && mCurrentState != State.DRAGGING;
}

效果如图:
这里写图片描述

回弹效果

可能还有的同学在想,我想在拖拽中实现回弹效果怎么样?毕竟这样更符合拖拽这一特性。上面的代码,都是假设手指离开屏幕后,child 停留在新的坐标上,如果我们的需求就释放手指后 child 移动回原来的位置,那么怎么做呢?

其实答案很简单,我们需要做如下工作:
1. 记录 child 原来位置的坐标。
2. 手指释放时借助于属性动画,从新的位置到原始位置做数值变化,变化的过程中移动 child 最终就形成了回弹的动画效果。

public class DragViewGroup extends FrameLayout {
    private static final String TAG = "TestViewGroup";

    // 记录手指上次触摸的坐标
    private float mLastPointX;
    private float mLastPointY;

    // 记录被拖拽之前 child 的位置坐标
    private float mDragViewOrigX;
    private float mDragViewOrigY;



    //用于识别最小的滑动距离
    private int mSlop;
    // 用于标识正在被拖拽的 child,为 null 时表明没有 child 被拖拽
    private View mDragView;



    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int action = event.getAction();

        switch (action){
            case MotionEvent.ACTION_DOWN:
                if ( isPointOnViews(event)) {
                    //标记状态为拖拽,并记录上次触摸坐标
                    mCurrentState = State.DRAGGING;
                    mLastPointX = event.getX();
                    mLastPointY = event.getY();
                }
                break;

            case MotionEvent.ACTION_MOVE:
                int deltaX = (int) (event.getX() - mLastPointX);
                int deltaY = (int) (event.getY() - mLastPointY);
                if (mCurrentState == State.DRAGGING && mDragView != null
                        && (Math.abs(deltaX) > mSlop || Math.abs(deltaY) > mSlop)) {
                    //如果符合条件则对被拖拽的 child 进行位置移动
                    ViewCompat.offsetLeftAndRight(mDragView,deltaX);
                    ViewCompat.offsetTopAndBottom(mDragView,deltaY);
                    mLastPointX = event.getX();
                    mLastPointY = event.getY();
                }
                break;

            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                if ( mCurrentState == State.DRAGGING ){
                    // 标记状态为空闲,并将 mDragView 变量置为 null
                    if (mDragView != null ) {
                        ValueAnimator animator = ValueAnimator.ofFloat(mDragView.getX(),mDragViewOrigX);
                        animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                            @Override
                            public void onAnimationUpdate(ValueAnimator animation) {
                                mDragView.setX((Float) animation.getAnimatedValue());
                            }
                        });
                        ValueAnimator animator1 = ValueAnimator.ofFloat(mDragView.getY(),mDragViewOrigY);
                        animator1.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                            @Override
                            public void onAnimationUpdate(ValueAnimator animation) {
                                mDragView.setY((Float) animation.getAnimatedValue());
                            }
                        });
                        AnimatorSet animatorSet = new AnimatorSet();
                        animatorSet.play(animator).with(animator1);
                        animatorSet.addListener(new AnimatorListenerAdapter() {
                            @Override
                            public void onAnimationEnd(Animator animation) {
                                super.onAnimationEnd(animation);
                                mDragView = null;
                            }
                        });
                        animatorSet.start();
                    }else {
                        mDragView = null;
                    }
                    mCurrentState = State.IDLE;

                }
                break;
        }
        return true;
    }

    /**
     * 判断触摸的位置是否落在 child 身上
     *
     * */
    private boolean isPointOnViews(MotionEvent ev) {
        boolean result = false;
        Rect rect = new Rect();
        for (int i = getChildCount() - 1;i >= 0;i--) {
            View view = getChildAt(i);
            rect.set((int)view.getX(),(int)view.getY(),(int)view.getX()+(int)view.getWidth()
                ,(int)view.getY()+view.getHeight());

            if (rect.contains((int)ev.getX(),(int)ev.getY())){
                //标记被拖拽的child
                mDragView = view;
                // 保存拖拽之间 child 的位置坐标
                mDragViewOrigX = mDragView.getX();
                mDragViewOrigY = mDragView.getY();
                result = true;
                break;
            }
        }

        return  result && mCurrentState != State.DRAGGING;
    }
}

这里写图片描述

到此,我们就用自己的方式实现了比较简单的拖拽功能,下面的部分自然就是学习如何用 ViewDragHelper 实现这一功能了。

ViewDragHelper 基本介绍

这里写图片描述

ViewDragHelper 是收录在 v4 兼容包中一个工具类,它的目的是辅助自定义 ViewGroup。ViewDragHelper 针对 ViewGroup 中的拖拽和重新定位 views 操作时提供了一系列非常有用的方法和状态追踪。

上面就是官网对于 ViewDragHelper,它的本质了只是一个工具类而已,为了更好地运用在拖拽这一动作上。

我们先看看它的使用方法。

ViewDragHelper 的创建

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

static ViewDragHelper   create(ViewGroup forParent, ViewDragHelper.Callback cb)

ViewDragHelper 提供了两个工厂方法来创建实例,为了简单起见,我们先分析第二个方法好了。它有两个参数。

forParent 自然是与 ViewDragHelper 相关联的 ViewGroup。

cb 的类型是 ViewDragHelper.Callback 是个回调,我们具体分析。

ViewDragHelper 提供了一系列回调,用来指示拖拽时的各种信号及状态变化,其中的方法全部陈列如下:

int clampViewPositionHorizontal(View child, int left, int dx)

int clampViewPositionVertical(View child, int top, int dy)

int getOrderedChildIndex(int index)

int getViewHorizontalDragRange(View child)

int getViewVerticalDragRange(View child)

void    onEdgeDragStarted(int edgeFlags, int pointerId)

boolean onEdgeLock(int edgeFlags)

void    onEdgeTouched(int edgeFlags, int pointerId)

void    onViewCaptured(View capturedChild, int activePointerId)

void    onViewDragStateChanged(int state)

void    onViewPositionChanged(View changedView, int left, int top, int dx, int dy)

void    onViewReleased(View releasedChild, float xvel, float yvel)

abstract boolean    tryCaptureView(View child, int pointerId)

作为初学者,我们先关注能构成完整拖拽事件序列的回调方法,这就是 MVP 法则,但这个 MVP 是 Mininum Viable Product 的意思,意思是最小可行性产品。通俗来讲就是用最少的资源能跑动起来的产品。

那么,ViewDragHelper 用哪几个回调能构成最简单能运行的实例呢?

// 决定了是否需要捕获这个 child,只有捕获了才能进行下面的拖拽行为
abstract boolean    tryCaptureView(View child, int pointerId)  


// 修整 child 水平方向上的坐标,left 指 child 要移动到的坐标,dx 相对上次的偏移量
int clampViewPositionHorizontal(View child, int left, int dx)

// 修整 child 垂直方向上的坐标,top 指 child 要移动到的坐标,dy 相对上次的偏移量
int clampViewPositionVertical(View child, int top, int dy)


// 手指释放时的回调
void    onViewReleased(View releasedChild, float xvel, float yvel)

但有了这些回调方法,还远远不够。

前文有讲过拖拽功能通过自己实现 onTouchEvent() 方法其实也是可以的,但是我们自己编写的代码肯定没有 Google 开发者稳定性高,毕竟是人家设计的产品嘛。

现在如果要用 ViewDragHelper 来处理这个流程,自然要把触摸相关的动作要委托给它了。这里涉及到了 ViewDrageHelper 两个方法。

/** 是否应该拦截 children 的触摸事件,
*只有拦截了 ViewDragHelper 才能进行后续的动作
*
*将它放在 ViewGroup 中的 onInterceptTouchEvent() 方法中就好了
**/
boolean shouldInterceptTouchEvent(MotionEvent ev)

/** 处理 ViewGroup 中传递过来的触摸事件序列
*在 ViewGroup 中的 onTouchEvent() 方法中处理
*/
void    processTouchEvent(MotionEvent ev)

接下来,就可以用 ViewDragHelper 进行编码了。我们用它来改写之前我们自己实现的拖拽动作。

public class TestViewGroup extends FrameLayout {
    private static final String TAG = "TestViewGroup";

    private ViewDragHelper mDragHelper;

    public TestViewGroup(Context context) {
        this(context,null);
    }

    public TestViewGroup(Context context, AttributeSet attrs) {
        this(context, attrs,0);
    }

    public TestViewGroup(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        mDragHelper = ViewDragHelper.create(this, 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 void onViewReleased(View releasedChild, float xvel, float yvel) {
                super.onViewReleased(releasedChild, xvel, yvel);
            }
        });

    }


    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        return mDragHelper.shouldInterceptTouchEvent(ev);
    }

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

}

看到了没有,寥寥几行代码就实现了同样的功能。住得注意的是


tryCaptureView() 方法返回 true 时才会导致下面的回调方法被调用  

clampViewPositionHorizontal() 和 clampViewPositionVertical() 中处理 child 拖拽时的位置坐标。

我们看看现象如何:
这里写图片描述

ViewDragHelper 实现拖拽后的回弹

现在,我要加一些难度了。还是要实现前文一样,手指释放的时候 child 回弹到原来的位置。

这个时候就要借助于另外一个 API 了。

将 child 安置到坐标 (finalLeft,finalTop) 的位置。
settleCapturedViewAt(int finalLeft, int finalTop)

因此,我们同样是要记录 child 刚开始被拖拽时的位置,这个可以在回调方法中设置。

void onViewCaptured(View capturedChild, int activePointerId)

所以呢,我们的思路:
1. 在 onViewCaptured() 方法中记录拖拽前的坐标。
2. 在 onViewReleased() 方法中调用 settleCapturedViewAt() 方法来重定位 child。

于是,我们修正相应代码:

mDragHelper = ViewDragHelper.create(this, new ViewDragHelper.Callback() {
    @Override
    public boolean tryCaptureView(View child, int pointerId) {
        return true;
    }

    @Override
    public void onViewCaptured(View capturedChild, int activePointerId) {
        super.onViewCaptured(capturedChild, activePointerId);
        mDragOriLeft = capturedChild.getLeft();
        mDragOriTop = capturedChild.getTop();
    }

    @Override
    public int clampViewPositionHorizontal(View child, int left, int dx) {
        return left;
    }

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

    @Override
    public void onViewReleased(View releasedChild, float xvel, float yvel) {
        super.onViewReleased(releasedChild, xvel, yvel);

        mDragHelper.settleCapturedViewAt((int)mDragOriLeft,(int)mDragOriLeft);
    }
});

这里写图片描述

可是,效果却没有变化。我们查看 settleCapturedViewAt() 源码。

/**
 * 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.
 *
 * @param finalLeft Settled left edge position for the captured view
 * @param finalTop Settled top edge position for the captured view
 * @return true if animation should continue through {@link #continueSettling(boolean)} calls
 */
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));
}

注释说明了一切, settleCapturedViewAt() 方法返回 true 时,开发者需要调用 continueSettleing() 方法在动画过程中的每一帧。

可能有些同学没能理解明白,我分解一下:
1. settleCapturedViewAt() 方法调用的目的的将 child 定位到 (left,top) 位置,但它不是瞬间到达,有一个动画的过程。
2. 需要动画过程的每一帧调用 continueSettling() 方法,直到它返回 false。
3. 如果 continureSettling() 返回 false 表明此次动画结束。

如果看过我《不再迷惑,也许之前你从未真正懂得 Scroller 及滑动机制》这篇博文的同学,已经可以很快与 Scroller 建立联系,Scroller 本身只是针对数值作动画而不会移动 View 本身的内容。

一个 View 能够配合 Scroller 工作实现滑动机制,在于 computeScroll() 方法中调用 Scroller.computeScrollOffsets() 方法,然后再调用 scrollTo() 方法,这样导致不停地重绘,不停地调用 computeScroll() 方法,然后又不停地 Scroller.computeScrollOffset() 方法,最终才使 Scroller 动画运转起来,并将 Scroller 变化的数值与 View 中 mScrollX、mScrollY 关键属性建立映射。

我在之前的文章中这么解释过:

Scroller 无法自驱动,一定需要外部条件多次调用它的 computeScrollOffset() 方法,正因为这些源源不断的调动,驱动了 Scroller 本身。这有点像自行车的后飞轮,只有踏板采了一圈,后飞轮自己才会转一圈。踏板不间断地踩踏,自行车才会平滑地向前行驶。而后飞轮齿轮与踏板齿轮之间的比例关系可以看作是 Scroller 中的 mCurrentX、mCurrentY 与 View 中的 mScrollerX、mScrollerY 之间的某种映射关系。

然后,我给了一张动图。
这里写图片描述

现在 settleCapturedViewAt() 的启动只是一个开始,需要源源不断地调用 continueSettleing() 方法,所以,我有种预感,这其中必定有 Scroller 相关的机制。于是,我查看 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) {
        // 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;
}

看到这里的时候,果然 Scroller 才是幕后英雄。好了,线索找到了,鸣金收兵。对于 Scroller 的研究不是本文重点,大家知道它能触发一个平滑的位移效果就好。

再回到原来的问题,continueSettleing() 方法要在每一帧被调用,和编码 Scroller 代码一样,最适合的场合就是 ViewGroup 中的 computeScroll() 方法中。

public TestViewGroup(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);

    mDragHelper = ViewDragHelper.create(this, new ViewDragHelper.Callback() {
        @Override
        public boolean tryCaptureView(View child, int pointerId) {
            return true;
        }

        @Override
        public void onViewCaptured(View capturedChild, int activePointerId) {
            super.onViewCaptured(capturedChild, activePointerId);
            mDragOriLeft = capturedChild.getLeft();
            mDragOriTop = capturedChild.getTop();
            Log.d(TAG, "onViewCaptured: left:"+mDragOriLeft
                    +" top:"+mDragOriTop);
        }

        @Override
        public int clampViewPositionHorizontal(View child, int left, int dx) {
            return left;
        }

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

        @Override
        public void onViewReleased(View releasedChild, float xvel, float yvel) {
            super.onViewReleased(releasedChild, xvel, yvel);

            mDragHelper.settleCapturedViewAt((int)mDragOriLeft,(int)mDragOriTop);
            invalidate();
        }
    });

}


@Override
public void computeScroll() {
    super.computeScroll();
    if (mDragHelper != null && mDragHelper.continueSettling(true)) {
        invalidate();
    }
}

我们再看看效果
这里写图片描述

讲到这里的时候,其实 ViewDragHelper 已经差不多了。不过,还有一个比较重要的特性,那就是边缘触发。

边缘触发最常见的就是侧滑菜单了。
这里写图片描述

如上所示,边缘触发时,只要在能够响应拖动的 View 的对应边缘上进行触摸便可以开始拖动了。

那么,用 ViewDragHelper 怎么来实现这样的行为呢?

首先,得声明 ViewDragHelper 能够识别哪些连续触摸行为。

void setEdgeTrackingEnabled (int edgeFlags)

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;

因为涉及到位操作,所以 edgeFlags 的取值可以通过或操作多重组合。比如

//识别左边缘和上边缘
setEdgeTrackingEnabled(EDGE_LEFT | EDGE_TOP) 

然后,ViewDragHelper 就可以识别边缘触摸了。在 ViewDragHelper.Callback() 中它也有对应的回调方法。


/**边缘拖拽开始*/
@Override
public void onEdgeDragStarted(int edgeFlags, int pointerId) {
    super.onEdgeDragStarted(edgeFlags, pointerId);
}


/**边缘被点击*/
@Override
public void onEdgeTouched(int edgeFlags, int pointerId) {
    super.onEdgeTouched(edgeFlags, pointerId);
}

我们一般在 onEdgeDragStarted() 方法中处理。如大家所见,这个方法只是通知了开发者边缘拖拽开始,但是它并没有提供 View 类型的参数,所以,它的目的也很明确,就是只提供边缘拖拽的信息,至于具体哪个 child 将被拖拽,这个权力交给开发者自己。

在正常的项目开发中,一般只有某个或者某些 child 才针对特定的边缘拖拽进行响应。

所以,在 onEdgeDragStarted() 方法中,我们应该手动捕获这些 child,让它们成为拖拽的现象。

之前,在回调方法

public boolean tryCaptureView(View child, int pointerId) {
    return true;
}

可以针对 child 的 ID 和类型来决定 child 是否响应拖拽。

而在 onEdgeDragStarted() 方法中,并没有这么类型的 child 参数,所以我们要手动指定一个 child,通过这个 API。

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

如源码所示,这个方法直接将 childView 设置为 mCaptureView,然后调用 mCallback 的回调方法 onViewCaptured()。

为了免于有些同学的迷惑。我们再来看看 tryCaptureView() 方法的调用流程。

public void processTouchEvent(MotionEvent ev) {


    switch (action) {
        case MotionEvent.ACTION_DOWN: {
            final float x = ev.getX();
            final float y = ev.getY();
            final int pointerId = ev.getPointerId(0);
            final View toCapture = findTopChildUnder((int) x, (int) y);

            tryCaptureViewForDrag(toCapture, pointerId);


            break;
        }

    }
}


boolean tryCaptureViewForDrag(View toCapture, int pointerId) {

    if (toCapture != null && mCallback.tryCaptureView(toCapture, pointerId)) {

        captureChildView(toCapture, pointerId);
        return true;
    }
    return false;
}

精简了相关代码,我们可以看到这样的流程。

这里写图片描述

我们可以得出什么结论呢?

Callback.tryCaptureView() 在 captureChildView() 之前调用,它是正常流程中判断 captureChildView() 的一个依据,只有它返回 true 时,captureChildView 才能被调用。

好吧,我们直接调用 captureChildView() 其实就是绕过了正常的流程,直接指定了某个 child 为被拖拽的 view。感觉有些暴力,但是只要我们明白它们内部流程,也没有什么问题。

我们继续做试验。现在,我们的目标是让最上层的 childview 响应边缘触发的效果。于是,我们可以这样更改代码。

public TestViewGroup(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);

    mDragHelper = ViewDragHelper.create(this, new ViewDragHelper.Callback() {
        @Override
        public boolean tryCaptureView(View child, int pointerId) {
            return true;
        }

        @Override
        public void onViewCaptured(View capturedChild, int activePointerId) {
            super.onViewCaptured(capturedChild, activePointerId);
            mDragOriLeft = capturedChild.getLeft();
            mDragOriTop = capturedChild.getTop();
            Log.d(TAG, "onViewCaptured: left:"+mDragOriLeft
                    +" top:"+mDragOriTop);
        }

        @Override
        public void onEdgeDragStarted(int edgeFlags, int pointerId) {
            super.onEdgeDragStarted(edgeFlags, pointerId);
            Log.d(TAG, "onEdgeDragStarted: "+edgeFlags);
            mDragHelper.captureChildView(getChildAt(getChildCount()-1),pointerId);
        }

        @Override
        public void onEdgeTouched(int edgeFlags, int pointerId) {
            super.onEdgeTouched(edgeFlags, pointerId);
            Log.d(TAG, "onEdgeTouched: "+edgeFlags);
        }

        @Override
        public int clampViewPositionHorizontal(View child, int left, int dx) {
            return left;
        }

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

        @Override
        public void onViewReleased(View releasedChild, float xvel, float yvel) {
            super.onViewReleased(releasedChild, xvel, yvel);

            mDragHelper.settleCapturedViewAt((int)mDragOriLeft,(int)mDragOriTop);
            invalidate();
        }
    });

    mDragHelper.setEdgeTrackingEnabled(ViewDragHelper.EDGE_ALL);

}

这里写图片描述
这里有意思的是,所谓边缘,其实是 childview 共同的边缘。也是下图红框的部分,大家需要注意。

这里写图片描述

然后我觉得这样解释不怎么合理。就去看源码。


/**
* Enable edge tracking for the selected edges of the parent view.
* The callback's {@link Callback#onEdgeTouched(int, int)} and
* {@link Callback#onEdgeDragStarted(int, int)} methods will only be invoked
* for edges for which edge tracking has been enabled.
*
* @param edgeFlags Combination of edge flags describing the edges to watch
* @see #EDGE_LEFT
* @see #EDGE_TOP
* @see #EDGE_RIGHT
* @see #EDGE_BOTTOM
*/
public void setEdgeTrackingEnabled(int edgeFlags) {
    mTrackingEdges = edgeFlags;
}

代码注释上说明了,边缘触发的边指得是 ViewGroup 本身的 4 条边,而非我之前想像的是某个 childview 的边缘。

边缘滑动的边指 ViewGroup 本身的 4 条边

到这里,边缘拖拽的效果也实现了,那么 ViewDragHelper 还有什么有趣的操作方法呢?可能大家对这两个会感兴趣。

//快速滚动的意思,一般手指离开后 view 还会由于惯性继续滑动。
void    flingCapturedView(int minLeft, int minTop, int maxLeft, int maxTop);

// 让 child 平滑地滑动到某个位置
boolean smoothSlideViewTo(View child, int finalLeft, int finalTop)

我们先来试验 flingCapturedView() 的效果。
之前,我们在 onViewRelease() 回调方法中让被拖拽的 child 回到原来的位置。现在,做少许改变,其它 child 仍然保持这一行为,但最低层的 child 被拖拽后会进行习惯性动作继续滑行一段距离。

@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
    super.onViewReleased(releasedChild, xvel, yvel);

    View child = getChildAt(0);
    if ( child != null && child == releasedChild ) {
        mDragHelper.flingCapturedView(getPaddingLeft(),getPaddingTop(),
                getWidth()-getPaddingRight()-child.getWidth(),
                getHeight()-getPaddingBottom()-child.getHeight());
    } else {

        mDragHelper.settleCapturedViewAt((int)mDragOriLeft,(int)mDragOriTop);
    }
    invalidate();
}

效果如下:
这里写图片描述

至于 smoothSlideViewTo() 这个方法,我们可以在 TestViewGroup 中编写这样的代码

public void testSmoothSlide(boolean isReverse) {
    if ( mDragHelper != null ) {
        View child = getChildAt(1);
        if ( child != null ) {
            if ( isReverse ) {
                mDragHelper.smoothSlideViewTo(child,
                        getLeft(),getTop());
            } else {
                mDragHelper.smoothSlideViewTo(child,
                        getRight()-child.getWidth(),
                        getBottom()-child.getHeight());
            }
            invalidate();
        }
    }
}

然后在外部,用来一个 Button 来控制它。

public class MainActivity extends AppCompatActivity {

    Button mBtnTest;
    TestViewGroup mViewGroup;
    private boolean isReverse;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_test_draghelper);
        mViewGroup = (TestViewGroup) findViewById(R.id.test_viewgroup);
        mBtnTest = (Button) findViewById(R.id.btn_test);
        mBtnTest.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                mViewGroup.testSmoothSlide(isReverse);
                isReverse = !isReverse;
            }
        });
//        setContentView(R.layout.activity_test);
    }
}

效果如下:
这里写图片描述

ViewDragHelper 操纵了 TestViewGroup 正常滑动了。

不过因为看到了 Button 的存在,我想起了 Android 开发中常见的滑动冲突问题,因为 Button 本身能够响应点击事件,那么 ViewDragHelper 能不能移动这个 Button 呢?
这里写图片描述

默认的是没有效果的,如果我非得移动这个 Button 呢?ViewDragHelper 可以实现吗?答案是肯定的,我们只要重写 ViewDragHelper.Callback 中两个回调方法


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

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

这两个方法只要返回值大于 0 ,那么它就可以滑动。

这里写图片描述

也许有人好奇,如果只是返回值大于 0 就好了,那么为什么返回值不用 boolean 类型呢?

其实返回值代表一段 X 轴或者 Y 轴一段可以拖拽的距离,它们参与了一些动作动画时长的计算,如 settleCapturedViewAt() 调用时要计算位移的时长。

private boolean forceSettleCapturedViewAt(int finalLeft, int finalTop, int xvel, int yvel) {

    final int duration = computeSettleDuration(mCapturedView, dx, dy, xvel, yvel);
    mScroller.startScroll(startLeft, startTop, dx, dy, duration);

    return true;
}

private int computeSettleDuration(View child, int dx, int dy, int xvel, int yvel) {


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

可以看到动画的时长计算时参考了很多变量,反而是 getViewHorizontalDragRange() 和 getViewVerticalDragRange() 返回的数值对于时长结果而言影响很小。

所以,我们只需要 getViewHorizontalDragRange() 和 getViewVerticalDragRange() 返回值大于 1 表明该方向对应 child 可以移动,返回值为 0 的话就不能移动。当然,这种 child 针对的是 Button 这类本身可以 clickable 的控件。

文章到了这里,正式结束了。可以看到 ViewDragHelper 也没有想像中的那么难。

总结

我们回过头来看看,文章开头的那个动图。
这里写图片描述
现在看来,它也挺好实现的不是吗?

当然,我演示的时候是用的继承的 FrameLayout,而实际上要做的工作还很多,大家可以尝试用 RecyclerView 来实现它。这个动图的效果只是一个引子,怎么去完美实现它属于对 ViewDragHelper 的实战了,是另外一个课题了。有兴趣的同学可以自行去研究。

现在,对这篇博文进行一点知识点的回顾。

  1. 不借助于 ViewDragHelper ,自己也能实现拖拽功能,比如博文的例子,比如 Launcher2 工程中相关的代码。
  2. ViewDragHelper 是一个工具类,为拖拽而生,它提供了一系列的方法和回调方法用来操纵拖拽及跟踪 child 被拖拽时的位置、状态。
  3. 回调方法 tryCaptureView() 返回值为 true 时,ViewDragHelper 才能拖动对应的 child。但是可以直接调用 captureChildView() 方法来指定被拖动的 child。
  4. ViewDragHelper 要在 ViewGroup 中的 onInterceptTouchEvent() 方法中调用 shouldInterceptTouchEvent() 方法,然后在 ViewGroup 中的 onTouchEvent() 方法调用 processTouchEvent()。
  5. ViewDragHelper 内部有一个 Scroller 变量,所以涉及到位移动画如 settleCapturedViewAt()、flingCapturedView()、smoothSlideViewTo() 方法时要复写 ViewGroup 的 computeScroll() 方法,在这个方法中调用 ViewDragHelper 的 continueSettling()。
  6. 如果要移动像 Button 这样 clickable == true 的控件,要复写 ViewDragHelper.Callback 中的两个回调方法 getViewHorizontalDragRange() 和 getViewVerticalDragRange(),使它们对应方法的返回值大于 0 就好了。

相关源码

阅读更多
版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/briblue/article/details/73730386
文章标签: android ViewGroup 拖拽
想对作者说点什么? 我来说一句

没有更多推荐了,返回首页

关闭
关闭
关闭