最近项目中需要实现一个类似于悬浮球的功能,可以自由拖动。 以前通过View的onTouchListener来实现过View的拖动功能,但是通过这种方式的话,需要额外处理Viwe的点击事件(单击事件)。
今天通过Android拖放框架来实现一下View的拖动功能。
Android拖放框架
拖放框架主要是用于把一个View拖放到另一个View,当启用多窗口模式后,也可以把View从一个应用拖放到另一个应用。可以设置要传递的数据,且在拖动过程中会绘制拖动路径(可以自定义绘制内容)。
startDrag()和startDragAndDrop()
可以通过调用View.startDrag或View.startDragAndDrop() (Android N以上)来告知系统开始拖动操作。这两个方法都需要传入4个参数,分别是:
参数 | 类型 | 含义 |
---|---|---|
clipData | ClipData | 拖放操作要传输的数据 |
shadowBuilder | DragShadowBuilder | 拖动阴影的构造器 |
myLocalState | Object | 本地数据,当跨Activity时无法接收 |
flags | int | 控制拖放操作的标志 |
DragShadowBuilder
可以通过View.DragShadowBuilder(View)来使用默认的拖动阴影(与传入的View样式相同),也可以继承View.DragShadowBuilder来实现不同的阴影。
OnDragListener
可以通过设置OnDragListener来实时监听拖动事件,在OnDragListener中可以获取到DragEvent,通过DragEvent中的Action可以知道当前拖动事件的操作类型。这些Action包含:
Action | 含义 |
---|---|
ACTION_DRAG_STARTED | 调用了View.startDrag()或View.startDragAndDrop()方法,并获取了DragShadow后,注册了OnDragListener的监听View会接收到此事件操作类型,表示开始拖动。若要接收 ACTION_DROP,则必须返回true。 |
ACTION_DRAG_ENTERED | DragShadow进入监听View的边界框时,监听View会接收到此事件操作类型。若想要接收后续的 ACTION_DRAG_LOCATION和ACTION_DRAG_EXITED,则必须返回true。 |
ACTION_DRAG_LOCATION | DragShadow在监听View的边界框内移动时,监听View会接收到此事件操作类型。 |
ACTION_DRAG_EXITED | DragShadow离开监听View的边界框时,监听View会接收到此事件操作类型。 |
ACTION_DROP | 在监听View上释放DragShadow时,监听View会接收到此事件操作类型。若成功的处理了释放操作,返回true,反之则返回false |
ACTION_DRAG_ENDED | 系统结束拖动操作时,监听View会接收到此事件操作类型。此时可以通过调用event.getResult()来获取ACTION_DROP返回的处理结果值。 |
实现View的拖放
相比于通过onTouchListener方法来说,拖放框架自带了路径绘制,不用通过在拖动过程中持续获取坐标设置到View来显示路径,而且无需额外再处理View的单击事件,方便了许多。
以下是我做的简单实现:
val dragView = findViewById<View>(R.id.drag_view)
dragView?.run {
val clipDataItem = ClipData.Item("111")
val clipData = ClipData("111", arrayOf(ClipDescription.MIMETYPE_TEXT_PLAIN), clipDataItem)
setOnLongClickListener {
val dragShadowBuilder = View.DragShadowBuilder(it)
//clipData是测试传递数据,无需传递数据可以直接传null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
it.startDragAndDrop(clipData, dragShadowBuilder, null, 0)
} else {
it.startDrag(clipData, dragShadowBuilder, null, 0)
}
true
}
}
//在根布局设置监听
val rootView: FrameLayout = findViewById(android.R.id.content)
rootView.setOnDragListener { v, event ->
when (event.action) {
DragEvent.ACTION_DRAG_STARTED -> {
//当调用View.startDrag()或startDragAndDrop()时会收到这个Action
//先把View隐藏
if (dragView?.visibility == View.VISIBLE) {
dragView?.visibility = View.INVISIBLE
}
}
DragEvent.ACTION_DRAG_ENTERED -> {
//当被拖拽的View进入监听View的边界时会收到这个Action
}
DragEvent.ACTION_DRAG_LOCATION -> {
//当被拖拽的View在监听View边界内移动时会收到这个Action
}
DragEvent.ACTION_DRAG_EXITED -> {
//当被拖拽的View离开监听View的边界时会收到这个Action
}
DragEvent.ACTION_DROP -> {
//释放
//将View移动到释放时的位置,并显示(这里还应该处理贴边逻辑,暂未实现)
//设置View的中心在当前坐标
val width = (crossPromotionAdView?.width ?: 0) / 2
val height = (crossPromotionAdView?.height ?: 0) / 2
dragView?.x = event.x - width
dragView?.y = event.y - height
//显示View
dragView?.visibility = View.VISIBLE
//获取调用startDrag()或startDragAndDrop()时传入的数据
val clipDataItem = event.clipData.getItemAt(0).text
}
DragEvent.ACTION_DRAG_ENDED -> {
//拖动结束
}
}
//这边我所有事件默认都返回true,可以按需调整
true
}
实现效果如下图:
实际使用时碰到的问题
在线上的项目中,收集到了崩溃信息如下(部分机型):
# main(2)
java.lang.NullPointerException
Attempt to invoke virtual method 'int android.content.ClipData.getItemCount()' on a null object reference android.widget.Editor.onDrop(Editor.java:2563)
1 android.widget.Editor.onDrop(Editor.java:2851)
2 android.widget.TextView.onDragEvent(TextView.java:13506)
3 android.view.View.callDragEventHandler(View.java:26070)
4 android.view.View.dispatchDragEvent(View.java:26058)
5 android.view.ViewGroup.dispatchDragEvent(ViewGroup.java:1765)
6 android.view.ViewGroup.dispatchDragEvent(ViewGroup.java:1765)
7 android.view.ViewGroup.dispatchDragEvent(ViewGroup.java:1765)
8 android.view.ViewGroup.dispatchDragEvent(ViewGroup.java:1765)
9 android.view.ViewRootImpl.handleDragEvent(ViewRootImpl.java:7465)
10 android.view.ViewRootImpl.access$2100(ViewRootImpl.java:198)
11 android.view.ViewRootImpl$ViewRootHandler.handleMessage(ViewRootImpl.java:5016)
12 android.os.Handler.dispatchMessage(Handler.java:106)
13 android.os.Looper.loop(Looper.java:214)
14 android.app.ActivityThread.main(ActivityThread.java:7073)
15 java.lang.reflect.Method.invoke(Native Method)
16 com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:493)
17 com.android.internal.os.ZygoteInit.main(ZygoteInit.java:964)
在stack overflow上找到了相同的问题(链接)。比较合适的解决方法为,在每次启动拖拽时,即使无需使用ClipData,仍创建一个包含空数据的ClipData,代码如下:
dragView?.setOnLongClickListener {
val dragShadowBuilder = DragShadowBuilder(dragView)
val dummyData = ClipData.newPlainText("dummyData", "")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
it.startDragAndDrop(dummyData, dragShadowBuilder, null, View.DRAG_FLAG_OPAQUE)
} else {
it.startDrag(dummyData, dragShadowBuilder, null, 0)
}
true
}