Android 两种拖拽 API 详解:ViewDragHelper 和 OnDragListener 的原理与区别

前言

Android 提供了两种拖拽 API,分别是 ViewDragHelperOnDragListener

OnDragListener

我们先看第二种 OnDragListener,代码如下:

kotlin 复制代码
class DragListenerView(context: Context, attrs: AttributeSet) : ViewGroup(context, attrs) {

    private var columns = 0
    private var rows = 0

    private val dragListener = OnDragListener { v, event ->
        when (event.action) {
            // 拖拽开始
            DragEvent.ACTION_DRAG_STARTED -> {
                if (event.localState === v) {
                    v.visibility = INVISIBLE
                }
            }

            // 拖拽进入某个View的区域,初始拖拽时也会触发,因为初始进入了被拖拽的View的区域
            DragEvent.ACTION_DRAG_ENTERED -> {
                if (event.localState !== v) {
                    // 此时 v 为拖拽进入的目标view
                    sortChildView(v)
                }
            }

            // 拖拽离开某个View的区域
            DragEvent.ACTION_DRAG_EXITED -> {
            }

            // 拖拽结束
            DragEvent.ACTION_DRAG_ENDED -> {
                if (event.localState === v) {
                    v.visibility = VISIBLE
                }
            }
        }

        true
    }

    private var draggedView: View? = null

    private var orderedChildView: MutableList<View> = mutableListOf()

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        columns = 2
        rows = childCount / columns + 1

        val widthSpec = MeasureSpec.getSize(widthMeasureSpec)
        val heightSpec = MeasureSpec.getSize(heightMeasureSpec)
        val childWidthMeasureSpec =
            MeasureSpec.makeMeasureSpec(widthSpec / columns, MeasureSpec.EXACTLY)
        val childHeightMeasureSpec =
            MeasureSpec.makeMeasureSpec(heightSpec / rows, MeasureSpec.EXACTLY)
        measureChildren(childWidthMeasureSpec, childHeightMeasureSpec)
        setMeasuredDimension(widthSpec, heightSpec)
    }

    override fun onLayout(
        changed: Boolean,
        l: Int,
        t: Int,
        r: Int,
        b: Int,
    ) {
        for (i in 0 until childCount) {
            val child = getChildAt(i)
            child.layout(
                0,
                0,
                child.measuredWidth,
                child.measuredHeight
            )
            child.translationX = (i % columns * child.measuredWidth).toFloat()
            child.translationY = (i / columns * child.measuredHeight).toFloat()
        }
    }

    override fun onFinishInflate() {
        super.onFinishInflate()
        for (i in 0 until childCount) {
            val child = getChildAt(i)
            orderedChildView.add(child)

            child.setOnLongClickListener { v ->
                draggedView = v
                v.startDragAndDrop(null, DragShadowBuilder(v), v, 0)
                true
            }

            child.setOnDragListener(dragListener)
        }
    }


    fun sortChildView(targetView: View) {
        // 重新排序View列表
        var targetViewIndex = 0
        var draggedViewIndex = 0
        orderedChildView.forEachIndexed { index, view ->
            if (view === targetView) {
                targetViewIndex = index
            }
            if (view === draggedView) {
                draggedViewIndex = index
            }
        }
        swapElements(orderedChildView, targetViewIndex, draggedViewIndex)
        // 使用动画重新布局
        for (i in 0 until orderedChildView.size) {
            val child = orderedChildView[i]
            val x = (i % columns * child.measuredWidth).toFloat()
            val y = (i / columns * child.measuredHeight).toFloat()
            child.animate()
                .translationX(x)
                .translationY(y)
                .setDuration(300)
                .start()
        }
    }

    private fun swapElements(list: MutableList<View>, index1: Int, index2: Int) {
        val temp = list[index1]
        list[index1] = list[index2]
        list[index2] = temp
    }

}

布局代码:

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<com.example.dragview.DragListenerView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <View
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="#F44336" />

    <View
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="#E91E63" />

    <View
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="#9C27B0" />

    <View
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="#673AB7" />

    <View
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="#3F51B5" />

</com.example.dragview.DragListenerView>

运行效果:

我们来讲解一下是如何实现的。

首先,对每个子 View 进行测量和布局。

kotlin 复制代码
// onLayout 方法中
for (i in 0 until childCount) {
    val child = getChildAt(i)
    child.layout(
        0,
        0,
        child.measuredWidth,
        child.measuredHeight
    )
    child.translationX = (i % columns * child.measuredWidth).toFloat()
    child.translationY = (i / columns * child.measuredHeight).toFloat()
}

