View.OnDragListener, ViewDragHelper, GestureDetector --- 拖放滑动

【记录】记录点滴

【场景】学习官方文档和sample时,实验的内容以及遇到的小坑

【需求】简单实现,基于View.OnDragListener, ViewDragHelper以及GestureDetector(或OnTouchEvent,OnTouchListener)实现拖放View滑动的效果

1. View.OnDragListener

官方文档提供了示例demo,包括实现拖放,自定义拖放时阴影的样式。

如果要实现拖放功能,需要1)创建View.OnDragListener,它是拖放事件的监听器;2)某些View过ViewGroup需要监听拖放事件,并根据操作状态实现需要的效果,因此对这些View设置创建好的View.OnDragListener监听器;3)创建一个View被拖放时的影子,并调用方法表明开始拖放操作。顺序不是固定的,感觉这个顺序比较好理解。

    //自定义的拖放监听器,未实现任何操作
    class DragerListener implements View.OnDragListener {
        @Override
        public boolean onDrag(View v, DragEvent event) {
            int action = event.getAction();
            switch (event.getAction()) {
                case DragEvent.ACTION_DRAG_STARTED:
                    /** 拖拽开始时 */
                    break;
                case DragEvent.ACTION_DRAG_ENTERED:
                    /** 拖拽进入区域时 */
                    break;
                case DragEvent.ACTION_DRAG_Location:
                    /** 拖拽进入区域后,仍在区域内拖动时 */
                    break;
                case DragEvent.ACTION_DRAG_EXITED:
                    /** 离开区域时 */
                    break;
                case DragEvent.ACTION_DROP:
                    /** 在区域内放开时 */
                    break;
                case DragEvent.ACTION_DRAG_ENDED:
                    /** 结束时 */
                default:
                    break;
            }
            return true;
        }
    }

 画一个简单的图说明,假设这是个手机屏幕,黑色圆是需要我们拖拽的View,红色矩形就是需要监听拖放事件并进行响应的区域。 首先创建拖放的监听器,随后仅仅对红色矩形(实际的应用场景中可能是View,也可能是ViewGroup)设置监听器,最后设置黑色圆的拖放阴影,并在适当的场景(如touch,longclick等)下告诉系统,我们开始了拖放操作。

开始拖拽圆时,触发START操作

DragEvent.ACTION_DRAG_STARTED

 当拖拽的点(通常是被拖拽的View的中心点)进入到红色的矩形区域后,触发ENTERED操作

DragEvent.ACTION_DRAG_ENTERED

 当拖拽的点一直在红色区域内移动时,会不断地触发LOCATION操作

DragEvent.ACTION_DRAG_LOCATION

 如果拖拽的点仍在红色区域内时,释放了被拖拽的View,则会触发DROP。因为DROP可以理解为是与区域绑定的,所以一次DROP只会交给一个对象来处理。脑补下,如果左下角还有个蓝色区域也监听了拖放事件,但是我们在红色区域内释放了View,那么只有红色区域的监听器会触发DROP操作

DragEvent.ACTION_DROP

并且在释放后,会触发ENDED操作

DragEvent.ACTION_DRAG_ENDED

另一种情况,如果拖拽的点移动出了区域,那么会触发EXITED操作

DragEvent.ACTION_DRAG_EXITED

到这里,应该可以弄清楚监听器的各操作类型的触发条件了。实现简单的拖拽

按照之前的描述,先自定义监听器,处理各种类型的操作

class DragListener implements View.OnDragListener {

        @Override
        public boolean onDrag(View v, DragEvent event) {
            int action = event.getAction();
            switch (event.getAction()) {
                case DragEvent.ACTION_DRAG_STARTED:
                    break;
                case DragEvent.ACTION_DRAG_ENTERED:
                    Log.e("lxy", "ACTION_DRAG_ENTERED");
                    //这个v就是监听拖拽事件的View,对照上面的图就是红色矩形区域
                    //拖拽进入区域后,变成蓝色背景
                    v.setBackgroundColor(Color.BLUE);
                    break;
                case DragEvent.ACTION_DRAG_LOCATION:
                    Log.e("lxy", "ACTION_DRAG_LOCATION");
                    break;
                case DragEvent.ACTION_DRAG_EXITED:
                    Log.e("lxy", "ACTION_DRAG_EXITED");
                    //拖拽出区域后,恢复成红色背景
                    v.setBackgroundColor(Color.RED);
                    break;
                case DragEvent.ACTION_DROP:
                    //释放后,v变成灰色背景
                    //也可以根据需求,做处理,比如“展示拖拽进来的图片”
                    v.setBackgroundColor(Color.GRAY);
                    break;
                case DragEvent.ACTION_DRAG_ENDED:
                default:
                    break;
            }
            return true;
        }
    }

