ViewDragHelper的简单分析(一)

前段时间简单的写了两篇关于android View的滚动的博客(详见《 View的滚动原理简单解析》和《 View的滚动原理简单解析(二)》 ,我们知道要改变一个View的位置

有好几种方式比如:

1)调用View的layout方法,设置View的布局位置

2)修改View的layoutParam参数

3)ParentView调用scrollTo/scrollBy方法改动childView的位置

当然还有其他方法,这个方法就是本篇博客的主角,这两个方法就是View类中的offsetLeftAndRight和offsetTopAndBottom,通过这两个方法可以用来修改一个View的的位置;比如要让一个View从初始位置水平竖直方向个移动100,简单如下代码就可以:

 view.offsetLeftAndRight(100);
 view.offsetTopAndBottom(100);
那么这个滚动View的方法跟scrollTo/scrollBy方法的区别就是scroll/scrollBy方法不会改变一个View的getLeft,getRight,getBottom,getTop的值,而offsetLeftAndRight和offsetTopAndBottom却可以改变上面的四个方法的返回值

这么个简单的调用就实现了View位置的改变,为什么说这两个方法是本篇博客的主角呢,且本篇博客的标题是ViewDragHelper,那么这两个方法与今天要讲的有什么关联?下面就开始正式开始吧!

既然是实现拖动,肯定是手指有按下并移动的动作(MOVE事件),这就是需要从它的事件处理先理一理是怎么实现滚动的:查看ViewDragHelper关于事件处理的方法processTouchEvent,该方法在处理MOVE事件的时候有如下的代码:

case MotionEvent.ACTION_MOVE: {
                if (mDragState == STATE_DRAGGING) {//如果处于拖动状态
                    final int index = MotionEventCompat.findPointerIndex(ev, mActivePointerId);
                    final float x = MotionEventCompat.getX(ev, index);
                    final float y = MotionEventCompat.getY(ev, index);
                    final int idx = (int) (x - mLastMotionX[mActivePointerId]);
                    final int idy = (int) (y - mLastMotionY[mActivePointerId]);
                    //实现拖动
                    dragTo(mCapturedView.getLeft() + idx, mCapturedView.getTop() + idy, idx, idy);

                    saveLastMotion(ev);
                }

在处理move事件的时候调用了dragTo方法,看名字也知道是什么鬼意思,这个方法正式实现View拖动的,进入看看一下:
  /**
    *(left,right)构成了此时View滚动过后左上角的位置
    *@param left 当前View即将滚动到的x轴的位置,即新的x的位置
    *@param right 当前View即滚动到的y轴的位置,即新的y的位置
    *@dx 水平滚动的距离
    *@dy 竖直滚动的距离
    **/
    private void dragTo(int left, int top, int dx, int dy) {
        int clampedX = left;
        int clampedY = top;
        //获取滚动之前view的位置坐标(oldLeft,oldTop)
        final int oldLeft = mCapturedView.getLeft();
        final int oldTop = mCapturedView.getTop();
        //如果水平滚动的距离不为空
        if (dx != 0) {
            //在处理手指滚动距离的时候,可以对这个距离做最后的计算处理
            clampedX = mCallback.clampViewPositionHorizontal(mCapturedView, left, dx);
            mCapturedView.offsetLeftAndRight(clampedX - oldLeft);
        }
    //如果竖直滚动的距离不为空
  if (dy != 0) {
            clampedY = mCallback.clampViewPositionVertical(mCapturedView, top, dy);
            mCapturedView.offsetTopAndBottom(clampedY - oldTop);
        }

        if (dx != 0 || dy != 0) {
            final int clampedDx = clampedX - oldLeft;
            final int clampedDy = clampedY - oldTop;
//滚动结束后的操作
            mCallback.onViewPositionChanged(mCapturedView, clampedX, clampedY,
                    clampedDx, clampedDy);
        }
    }
     
这个里面让mCaptureView实现变换位置的就是调用offsetLeftAndRght或者offsetTopAndBottom;同时我们还可以发现一种情况在计算滚动距离到真正实现拖拽位置之前,我们可以通过CallBack的clampViewPositionHorizontal或者clampViewPositionVertical来进行拖拽之前的做最后的处理工作,该方法的返回值结合View当前的位置来最终确定拖拽过后滚动的位置。在拖动改变位置后还可以调用onViewPositionChanged来做一些处理工作,随着拖拽动作的进行onViewPositionChanged这个方法是持续调用的。

那么分析到这个地方,ViewDragHelper拖拽View的基本原理就很清晰了:

1)捕获拖动目标View,在ViewDragHelper里也就是mCaptureView