在布局过程中,我们先调用 layout 方法将每个子 View 摆放在了 (0,0) 位置,然后再使用 translationX/Y 移动子 View 到对应的位置。

这是为了让动画过程更简单,动画时,只需考虑移动后的位置,而无需关心初始的位置和移动前的位置。

kotlin 复制代码
// onFinishInflate 方法中
for (i in 0 until childCount) {
    val child = getChildAt(i)
    orderedChildView.add(child)

    child.setOnLongClickListener { v ->
        draggedView = v
        v.startDragAndDrop(null, DragShadowBuilder(v), v, 0)
        true // 表示消费长按事件
    }

    child.setOnDragListener(dragListener)
}

然后在布局加载完成、视图都创建好后,我们为每一个子 View 设置了长按监听器。在长按时,会调用子 View 的 View.startDragAndDrop 方法,开始拖拽。

设置了拖拽监听器,在拖拽时,能够回调 OnDragListener.onDrag 方法。

还将每个 View 放入 orderedChildView 列表中,以便后续使用。在长按回调中,更新了当前被拖拽的 View。

kotlin 复制代码
private val dragListener = OnDragListener { v, event ->
    when (event.action) {
        DragEvent.ACTION_DRAG_STARTED -> {
            if (event.localState === v) {
                v.visibility = INVISIBLE
            }
        }

        DragEvent.ACTION_DRAG_ENTERED -> {
            if (event.localState !== v) {
                sortChildView(v)
            }
        }

        DragEvent.ACTION_DRAG_EXITED -> {
        }

        DragEvent.ACTION_DRAG_ENDED -> {
            if (event.localState === v) {
                v.visibility = VISIBLE
            }
        }
    }

    true
}

在拖拽过程中,每个注册了的子 View 都会收到拖拽的回调。

其中 localState 就是开始拖拽时传入的数据,也就是 startDragAndDrop 方法的第三个参数 myLocalState,就是当前正在被拖拽的 View,这个数据我们可以随时拿取。

此方法的第一个参数 ClipData data 也可以传递数据,但它只能在释放了被拖拽的数据后才能拿取,在 DragEvent.ACTION_DROP 事件中。

我们让正在被拖拽的 View 在开始拖拽时隐藏,在拖拽结束后显示。每当拖拽进某个 View 的区域时,我们就调用 sortChildView 方法,以动画的形式重排每个 View 的位置。

kotlin 复制代码
fun sortChildView(targetView: View) {
    // 获取目标View和被拖拽View的索引
    var targetViewIndex = 0
    var draggedViewIndex = 0
    orderedChildView.forEachIndexed { index, view ->
        if (view === targetView) {
            targetViewIndex = index
        }
        if (view === draggedView) {
            draggedViewIndex = index
        }
    }
    // 交换两个元素
    swapElements(orderedChildView, targetViewIndex, draggedViewIndex)
    // 使用动画重新布局
    for (i in 0 until orderedChildView.size) {
        val child = orderedChildView[i]
        val x = (i % columns * child.measuredWidth).toFloat()
        val y = (i / columns * child.measuredHeight).toFloat()
        child.animate()
            .translationX(x)
            .translationY(y)
            .setDuration(300)
    }
}

如果拖拽时并不隐藏,运行效果将会是:

为什么拖起来的是半透明的,原来的东西还在?

这是因为 startDragAndDrop 方法启动的是系统级的拖拽。我们拖动的并不是一个 View,而是一个影子,它被绘制在了最顶层,能够遮挡住状态栏、导航栏,甚至其他应用。

系统只关心这个影子和要传递的数据,并不管原来的 View 是否存在。所以,我们需要手动在 ACTION_DRAG_STARTED 中将 View 隐藏,在 ACTION_DRAG_ENDED 中显示。

ViewDragHelper

再来看第一种 ViewDragHelper,完整代码:

kotlin 复制代码
class DragHelperView(context: Context, attrs: AttributeSet?) : ViewGroup(context, attrs) {

    private var viewDragHelper = ViewDragHelper.create(this, DragHelperCallback())