随后,对View设置监听器

//layout1对应为上述内容中的红色区域
FrameLayout layout1 = findViewById(R.id.layout1);
layout1.setOnDragListener(mDragListen);

 最后创建默认的拖拽阴影,并在适当的情况下告诉系统我们开始拖拽了

    mDrag1.setOnLongClickListener(v -> {
            //创建拖拽阴影
            View.DragShadowBuilder shadowBuilder = new View.DragShadowBuilder(mDrag1);
            //告诉系统开始拖拽了
            v.startDrag(null, shadowBuilder, v, 0);
            return true;
        });

 至此,就可以简单实现拖放功能了,这里去除了其他博客中都会有的ClipData,因为我没有要传的数据。

不过这种实现还是有些局限,比如我们拖拽的是生成的阴影,拖拽过程中View本身不会移动,释放后View也不会改变位置。

附上另一个demo的截图来说明,拖拽第一个View时,以及执行DROP后。

                        

如果希望改变View改变位置,觉得(因为没试过,假如有坑呢)可以让父布局监听拖拽事件,并在LOCATION或DROP/ENDED这些操作中来改变View的位置等等。

PS:如果希望修改拖拽的阴影,可以参考官方的示例,自定义个View.DragShadowBuilder

private static class MyDragShadowBuilder extends View.DragShadowBuilder {
 
// The drag shadow image, defined as a drawable thing
private static Drawable shadow;
 
    // Defines the constructor for myDragShadowBuilder
    public MyDragShadowBuilder(View v) {
 
        // Stores the View parameter passed to myDragShadowBuilder.
        super(v);
 
        // Creates a draggable image that will fill the Canvas provided by the system.
        shadow = new ColorDrawable(Color.LTGRAY);
    }
 
    // Defines a callback that sends the drag shadow dimensions and touch point back to the
    // system.
    @Override
    public void onProvideShadowMetrics (Point size, Point touch)
        // Defines local variables
        private int width, height;
 
        // Sets the width of the shadow to half the width of the original View
        width = getView().getWidth() / 2;
 
        // Sets the height of the shadow to half the height of the original View
        height = getView().getHeight() / 2;
 
        // The drag shadow is a ColorDrawable. This sets its dimensions to be the same as the
        // Canvas that the system will provide. As a result, the drag shadow will fill the
        // Canvas.
        shadow.setBounds(0, 0, width, height);
 
        // Sets the size parameter's width and height values. These get back to the system
        // through the size parameter.
        size.set(width, height);
 
        // Sets the touch point's position to be in the middle of the drag shadow
        touch.set(width / 2, height / 2);
    }
 
    // Defines a callback that draws the drag shadow in a Canvas that the system constructs
    // from the dimensions passed in onProvideShadowMetrics().
    @Override
    public void onDrawShadow(Canvas canvas) {
 
        // Draws the ColorDrawable in the Canvas passed in from the system.
        shadow.draw(canvas);
    }
}

2. ViewDragHelper

另一中简单实现拖拽的方式就是借助于ViewDragHelper,为了研究OnDragListener而查看官方sample时,竟发现sample是用这个实现的。

首先ViewDragHelper必须结合ViewGroup使用。1)自定义ViewGroup,创建ViewDragHelper;2)ViewGroup中让ViewDragHelper来处理事件拦截(onInterceptTouchEvent)和事件消费(onTouchEvent)。

首先,创建ViewDragHelper

//this是ViewGroup,1f代表灵敏度,越大越灵敏,DragCallback是ViewDragHelper.Callback,提供触摸过程中回调的相关方法
mViewDragHelper = ViewDragHelper.create(this, 1f, new DragCallback());

 然后让ViewDragHelper来处理事件拦截和事件消费

 @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        int action = ev.getActionMasked();
        if (action == MotionEvent.ACTION_CANCEL
                || action == MotionEvent.ACTION_UP) {
            mViewDragHelper.cancel();
            return false;
        }
        return mViewDragHelper.shouldInterceptTouchEvent(ev);
    }
@Override
    public boolean onTouchEvent(MotionEvent event) {
        mViewDragHelper.processTouchEvent(event);
        return true;
    }

 ViewDragHelper的使用只需要这几步,很简单。接下来,分析记录下之前的DragCallback。DragCallback继承了ViewDrag.Callback,主要包含以下几个回调方法,先学习最简单和常用的几个,写了注释的。

