4. 拖拽框架(DragLayer):层级与事件分发、接口设计、动画反馈

拖拽,是桌面系统里最核心的交互之一。你想想看,用户长按一个图标,把它拖到另一个位置,或者拖进文件夹,甚至拖到屏幕边缘触发某个功能——这一连串动作背后,需要一个强大的框架来支撑。

这个框架,就是 DragLayer。我把它比作桌面系统的“物流中枢”。今天我们就来拆解它。

4.1 DragLayer 的层级地位

先搞清楚一件事:DragLayer 在窗口体系里处于什么位置?

在 Android 的 WindowManager 里,每个窗口都有个层级(Layer)。普通应用窗口在某个层级,状态栏在更高层,而 DragLayer——它必须是最顶层。

为什么?因为拖拽过程中,那个被拖起来的图标要浮在所有界面之上。如果它被别的窗口盖住,用户就看不见了,交互就断了。

核心原则:DragLayer 的 WindowManager.LayoutParams.type 通常设置为 TYPE_APPLICATION_OVERLAY,并且它的 z-order 要高于所有普通窗口。

我早期做的一个项目里,就因为 DragLayer 层级设低了,结果拖拽时图标被通知栏遮住了一半。用户反馈说“图标消失了”,排查了半天才发现是层级问题。从那以后,我每次都会在代码里显式检查 DragLayer 的层级。

4.2 事件分发:谁该响应触摸?

拖拽的本质,是触摸事件的拦截与重定向。DragLayer 需要做两件事:

  1. 拦截长按事件——当用户在桌面上长按一个图标时,触发拖拽。
  2. 接管后续触摸——拖拽开始后,所有触摸事件都交给 DragLayer 处理,不再分发给下面的桌面。

具体实现上,我习惯在 DragLayer 的 onInterceptTouchEvent() 里做判断:

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    switch (ev.getAction()) {
        case MotionEvent.ACTION_DOWN:
            // 记录触摸点,判断是否在可拖拽区域
            mDownX = ev.getX();
            mDownY = ev.getY();
            break;
        case MotionEvent.ACTION_MOVE:
            // 如果移动距离超过阈值,且当前处于拖拽状态,则拦截
            if (isDragging && isDragDistanceExceeded(ev)) {
                return true;
            }
            break;
        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_CANCEL:
            // 结束拖拽
            break;
    }
    return super.onInterceptTouchEvent(ev);
}

这里有个细节:onInterceptTouchEvent 返回 true 后,后续事件会直接交给 DragLayer 的 onTouchEvent()。所以你要在 onTouchEvent() 里处理拖拽的移动逻辑。

小技巧:我建议在 DragLayer 里维护一个 mDragState 枚举(IDLE, DRAGGING, ANIMATING),这样事件分发的逻辑会清晰很多。

4.3 DragSource 与 DropTarget 接口设计

拖拽不是 DragLayer 一个人的事。它需要知道:

  • 谁发起了拖拽?(DragSource)
  • 拖到哪里去?(DropTarget)

这两个接口,是拖拽框架的“协议”。

DragSource 接口

DragSource 代表拖拽的发起者。比如桌面上的一个图标、文件夹里的一个应用、或者 Dock 栏上的快捷方式。

public interface DragSource {
    /**
     * 开始拖拽时调用
     * @param dragInfo 拖拽数据
     * @return 拖拽的视图(通常是一个克隆的图标)
     */
    View onDragStart(DragInfo dragInfo);
    
    /**
     * 拖拽结束时调用
     * @param success 是否成功放置
     */
    void onDragEnd(boolean success);
}

我遇到过一个问题:onDragStart() 返回的 View 如果直接使用原图标,会导致原位置图标消失。所以一定要克隆一个 View,或者创建一个 Bitmap 来显示。

DropTarget 接口

DropTarget 代表可以接收拖拽的目标。比如桌面网格、文件夹、或者屏幕边缘的“删除”区域。

public interface DropTarget {
    /**
     * 判断是否接受这个拖拽
     */
    boolean acceptDrop(DragInfo dragInfo);
    
    /**
     * 拖拽进入目标区域
     */
    void onDragEnter(DragInfo dragInfo);
    
    /**
     * 拖拽离开目标区域
     */
    void onDragExit(DragInfo dragInfo);
    
    /**
     * 拖拽在目标区域内移动
     */
    void onDragOver(DragInfo dragInfo);
    
