上周产品经理跑过来,指着我们的任务管理模块说:“用户提意见了,清单里的任务不能拖拽排序,每次都要手动改优先级数字,太反人类了。这周把长按拖拽排序安排上。”

我寻思着 ArkUI 的原生拖拽能力应该挺完善的,直接加个 draggable 属性不就行了?结果真机一跑,测试同学直接发飙:“我长按想拖拽,结果它直接触发了侧滑菜单!好不容易拖起来了,原来位置的卡片直接塌陷,整个列表疯狂闪动。”为了填平这个交互的深水区,我花了整整两天时间重构了列表的手势与拖拽逻辑。

被系统原生长按拦截的滑动事件

我们先来看最基础的拖拽。ArkUI 里让一个组件可拖拽,最直接的方式是设置 draggable(true),并绑定 onDragStart。

typescript

ListItem() {
  TaskCard({ task: item })
}
.draggable(true) // 开启拖拽
.onDragStart((event: DragEvent) => {
  if (event) {
    // 返回拖拽时携带的数据
    return { extraParams: JSON.stringify(item) };
  }
  return null;
})

写完一跑,灾难出现了:为了做侧滑删除,我原本在这个 ListItem 上套了一个 SwipeAction 或者自定义了 LongPressGesture。长按事件和拖拽事件在底层的手势识别器里打架了。系统根本分不清你是想长按弹菜单,还是想长按拖拽,导致画面疯狂闪烁,甚至应用直接卡死。

警示:在长列表里做拖拽,千万不要在业务层自己写 LongPressGesture 去触发状态。只要设置了 draggable(true),系统底层会自带一个长按识别的时序。如果你外层有冲突的手势,必须用 .gesture() 配合优先级去拦截,或者干脆把侧滑菜单的优先级调低。

填补拖拽过程中的布局空洞

解决了能拖起来的问题,下一个痛点就是视觉体验。当卡片被你拎起来的时候,它原本的坑位直接被后面的元素顶上去了,整个列表瞬间发生重排。

想要那种“原位置留个透明框,其他元素平滑避让”的高级感,必须在拖拽开始时,给原组件做一个透明化处理,并利用 customPreview 自定义拖拽时跟随手指的浮层。

typescript

@Builder
function DragPreviewBuilder(item: TaskItem) {
  TaskCard({ task: item })
    .width('90%')
    .shadow({ radius: 15, color: '#33000000' }) // 加上阴影,提升悬浮感
    .scale({ x: 1.05, y: 1.05 }) // 稍微放大
}

// 在 ListItem 里应用
ListItem() {
  TaskCard({ task: item })
}
.draggable(true)
.onDragStart((event: DragEvent) => {
  // 核心动作:返回预览图和数据
  return { 
    builder: DragPreviewBuilder.bind(this, item),
    extraParams: JSON.stringify(item) 
  } as DragItemInfo;
})
// 关键:拖拽开始时,把原本的组件隐藏,保留占位
.opacity(this.draggingItemId === item.id ? 0 : 1)

配合 onDragStart 里把被拖拽的组件 .opacity(0),这样底层框架就会保留它原本的布局空间,其他卡片就不会瞎跳了。

处理落位释放与数据重排的平滑动画

当用户手指松开,组件掉落在目标位置时。我们需要在父容器的 onDrop 里接管逻辑。

这里有个极其隐蔽的深坑:onDrop 回调里拿到的数据是字符串。你必须在用 JSON.parse 之前做好 try-catch,因为有时候系统传过来的可能是空字符串,或者是别的格式,直接解析会让应用闪退。

typescript

List() {
  // ForEach 渲染列表
}
.onDrop((event: DragEvent) => {
  if (event && event.getData()) {
    try {
      const droppedData = JSON.parse(event.getData().extraParams);
      const targetIndex = this.getIndexByPosition(event.getData().touchPoint); // 计算落点索引
      
      // 核心动作:使用 animateTo 包裹数据操作,实现平滑重排
      animateTo({ duration: 300, curve: Curve.Friction }, () => {
        // 从原数组删除,插入到新位置
        const sourceIndex = this.tasks.findIndex(t => t.id === droppedData.id);
        this.tasks.splice(sourceIndex, 1);
        this.tasks.splice(targetIndex, 0, droppedData);
      });
    } catch (err) {
      console.error('拖拽数据解析失败', err);
    }
  }
})

计算落点索引也是个技术活。不能简单地用 Y 坐标去除以组件高度,因为列表可能有滚动偏移。必须结合 List 的 currentOffset 或者用 onItemDragMove 自带的 insertIndex 属性来动态计算。

多端适配下的长按冲突防御

拖拽功能在折叠屏或者平板上,如果同时连着鼠标,还会引发鼠标点击事件和拖拽事件的叠加。用户用鼠标长按拖动时,可能会触发两次落位动作。

必须在应用层加一把状态锁,或者判断当前设备的输入源。综合来看,拖拽开发看着只是几个 API 的调用,底层却涉及到了手势派发、布局重绘和状态管理的多重博弈。把占位填补好,把动画跟手度调稳,你的应用交互才算真正摸到了高级的边。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