    inner class DragHelperCallback : ViewDragHelper.Callback() {
        override fun tryCaptureView(
            child: View,
            pointerId: Int,
        ): Boolean {
            return true
        }

        override fun onViewDragStateChanged(state: Int) {
            if (state == ViewDragHelper.STATE_IDLE) {
                val capturedView = viewDragHelper.capturedView
                capturedView?.elevation--
            }
        }

        override fun clampViewPositionHorizontal(
            child: View,
            left: Int,
            dx: Int,
        ): Int {
            return left
        }

        override fun clampViewPositionVertical(
            child: View,
            top: Int,
            dy: Int,
        ): Int {
            return top
        }

        var capturedLeft = 0f
        var capturedTop = 0f
        override fun onViewCaptured(capturedChild: View, activePointerId: Int) {
            capturedChild.elevation = elevation + 1
            capturedLeft = capturedChild.left.toFloat()
            capturedTop = capturedChild.top.toFloat()
        }

        override fun onViewPositionChanged(
            changedView: View,
            left: Int,
            top: Int,
            dx: Int,
            dy: Int,
        ) {

        }

        override fun onViewReleased(
            releasedChild: View,
            xvel: Float,
            yvel: Float,
        ) {
            viewDragHelper.settleCapturedViewAt(capturedLeft.toInt(), capturedTop.toInt())
            postInvalidateOnAnimation()
        }
    }

    override fun computeScroll() {
        if (viewDragHelper.continueSettling(true)) {
            ViewCompat.postInvalidateOnAnimation(this)
        }
    }


    // 行列数
    private var columns: Int = 0
    private var rows: Int = 0

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        columns = 2
        rows = childCount / columns + 1

        val widthSpec = MeasureSpec.getSize(widthMeasureSpec)
        val heightSpec = MeasureSpec.getSize(heightMeasureSpec)
        val childWidthMeasureSpec =
            MeasureSpec.makeMeasureSpec(widthSpec / columns, MeasureSpec.EXACTLY)
        val childHeightMeasureSpec =
            MeasureSpec.makeMeasureSpec(heightSpec / rows, MeasureSpec.EXACTLY)
        measureChildren(childWidthMeasureSpec, childHeightMeasureSpec)
        setMeasuredDimension(widthSpec, heightSpec)
    }

    override fun onLayout(
        changed: Boolean,
        l: Int,
        t: Int,
        r: Int,
        b: Int,
    ) {
        for (i in 0 until childCount) {
            val child = getChildAt(i)
            child.layout(
                i % columns * child.measuredWidth,
                i / columns * child.measuredHeight,
                i % columns * child.measuredWidth + child.measuredWidth,
                i / columns * child.measuredHeight + child.measuredHeight
            )
        }
    }
    
    override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
        return viewDragHelper.shouldInterceptTouchEvent(ev)
    }

    override fun onTouchEvent(event: MotionEvent): Boolean {
        viewDragHelper.processTouchEvent(event)
        return true
    }
    
}

运行效果:

会发现在手指触碰的瞬间,就开始了拖拽。并且拖拽开始后,子 View 不是半透明的,还会被状态栏、导航栏挡住(图中没展示出来)。

和刚才的不一样,为什么呢?

因为 ViewDragHelper 不是系统级的拖拽功能,只是一个辅助工具(Helper)。

我们用它来拖动 View 本身,它在实时地修改 View 的 lefttop 坐标,所以这个 View 还在它原来的父布局里中,自然会被父布局裁剪、被状态栏挡住,也不是半透明的。

讲解一下实现方式。

kotlin 复制代码
private var viewDragHelper = ViewDragHelper.create(this, DragHelperCallback())

override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
    return viewDragHelper.shouldInterceptTouchEvent(ev)
}

override fun onTouchEvent(event: MotionEvent): Boolean {
    viewDragHelper.processTouchEvent(event)
    return true
}

首先,我们创建了 ViewDragHelper 对象,然后让它去判断是否拦截和处理事件。必须要在这两个方法中将事件交给它,这样才能正常工作。

我们再来看各个回调方法,首先是 tryCaptureView

它会在手指触摸到子 View 的一瞬间被调用,询问是否抓住这个 View。如果返回 true,子 View 就会跟着手指移动,否则,不移动。

kotlin 复制代码
override fun clampViewPositionHorizontal(
    child: View,
    left: Int,
    dx: Int,
): Int {
    return left
}

override fun clampViewPositionVertical(
    child: View,
    top: Int,
    dy: Int,
): Int {
    return top
}

这两个回调用于限制移动的范围,这里并没有限制,因为总是返回内部计算出的建议位置。如果返回 0,就无法拖动子 View。