2)计算滚动的距离,然后处理MOVE时候,调用dragTo方法实现mCaptrueView的拖动

3)在实现拖动之前或者拖动之后的时候可以自定义CallBack的onViewPositionChanged方法来对拖动进行处理。

由于ViewDragHelper的构造器是私有的,所以外部不能直接用new 来初始化,不过它提供了两个create方法来完成获取ViewDragHelper对象,这两个方法最终会调用ViewDragHelper的私有构造器来完成了初始化:

private ViewDragHelper(Context context, ViewGroup forParent, Callback cb) {
        if (forParent == null) {
            throw new IllegalArgumentException("Parent view may not be null");
        }
        if (cb == null) {
            throw new IllegalArgumentException("Callback may not be null");
        }

        mParentView = forParent;
        mCallback = cb;
       //此处有省略代码  
        .....
       mScroller = ScrollerCompat.create(context, sInterpolator);
   }

构造器初始化了一个ViewGroup即forParent和一个Callback即cb;同时还初始化了一个mScroller这个ScrollerCompat,这个类有好几种实现,mScroller这个对象主要负责View的拖拽滚动等效果,简单的原理可参考《 View的滚动原理简单解析(二)》 ,其实ScrollerCompatcreate方法返回的一个对象ScrollerCompatImplBase就是Scroller这个在起到作用,具体的内部细节就不多说.

下面就说说怎么初始化mCaptureView这个拖动的View的,ViewDragHelper提供了两个方法对他进行初始化:

public void captureChildView(View childView, int activePointerId) {
        //mCaptureView的parentView必须是mParentView
        if (childView.getParent() != mParentView) {
            throw new IllegalArgumentException("captureChildView: parameter must be a descendant " +
                    "of the ViewDragHelper's tracked parent view (" + mParentView + ")");
        }
        //初始化mCaptureView
        mCapturedView = childView;
        mActivePointerId = activePointerId;
        //调用callback的onViewCaptured,对要移动的view可以在这个方法在移动之前做一些处理
        mCallback.onViewCaptured(childView, activePointerId);
        //表明该childView处于拖动drag状态
        setDragState(STATE_DRAGGING);
    }

//设置被拖动的view的状态
    void setDragState(int state) {
        mParentView.removeCallbacks(mSetIdleRunnable);
        //更新状态
        if (mDragState != state) {
            mDragState = state;
            //拖动状态发生了改变的时候调用
            mCallback.onViewDragStateChanged(state);
            if (mDragState == STATE_IDLE) {
                mCapturedView = null;
            }
        }
    }


captureView方法可以总结出来三个要点:

1)要拖动的captureView的parentView,必须是创建ViewDragHelper对象时初始化的mParentView

2)在捕获到mCaptureView的时候,可以调用Callback的onViewCapture方法里面做一些自定义的处理工作

3)此时设置mCaptureView的状态为STATE_DRAGGING状态

4)在拖拽状态发生改变的时候android monkey可以设置callback的onViewDragState根据不同的状态做一些处理工作

先不说这个captureChidView方法具体的怎么调用,要想获取mCaptureView正常逻辑来说应该是在手指按下屏幕的时候进行捕获,所以来分析ViewDragHelper这个类里面关于ACTION_DOWN事件的处理:在processTouchEvent这个方法里面有如下代码:

case MotionEvent.ACTION_DOWN: {//处理down事件
    final float x = ev.getX();
    final float y = ev.getY();
    final int pointerId = MotionEventCompat.getPointerId(ev, 0);
    //查找手指位置对应的childView
    final View toCapture = findTopChildUnder((int) x, (int) y);

    saveInitialMotion(x, y, pointerId);

    //尝试捕获要拖动的View或者检查是否有childView可以拖动
     tryCaptureViewForDrag(toCapture, pointerId);

关于手指按下事件有两个要点

1)根据手指所在的位置调用findTopChildUnde(x,y)来获取parentView中的子View,它的实现很简单,就是循环Viewgoup或者parentView中的子View,如果手指在子View的布局范围内就返回(注意该法方法循环的的起始下标):

