【记录】记录点滴
【场景】学习官方文档和sample时,实验的内容以及遇到的小坑
【需求】简单实现,基于View.OnDragListener, ViewDragHelper以及GestureDetector(或OnTouchEvent,OnTouchListener)实现拖放View滑动的效果
官方文档提供了示例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);
}
}
另一中简单实现拖拽的方式就是借助于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();
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
其实,想到拖拽,我们一般第一反应就是重写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