//child水平方向上的坐标,left是child要移动过去的位置,dx是相对于上次的偏移量
int clampViewPositionHorizontal(View child, int left, int dx)

//child垂直方向上的坐标,top是child要移动过去的位置,dy是相对于上次的偏移量
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)

//捕获时,即tryCaptureView返回true时触发
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)

//能否捕获child,如果能捕获(返回true),才可以执行后续的拖拽
abstract boolean tryCaptureView(View child, int pointerId)

那么基于tryCaptureView,clampViewPositionHorizontal,clampViewPositionVertical,onViewCaptured以及onViewReleased就可以简单实现拖拽效果了。如下,自定义的DragCallback最后长这样,参考Google sample

    dragViews = new ArrayList<View>();
    public void addDragView(View view){
        dragViews.add(view);
    }


    class DragCallback extends ViewDragHelper.Callback{
        @Override
        public boolean tryCaptureView(@NonNull View child, int pointerId) {
            //可以拖拽的View,事先会通过addDragView方法添加到dragViews中
            if(dragViews.contains(child)){
                return true;
            }
            return false;
        }

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

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

        @Override
        public void onViewCaptured(@NonNull View capturedChild, int activePointerId) {
            super.onViewCaptured(capturedChild, activePointerId);
            //捕获时,可以做些处理
        }

        @Override
        public void onViewReleased(@NonNull View releasedChild, float xvel, float yvel) {
            //释放时,可以做些处理,如下做了释放后回弹至某个位置的处理
            //这两个方法实际是基于Scroller实现的,最终会调用startScroll方法,所以回弹效果要结合computeScroll()来实现
            //如果不做处理,释放后View会停留在释放的位置
//            mViewDragHelper.settleCapturedViewAt(0, 0);
            mViewDragHelper.smoothSlideViewTo(releasedChild, 0, 0);
            invalidate();
        }
    }


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

 在onViewReleased中,有settleCapturedViewAt和smoothSlideviewTo两个方法,他们的区别在于回弹时的起始速度。这里还必须使用invalidate()之类的强制重绘方法,用来触发computeScroll

相较于OnDragListener,ViewDragHelper更简单,更适用于单纯的拖拽。

补充:介绍下其他的回调方法。

1)边缘滑动:实现边缘滑动,要先代码设置,下面列举了多种实用方式

//可以多重组合使用
//可以滑动左边缘 和 右边缘
mViewDragHelper.setEdgeTrackingEnabled(ViewDragHelper.EDGE_LEFT | ViewDragHelper.EDGE_RIGHT);
//可以单侧边缘
//可以滑动上边缘
mViewDragHelper.setEdgeTrackingEnabled(ViewDragHelper.EDGE_TOP);
//可以滑动下边缘
mViewDragHelper.setEdgeTrackingEnabled(ViewDragHelper.EDGE_BOTTOM);
//可以滑动所有边缘
mViewDragHelper.setEdgeTrackingEnabled(ViewDragHelper.EDGE_ALL);

对应边缘滑动的几个回调方法是


//边缘被点击
void onEdgeTouched(int edgeFlags, int pointerId)

//边缘拖拽开始,可以手动调用captureChildView触发从边缘拖动View的效果
void onEdgeDragStarted(int edgeFlags, int pointerId)

//返回true,表示锁定指定的边缘,锁定后即使设置了setTrackingEnabled,滑动也不会触发onEdgeDragStared
//返回false,不锁定指定的边缘
boolean onEdgeLock(int edgeFlags)

实现边缘滑动,通常重写onEdgeDragStarted方法,调用captureChildView指定滑动的View,如下

@Override
public void onEdgeDragStarted(int edgeFlags, int pointerId) {
    super.onEdgeDragStarted(edgeFlags, pointerId);
    //dragViews.get(0)是基于上面内容的View,指定随边缘滑动的View
    mViewDragHelper.captureChildView(dragViews.get(0),pointerId);
}

那为什么,只需要调用captureChildView就可以实现边缘滑动?

在使用ViewDragHelper时,我们会让ViewDragHelper来处理事件拦截和事件消费。简单分析下这两部分的代码

首先是事件拦截,onInterceptTouchEvent中的shouldInterceptTouchEvent

点击时,toCapture是findTopChildUnder根据(x, y)触摸点计算得到的View,而此时mCapturedView为null,mDragState为STATE_IDLE。所以无论在什么情况下(触摸点(x, y)在View上或(x, y) 没在View上),都不会执行tryCaptureViewForDrag。

PS,这里还牵扯到了比较复杂的事件分发机制,暂时不在这里记录了。