kotlin 复制代码
var capturedLeft = 0f
var capturedTop = 0f
override fun onViewCaptured(capturedChild: View, activePointerId: Int) {
    capturedChild.elevation = elevation + 1
    capturedLeft = capturedChild.left.toFloat()
    capturedTop = capturedChild.top.toFloat()
}

当子 View 被拖拽时,会被调用,我们将该 View 的层级加一,让它可以显示在最前方。并保存了初始位置,以便后续能够吸附回原来的位置。

kotlin 复制代码
override fun onViewPositionChanged(
    changedView: View,
    left: Int,
    top: Int,
    dx: Int,
    dy: Int,
) {
    
}

被拖拽的子 View 位置改变时会调用此方法,你可以在这实现拖拽重排的功能。

kotlin 复制代码
override fun onViewReleased(
    releasedChild: View,
    xvel: Float,
    yvel: Float,
) {
    viewDragHelper.settleCapturedViewAt(capturedLeft.toInt(), capturedTop.toInt())
    postInvalidateOnAnimation()
}

这个是松手的回调,我们调用 settleCapturedViewAt 方法让被拖拽的 View 回到之前的位置,然后调用了 postInvalidateOnAnimation() 进行重绘。

kotlin 复制代码
override fun computeScroll() {
    if (viewDragHelper.continueSettling(true)) {
        ViewCompat.postInvalidateOnAnimation(this)
    }
}

重绘时会调用 computeScroll 方法,我们调用 continueSettling() 方法检查 View 是否在移动过程中,如果在,就继续触发重绘,直到 View 回到原本位置。

最后,在回到空闲状态时,需要降低层级。

kotlin 复制代码
override fun onViewDragStateChanged(state: Int) {
    if (state == ViewDragHelper.STATE_IDLE) {
        val capturedView = viewDragHelper.capturedView
        capturedView?.elevation--
    }
}

这些回调方法就讲解完毕了。

最后,为了防止同时拖拽多个子 View,可以添加一个标记。

kotlin 复制代码
var isDragging = false

override fun tryCaptureView(
    child: View,
    pointerId: Int,
): Boolean {
    return !isDragging
}

override fun onViewDragStateChanged(state: Int) {
    if (state == ViewDragHelper.STATE_IDLE) {
        isDragging = false
        // ...
    }
}

override fun onViewCaptured(capturedChild: View, activePointerId: Int) {
    isDragging = true
    // ...
}

实际应该怎么选?

OnDragListener 中,我们说到开始拖拽时传递数据的方式有 datamyLocalState 参数,它们可用的时机不同。

为什么要这么设计?

其实 data 数据 (类型为 ClipData,剪贴板数据),它是可以跨进程的。我们可以拖动图片或是文字到另一个应用中。

所以 OnDragListener 主要关注的是拖起、放下动作以及数据的传递,并不关心 View 的移动,只关心数据从 A 移动到了 B。它拖动的半透明影子是全局的,所以可以跨应用,盖住所有东西。

ViewDragHelper 关注的是界面中的子 View 的拖动,它会修改子 View 的坐标。比如侧滑菜单(DrawerLayout)、底部弹窗(BottomSheet),都是通过它来实现的。

总结:

  1. 要做跨应用、传数据的拖拽,就使用 OnDragListener

  2. 要在自定义 ViewGroup 中,实现子 View 拖动或滑动控制,就使用 ViewDragHelper

相关推荐
元亓亓亓2 小时前
JavaWeb--day3--Ajax&Element&路由&打包部署
android·ajax·okhttp
居然是阿宋2 小时前
Android XML属性与Jetpack Compose的对应关系(控件基础属性篇)
android
GoatJun2 小时前
Android ScrollView嵌套RecyclerView 导致RecyclerView数据展示不全问题
android
潜龙95273 小时前
第6.2节 Android Agent开发<二>
android·python·覆盖率数据
网安Ruler4 小时前
代码审计-PHP专题&原生开发&SQL注入&1day分析构造&正则搜索&语句执行监控&功能定位
android
paid槮5 小时前
MySql基础:数据类型
android·mysql·adb
用户2018792831677 小时前
AMS和app通信的小秘密
android
用户2018792831677 小时前
ThreadPoolExecutor之市场雇工的故事
android
诺诺Okami7 小时前
Android Framework-Launcher-InvariantDeviceProfile
android