public View findTopChildUnder(int x, int y) {
        final int childCount = mParentView.getChildCount();
        for (int i = childCount - 1; i >= 0; i--) {
            final View child = mParentView.getChildAt(mCallback.getOrderedChildIndex(i));
            if (x >= child.getLeft() && x < child.getRight() &&
                    y >= child.getTop() && y < child.getBottom()) {
                return child;
            }
        }
        return null;
    }

2)调用tryCaptureViewForDrag来判断在parentView中是否有可拖拽的childView:

 boolean tryCaptureViewForDrag(View toCapture, int pointerId) {
       //如果在之前调用tryCaptureViewForDrag方法的时候已经获取了mCapturedView 
       //就直接返回true对mCaptureView进行拖拽
       if (toCapture == mCapturedView && mActivePointerId == pointerId) {
            // Already done!
            return true;
        }
     //第一次调用tryCaptrueViewForDrag时候
     if (toCapture != null && mCallback.tryCaptureView(toCapture, pointerId)) {
            mActivePointerId = pointerId;
            captureChildView(toCapture, pointerId);
            return true;
        }
        return false;
    }
上面的方法第一个if条件很简单,就不多作解释,第二个条件是决定parentView中是否有可拖拽的childView的关键所在:toCapture!=null即手指所在的位置有childView存在,并且callback的tryCaptureView返回true,关键就在于tryCaptureView,它的意义所在就是判断toCapture这个View是否允许对它进行拖拽,如果允许就返回true,同时调用了captureChildView对mCaptureView进行了初始化(详见上面关于captureChildView方法的说明):如果返回false则不允许toCapture拖拽

所以我们在设置重写Callback的tryCaptureView的时候可以这么写:

    @Override
        public boolean tryCaptureView(View child, int pointerId) {
            return true;
        }
或者
   @Override
        public boolean tryCaptureView(View child, int pointerId) {
         return child == getChildAt(0); } 

 
 
 
 

从上面的分析可以知道我们可以通过定义一个继承了Callback类来重写里面的onViewCapture,onViewDragState,onViewPositionChanged来分别对mCaptureView拖拽的过程中做一些我们自己内的一些操作。上面都是说的callback的方法都是拖拽过程中让我们控制,那么在拖拽结束的时候能否继续让我们这么monkey做一些其他的处理呢?正如上面我们分析dragTo的方式,我们去看看processTouchEvent这个方法里面对ACTION_UP是怎么处理的:

case MotionEvent.ACTION_UP: {
                if (mDragState == STATE_DRAGGING) {//如果处于拖动状态
                    releaseViewForPointerUp();
                }
                cancel();
                break;
            }
如上面的代码,在处理up事件的时候调用了方法,这个方法里面有调用  dispatchViewReleased方法:
 private void dispatchViewReleased(float xvel, float yvel) {
        mReleaseInProgress = true;
        mCallback.onViewReleased(mCapturedView, xvel, yvel);//调用callback的onViewRelease方法
        mReleaseInProgress = false;

        if (mDragState == STATE_DRAGGING) {
           //改变状态
            setDragState(STATE_IDLE);
        }
    }
  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;
    }



发现在dispatchViewReleased方法里面调用了callback的onViewReleased方法,这样的话我们就可以在定义自己的CallBack对象的时候重写这个方法在drag结束的时候做一些其他的操作!同时在dispatchViewReleased方法里面还设置了mCaptureView的拖拽状态为IDLE状态

所以简单自定义一个简单的拖拽效果的View就很简单了:扩展LinearLayouttuo拖拽它的第一个子View,简单的页面效果如下(博文最后悔附上demo代码)