    /**
     * 放置完成
     */
    void onDrop(DragInfo dragInfo);
}

你看,这个接口设计得很像“鼠标事件”——进入、离开、移动、放下。每个 DropTarget 都可以根据这些回调来更新自己的 UI。

设计要点:DragInfo 是一个数据类,包含被拖拽对象的类型、位置、附加数据等。我建议把它设计成可序列化的,方便跨进程传递。

4.4 拖拽动画与视觉反馈

没有动画的拖拽,就像没有声音的电影——能用,但体验很差。

拖拽动画主要分三块:

1. 拖拽过程中的跟随动画

手指移动时,被拖拽的图标要平滑跟随。这个很简单,在 onTouchEvent() 里更新 View 的 translationX/Y 就行。

// 在 onTouchEvent 的 ACTION_MOVE 中
mDragView.setTranslationX(ev.getRawX() - mDragOffsetX);
mDragView.setTranslationY(ev.getRawY() - mDragOffsetY);

但直接设置位置会显得很生硬。我习惯加一个 spring 效果:

// 使用 SpringAnimation 实现弹性跟随
SpringAnimation animX = new SpringAnimation(mDragView, SpringAnimation.TRANSLATION_X, targetX);
animX.getSpring().setStiffness(500f);
animX.getSpring().setDampingRatio(0.8f);
animX.start();

这样拖拽时图标会有一点点“滞后感”,反而更自然。

2. 目标区域的反馈动画

当拖拽进入某个 DropTarget 时,目标区域应该给出视觉反馈。比如:

  • 桌面网格高亮显示插入位置
  • 文件夹图标放大并打开
  • 删除区域变红

这些反馈通常在 onDragEnter()onDragExit() 里触发。

注意:反馈动画不要做得太夸张。我曾经见过一个项目,拖拽进文件夹时文件夹弹跳了3次,用户反馈说“太晕了”。适度的缩放(1.0 -> 1.1)就够了。

3. 放置成功/失败的动画

放置成功时,图标应该“归位”到目标位置,并有一个轻微的弹入效果。放置失败时(比如拖到了无效区域),图标应该“飞回”原位置。

飞回动画我通常用 ValueAnimator 配合 PathInterpolator 实现:

// 飞回动画
ValueAnimator anim = ValueAnimator.ofFloat(0f, 1f);
anim.addUpdateListener(animation -> {
    float fraction = animation.getAnimatedFraction();
    float x = startX + (endX - startX) * fraction;
    float y = startY + (endY - startY) * fraction;
    mDragView.setTranslationX(x);
    mDragView.setTranslationY(y);
});
anim.setInterpolator(new PathInterpolator(0.4f, 0f, 0.2f, 1f));
anim.setDuration(300);
anim.start();

这个 PathInterpolator 会让动画先快后慢,看起来更自然。

4.5 整体架构图

下面这张图展示了 DragLayer 框架的核心组件和交互流程:

DragLayer 拖拽框架架构图 DragLayer 事件分发 onInterceptTouchEvent 拖拽状态管理 IDLE / DRAGGING / ANIMATING 动画引擎 SpringAnimation / ValueAnimator DragSource onDragStart() → 返回拖拽视图 onDragEnd() → 处理结束逻辑 DropTarget acceptDrop() / onDragEnter() onDragOver() / onDrop() 视觉反馈 目标区域高亮 / 缩放 飞回动画 / 弹性跟随 交互流程:长按触发 → DragSource.onDragStart() → 事件分发 → 拖拽移动 → DropTarget 回调 → 放置/飞回 整个流程由 DragLayer 统一调度,确保触摸事件和动画同步

4.6 避坑指南

最后,分享几个我踩过的坑:

  • 触摸事件被消费了——如果 DragLayer 的子 View 也处理了触摸事件,记得在 onInterceptTouchEvent 里返回 true,否则拖拽会失效。
  • 动画卡顿——拖拽过程中如果频繁触发布局,会导致掉帧。我建议用 setLayerType(View.LAYER_TYPE_HARDWARE, null) 开启硬件加速。
  • 多指触控——用户可能用两根手指操作。记得处理 ACTION_POINTER_DOWNACTION_POINTER_UP,避免拖拽被意外中断。

嗯,拖拽框架的内容差不多就这些了。核心就是:层级要够高、事件要拦截、接口要清晰、动画要流畅。把这四点做好,你的桌面系统拖拽体验就不会差。


公众号:蓝海资料掘金营,微信deep3321