public boolean shouldInterceptTouchEvent(@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);
                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;
            }
            ...
            ...
            ...
        }
        ...
        ...
        ...
        return mDragState == STATE_DRAGGING;    
    }

接着是事件消费,onTouchEvent中的processTouchEvent。

因为Touch不会再走onInterceptTouchEvent,所以接着分析processTouchEvent,里面关键的是tryCaptureViewForDrag方法。

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

 在tryCaptureViewForDrag方法中,如果tryCaptureView如果返回true,就会执行captureChildView。这个captureChildView就是最开始提到的,实现边缘滑动的关键。也就是它指定了要滑动的View,mCapturedView,并设置STATE_DRAGGING状态。然后在processTouchEvent处理ACTION_MOVE时,就会对mCapturedView处理滑动。

所以在重写onEdgeDragStarted方法时,调用captureChildView来指定要滑动的View就能实现边缘滑动了。

 参考了

 https://blog.csdn.net/briblue/article/details/73730386

 https://www.cnblogs.com/liemng/p/4997427.html

3. GestureDetector(或OnTouchEvent,OnTouchListener)

其实,想到拖拽,我们一般第一反应就是重写Touch相关的处理方法,比如对View设置setOnTouchListener,重写ViewGroup中的OnTouchEvent等。所以借这个机会,简单记录下GestureDetector。

使用的话,包括:1)创建手势监听器;2)创建手势类;3)让手势类处理Touch事件。

这个是第一次写的代码,貌似逻辑很正确,distanceX与distanceY是最近两次Touch的差值,可以利用getX与getY验证,lastX - e2.getX的值一致的。

mGestureDetector = new GestureDetector(this, new DragGestureListener);

//为了看着舒服,去除了部分必要的方法
class DragGestureListener extends GestureDetector.OnGestureListener{
    @Override
            public boolean onDown(MotionEvent e) {

                return true;
            }

            @Override
            public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
                if(Math.abs(distanceX) > ViewConfiguration.getWindowTouchSlop() 
                    || Math.abs(distanceY) > ViewConfiguration.getWindowTouchSlop()){
                    ViewCompat.offsetLeftAndRight(mDrag3, (int) -distanceX);
                    ViewCompat.offsetTopAndBottom(mDrag3, (int) -distanceY);
                }
                return true;
            }


            @Override
            public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
                return false;
            }
}

 情况一:对View使用

用它来处理View的Touch事件。但是!拖动的过程中抖动很大(单向滑动时,distanceX与distanceY的值抖动),不顺畅。

//Activity中,对View设置
mDrag3.setOnTouchListener((v, event) -> {
    mGestureDetector.onTouchEvent(event);
    return true;
})

情况二:在ViewGroup中使用

简单修改onScroll后,将这部分代码放到ViewGroup中,就不会发生抖动的问题,虽然还是有点顿顿的(把TouchSlop的判断去掉就好了)。

    //自定义ViewGrup中,修改
    //修改监听器的onScroll,拖拽的是ViewGroup中的子View,dragV
    ...
    @Override
    public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
        if(Math.abs(distanceX) > ViewConfiguration.getWindowTouchSlop() 
            || Math.abs(distanceY) > ViewConfiguration.getWindowTouchSlop()){
            ViewCompat.offsetLeftAndRight(dragV, (int) -distanceX);
            ViewCompat.offsetTopAndBottom(dragV, (int) -distanceY);
        }
        return true;
    }
    ...


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


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

GestureDetectore的内部源码还没有研究,针对于第一种情况,先只重写Touch事件消费来实现试试,利用getX与getY方法计算得到move的dx距离后,会发生抖动,那么用getRawX与getRawY计算得到dx后就不会。原来是这样,忘记了getX与getRawX的区别,他们的参考点是不同。借用其他博客的一张图,

对View使用时,View自身会移动,也就是说getX方法的参考点也发生了移动,那么dx当然会产生抖动的情况。虽然还没看GestureDetectore的相关源码,但是估计onScroll中的distanceX,distanceY是基于getX,getY方法计算得到的,然后通过getRawX验证下了猜测,这里就不粘贴内容了。想想,对于情况一,我的使用思路可能就存在问题。

PS,示例想简单点,所以很多内容需要根据实际的需求来完善,如,应该完善事件拦截onInterceptTouchEvent,判断哪些情况应该拦截;判断Down事件是否落在子View上;限制拖拽的边界等

参考

https://blog.csdn.net/dmk877/article/details/51550031

猜你喜欢

转载自blog.csdn.net/FeiLaughing/article/details/83036524
今日推荐