上图就是一个自定义带有ViewDragHelper的LinearLayout,该LinearLayout里面就是ImageView.ViewDragHelper的初始化以及callBack的代码如下:

 @Override  
    protected void onFinishInflate() {  
        super.onFinishInflate();  
        //获取要拖拽的对象  
        mDragHelper.captureChildView(getChildAt(0), 0);  
    }  
  
    private class Callback extends ViewDragHelper.Callback {  
        @Override  
        public void onViewCaptured(View capturedChild, int activePointerId) {  
            Log.e(tag,"start drag");  
        }  
  
        @Override  
        public boolean tryCaptureView(View child, int pointerId) {  
            return child == getChildAt(0);  
        }  
  
        /** 
         * 当view的位置发生变化的时候调用,具体是在调用dragTo方法的时候调用 
         * if (dx != 0 || dy != 0) { 
         * final int clampedDx = clampedX - oldLeft; 
         * final int clampedDy = clampedY - oldTop; 
         * mCallback.onViewPositionChanged(mCapturedView, clampedX, clampedY, 
         * clampedDx, clampedDy); 
         * } 
         * 该方法在clampViewPositionHorizontal之后在调用 
         * 
         * @param left view改变后的新的X轴位置 
         * @param top  view位置改变后的新的y的位置 
         * @param dx   水平方向滚动的距离 
         * @param dy   水平方向滚动的距离 
         */  
        @Override  
        public void onViewPositionChanged(View changedView, int left, int top,  
                                          int dx, int dy) {  
            super.onViewPositionChanged(changedView, left, top, dx, dy);  
            Log.e(tag,"(left,right)===("+left + ","+ top+")");  
        }  
  
        @Override  
        public void onViewReleased(View releasedChild, float xvel, float yvel) {  
           Log.e(tag," drag over");  
        }  
    }

 
 拖拽着上面的小狗走一个,然后抬起手指会产生如下log打印: 
 

ViewDragHelper的简单分析(一)_第1张图片

这很明显可以发现onViewPositionChanged方法随着手指的移动会持续调用,但是里面的left和top值始终不变,事实上在拖动的过程中图片并没有发生移动。这是为什么呢?其实这个问题要从dragTo方法上入手,在进行水平或者竖直移动的时候关键是下面两个方法:

 if (dx != 0) {
            //在处理手指滚动距离的时候,可以对这个距离做最后的计算处理
            clampedX = mCallback.clampViewPositionHorizontal(mCapturedView, left, dx);
            mCapturedView.offsetLeftAndRight(clampedX - oldLeft);
        }
    //如果竖直滚动的距离不为空
  if (dy != 0) {
            clampedY = mCallback.clampViewPositionVertical(mCapturedView, top, dy);
            mCapturedView.offsetTopAndBottom(clampedY - oldTop);
        }
传入offsetLeftAndRight/offsetTopAndBottom方法里传入的offset的值需要经过clampViewPositionHorizontal/offsetTopAndBottom方法计算过后的结果,然后拿计算过后的结果减去mCamptureView的当前位置的坐标left/top.且因为此时(left,top)==(0,0),并且clampViewPositionHorizontal/offsetTopAndBottom默认的返回值也是0,所以传入offsetLeftAndRight/offsetTopAndBottom的offset的偏移量也是0,所以拖动起来位置不变

所以这个方法解决起来也很简单,在ImageView的xml配置里面设置一下marginLeft或者marginRight值就可以:

 android:layout_marginLeft="100dp"
            android:layout_marginTop="100dp"
但是这个还有个问题你会发现,因为没有重写clampViewPositionHorizontal/clampViewPositionVertical的返回值 ,clampedX-oldLeft/clampedY-oldTop)的计算记过实际上位-oldLeft/-oldTop.也就是说在这种情况下上面的小狗图片拖动一下位置会立马回到(0,0)坐标点,然后继续拖拽小狗图片位置又不会变化了(之所以说是立马,是因为实际并没有过度效果,因为直接相当于调用了offseetLeftAndRight)。

所以看来才扩展CallBack的时候我们必须重写clampViewPositionHorizontal/clampViewPositionVertical这两个方法,正如ViewDragHelper对这两个方法的注释上说的那样:

the extending class must override this method and provide the desired clamping.

所以为了真正让上图中的小狗随着手指的拖拽运动起来,我们在重写了clampViewPositionHorizontal/clampViewPositionVertical这两个方法,代码如下:

