文章

Android 拖拽 API 的选择与使用

Android 官方提供了两套拖拽 API,部分情况下它们都能实现需求。今天不仅要讨论各自的基本用法,更重要的是搞清楚它们的区别与使用场景。

OnDragListener

OnDragListener 是 Android framework 提供的 API,不需要引用额外的库。它有几个默认的特性:

  • 拖动时原始 View 照常在原位置显示。
  • 拖动时会有一个半透明的浮层,全局置顶,甚至可以覆盖系统 UI(比如导航栏)。

OnDragListener 配套的是 View.startDrag() 方法或 ViewCompat.startDragAndDrop() 兼容版本的方法,调用它就能启动拖拽,系统会自动显示一个浮层跟随手指,非常省心。

通过 View.setOnDragListener() 注册监听就可以得到拖拽的回调。与点击监听不同,任意 View 被拖拽都会触发所有 View 的回调。拖拽回调的 View 参数并不是被拖拽的 View,而是注册了这个监听的 View。为什么这么设计?因为拖拽回调有 ACTION_DRAG_ENTERED 之类的事件,它在拖拽进入其他 View 的区域时触发。一个 View 很可能希望别的东西拖拽到自己身上时进行一些响应,因此拖拽回调不仅仅针对被拖拽的 View,而是针对所有注册了的 View。

那么问题来了,怎能知道哪个 View 被拖动了?看一下启动拖拽的方法签名:

public static boolean startDragAndDrop(@NonNull View v, ClipData data,
        View.DragShadowBuilder shadowBuilder, Object localState, int flags)

其中有三个参数:

  • localState: Object: 拖拽回调中 DragEvent.localState 的值就是这个参数的值。利用这一点,如果把 View 自身作为参数,自然在回调中就能判断是哪一个 View 被拖动了。
  • data: ClipData: 只能在 DragEvent.ACTION_DROP 事件中才能通过 DrarEvent.clipData 取到。看起来和 localState 类似,但它有个特点就是可以跨进程。也因此比较重,所以只能在特定的时候获取。
  • shadowBuilder: View.DragShadowBuilder: 决定了拖拽浮层的样式,默认是和原始 View 一样。

刚才提到这套 API 有个参数可以跨进程,这有什么用?类似于拖拽图片到聊天窗口直接发送的功能就是这么实现的!当然,这需要相册和聊天软件同时支持才可以。

ViewDragHelper

ViewDragHelper 是 AndroidX 内的一个类,用于实现 View 拖拽。它的特点如下:

  • 不改变 View 的 z 轴顺序。
  • 直接改变 View 的位置,没有额外的浮层。

ViewDragHelperGestureDetectorCompat 类似,都是一个外挂的手势检测器,只不过除了检测手势之外,它内部直接就完成了 View 的移动操作。既然是手势检测器,那需要自定义 View 后才能使用,模板代码如下:

class DragHelperGridView(context: Context, attrs: AttributeSet?) : ViewGroup(context, attrs) {
  private var dragHelper = ViewDragHelper.create(this, DragCallback())

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

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

ViewDragHelper.Callback() 有很多回调:

  • tryCaptureView(): 返回 true 表示启动拖动,否则不拖动这个 View。

  • clampViewPositionXxxx(): 用于限制拖动的范围,默认实现是不能拖动。参数会传入要拖动到的位置和本次手指的位移,返回允许拖动到的位置。若直接返回要拖动到的位置则相当于此方向上无限制。例如下面的实现:

    override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int {
      return left
    }
    
  • onViewCaptured(): 开始拖动的回调,可做一些初始化工作。

  • onViewReleased(): 释放的回调,可做一些收尾工作。

ViewDragHelper 也内置了「归位」的逻辑,通常在 onViewReleased() 回调中调用 ViewDragHelper.settleCapturedViewAt() 以动画的形式让当前被拖拽的 View 移动到指定位置。与 OverScroller 类似,我们得在每一帧手动计算一下实时位置,模板代码如下:

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

对比

OnDragListener ViewDragHelper
使用 直接使用 需要自定义 ViewGroup
效果 显示浮层,保留原 View 直接移动原 View
拖拽显示范围 全局 ViewGroup 内部

可能有小伙伴对 OnDragListener 的设计很不满意,比如「为什么拖走了原始 View 还显示?」 因为 OnDragListener API 关注的是数据,UI 只是一种触摸反馈。例如把图片从相册拖到聊天窗口,当然应该在相册中继续正常显示。它的语义是「把这个数据传递给某个 App」,而不是「把这个 View 移动过去」。使用 View.startDrag() API 通常伴随着数据的传递,甚至是跨应用的传递,否则在一定程度上就不是一个好的选择。

ViewDragHelper 则相反,它关注的就是 UI,操作的也是 View,适用于单纯对界面组件的拖动场景。它的语义很纯粹:移动这个 View。