/**
         * 该方法在dragTo方法中调用,计算的结果借给View的offsetLeftAndRight或者
         * offsetTopAndBottom来用,该方法调用在onViewPositionChanged之前
         * 计算的结果交给 view的offsetLeftAndRight方法
         * @param child 要拖动的View
         * @param left  下一时刻view的左上角的X坐标
         * @param dx    水平方向即将滚动的距离
         */
        @Override
        public int clampViewPositionHorizontal(View child, int left, int dx) {
            return left;
        }

        /**
         * 该方法在dragTo方法中调用,计算的结果借给View的offsetLeftAndRight或者
         * offsetTopAndBottom来用,该方法调用在onViewPositionChanged之前
         * 计算的结果交给 view的offsetLeftAndRight方法
         * @param child 要拖动的View
         * @param top  下一时刻view的左上角的Y坐标
         * @param dy    竖直向即将滚动的距离
         */
        @Override
        public int clampViewPositionVertical(View child, int top, int dy) {
            return top;
        }

这两个方法参数都很简单,我们重写的方法也很简单:这两个方法的第二个参数的left/top的意思是相对于当前时刻View的位置坐标随着拖动将要到达的下一个坐标位。所以直接返回left/top,即可offsetLeftAndRight/offsetTopAndBottom方法的时候就是是offsetLeftAndRight(dx)/offsetTopAndBottom(dy)了:运行打印小狗的位置变化如下(很明显其坐标位置发生了改变):

ViewDragHelper的简单分析(一)_第2张图片


为了方便说明问题,以下只对clampViewPositionHorizontal进行说明,如果按照上面所说的直接返回left话会发现小狗图片会超过parentView也就是会变的在屏幕中看不到,那么怎么做限制呢?比如当childView的左边缘拖动到parentView的左/右边缘的时候,手指继续向左/右移动的时候childView的位置不在变化,该怎么办呢?这个很简单,其实按照刚才所说如果offsetLeftAndRight(0)的话,View的水平位置是不会发生改变的。那么我们需要做的就很简单了,就是让clampedX - oldLeft = 0即可(见如下两行代码)

 clampedX = mCallback.clampViewPositionHorizontal(mCapturedView, left, dx);
  mCapturedView.offsetLeftAndRight(clampedX - oldLeft);
   其中clampViewPositionHorizontal的第二个参数有如下关系:

  left=oldLeft+dx

所以为了让clampedX - oldLeft = 0就不是问题了,如果达到左边界或者右边界的话clampViewPositionHorizontal方法直接返回left-dx即可。代码如下:

  public int clampViewPositionHorizontal(View child, int left, int dx) {
            final int leftPadding = getPaddingLeft();
            //parentView可用的右边界的位置
            final int rightBound = getWidth()-getPaddingRight();
            //如果达到了左边界或者右边界
            if(leftPadding>left||left+ child.getWidth()>rightBound){
                return left-dx;
            }
            //child没有碰到parentView的边缘直接滚动到left的位置
            return left;
        }

相应的道理,处理上下边界的代码就是如下这样了:

  @Override
        public int clampViewPositionVertical(View child, int top, int dy) {
            final int topPadding = getPaddingTop();
            final int bottomBound = getHeight()-getPaddingBottom();
            //当childView遇到了parentView的上边缘或者下边缘
            if(top<topPadding||top+child.getHeight()>bottomBound){
                return top-dy;
            }

            return top;
        }

最后来做个总结,ViewDragHelper的工作原理有如下几个步骤:

1)初始化ViewDragHelper对象并定义CallBack对象

2)当手指按下时处理ACTION_DOWN事件,根据手指的的位置找到parentView中对应的childView即toCapture

3)如果toCapture不为空,那么就调用CallBack的tryCaptureView方法判断toView是否允许对其进行拖拽

4)如果tryCaptureView方法返回true,说明允许toCapture进行拖拽,并调用callBack的onViewCaptured方法和ViewDragHelper的captureChildView方法进行mCaptureView的初始化等动作

5)找到mCaptureView后,响应ACTION_MOVE事件,调用dragTo方法进行移动,移动的过程中可以重写callback的相关方法对mCaptureView进行一些自己控制

6)水平或者竖直滚动必须重写CallBack的clampViewPositionHorizontal或者clampViewPositionVertical两个方法

鉴于篇幅原因,ViewDragHelper的原理以及CallBack的几个先关方法及说明完毕,并且提供了一个简单的demo,不过这个demo还有好些问题需要改进,同时关于ViewDragHelper还有好多没有说明,这将在下一篇博文中一 一提起,本篇比较零碎,欢迎批评指正 ,demo代码下载

你可能感兴趣的:(android,viewdraghelper)