HarmonyOS 6.0 长按菜单与拖拽预览

长按弹出菜单、拖拽生成预览图------这两个交互在iOS上很常见,Android上也有类似实现。HarmonyOS提供了bindContextMenu和onDragStart/onDrop两套API,但组合使用时坑不少。拖拽预览图怎么生成?长按菜单跟拖拽手势怎么不冲突?拖拽到目标区域怎么高亮?这篇把长按+拖拽的完整交互链路走通。

长按菜单:bindContextMenu

最简单的长按菜单:

typescript 复制代码
Image(item.coverUrl)
  .width(120)
  .height(80)
  .bindContextMenu([
    { value: '保存图片', action: () => { this.saveImage(item); } },
    { value: '分享', action: () => { this.shareImage(item); } },
    { value: '删除', action: () => { this.deleteImage(item); } }
  ], ResponseType.LongPress)

ResponseType.LongPress表示长按触发。还有ResponseType.RightClick(右键/鼠标)和ResponseType.Touch(触摸)。

菜单项的value是显示文本,action是点击回调。

问题:bindContextMenu弹出的是系统默认样式,不能自定义。 要自定义菜单样式,用自定义弹窗。

自定义长按菜单

用@Builder构建自定义菜单,配合isShow控制显示:

typescript 复制代码
@State contextMenuVisible: boolean = false
@State menuTargetItem: DataItem | null = null
@State menuPositionX: number = 0
@State menuPositionY: number = 0

Image(item.coverUrl)
  .width(120)
  .height(80)
  .onLongPress((event: GestureEvent) => {
    this.menuTargetItem = item;
    this.menuPositionX = event.target.x;
    this.menuPositionY = event.target.y;
    this.contextMenuVisible = true;
  })

if (this.contextMenuVisible && this.menuTargetItem !== null) {
  Column() {
    Text('保存图片')
      .width('100%')
      .padding(12)
      .fontSize(14)
      .fontColor('#333333')
      .onClick(() => {
        this.saveImage(this.menuTargetItem!);
        this.contextMenuVisible = false;
      })

    Divider().color('#f0f0f0')

    Text('分享')
      .width('100%')
      .padding(12)
      .fontSize(14)
      .fontColor('#333333')
      .onClick(() => {
        this.shareImage(this.menuTargetItem!);
        this.contextMenuVisible = false;
      })

    Divider().color('#f0f0f0')

    Text('删除')
      .width('100%')
      .padding(12)
      .fontSize(14)
      .fontColor('#e53935')
      .onClick(() => {
        this.deleteImage(this.menuTargetItem!);
        this.contextMenuVisible = false;
      })
  }
  .width(140)
  .backgroundColor(Color.White)
  .borderRadius(8)
  .shadow({ radius: 10, color: '#33000000', offsetX: 0, offsetY: 4 })
  .position({ x: this.menuPositionX, y: this.menuPositionY + 80 })
  .zIndex(999)
}

position定位菜单在长按位置附近。zIndex确保菜单在最上层。

问题:菜单显示时点击其他区域应该关闭菜单。 用Stack+透明遮罩实现:

typescript 复制代码
Stack() {
  // 原始内容
  Column() {
    // 列表
  }

  // 菜单遮罩
  if (this.contextMenuVisible) {
    Column()
      .width('100%')
      .height('100%')
      .backgroundColor('#0d000000')
      .onClick(() => {
        this.contextMenuVisible = false;
      })

    // 菜单内容
    Column() {
      // 菜单项
    }
    .position({ x: this.menuPositionX, y: this.menuPositionY + 80 })
    .zIndex(1000)
  }
}

遮罩在菜单下面(zIndex 999),菜单在上面(zIndex 1000)。点击遮罩关闭菜单,点击菜单项执行操作。

拖拽预览图

拖拽时系统会生成组件的快照作为预览图。但如果组件很大或者有透明区域,默认预览不好看。用onDragStart自定义预览:

typescript 复制代码
Image(item.coverUrl)
  .width(120)
  .height(80)
  .onDragStart((event: DragEvent) => {
    this.dragItem = item;
    return this.dragPreviewBuilder(item);
  })

@Builder
dragPreviewBuilder(item: DataItem) {
  Column() {
    Image(item.coverUrl)
      .width(80)
      .height(60)
      .borderRadius(8)
    Text(item.title)
      .fontSize(12)
      .fontColor('#333333')
      .maxLines(1)
      .margin({ top: 4 })
  }
  .width(100)
  .padding(8)
  .backgroundColor(Color.White)
  .borderRadius(8)
  .shadow({ radius: 8, color: '#33000000', offsetX: 0, offsetY: 2 })
}

onDragStart返回的@Builder作为拖拽预览。预览图比原始组件小(80x60 vs 120x80),加了白色背景和阴影,看起来像一张卡片被"拿起来"了。

拖拽目标区域

onDrop接收拖拽放置:

typescript 复制代码
// 拖拽源
Image(item.coverUrl)
  .onDragStart((event: DragEvent) => {
    this.dragItem = item;
    return this.dragPreviewBuilder(item);
  })

// 拖拽目标
Column() {
  Text('拖到这里收藏')
    .fontSize(14)
    .fontColor('#999999')
}
.width('100%')
.height(80)
.backgroundColor(this.isDragOver ? '#e3f2fd' : '#f5f5f5')
.borderRadius(8)
.border({
  width: 2,
  color: this.isDragOver ? '#1a73e8' : '#e0e0e0',
  style: BorderStyle.Dashed
})
.onDragEnter((event: DragEvent) => {
  this.isDragOver = true;
})
.onDragLeave((event: DragEvent) => {
  this.isDragOver = false;
})
.onDrop((event: DragEvent) => {
  this.isDragOver = false;
  if (this.dragItem !== null) {
    this.addToFavorites(this.dragItem);
  }
})

三个回调:onDragEnter进入目标区域、onDragLeave离开、onDrop放置。isDragOver控制目标区域的高亮------虚线边框变蓝、背景变浅蓝。

长按与拖拽的冲突

长按触发菜单,拖拽也需要长按启动------两个手势会打架。解决方式是让长按时间更短时触发菜单,更长时启动拖拽:

typescript 复制代码
Image(item.coverUrl)
  .gesture(
    GestureGroup(GestureMode.Exclusive,
      LongPressGesture({ repeat: false, duration: 500 })
        .onAction(() => {
          this.showContextMenu(item);
        }),
      PanGesture()
        .onActionStart(() => {
          this.hideContextMenu();
        })
    )
  )

GestureMode.Exclusive让两个手势互斥------先触发的赢。LongPressGesture的duration=500表示500ms长按触发菜单。PanGesture在手指移动时触发,不需要等长按完成。

更好的方式:不用onDragStart,用PanGesture手动实现拖拽,这样长按和拖拽的区分完全可控:

typescript 复制代码
@State isDragging: boolean = false
@State dragOffsetX: number = 0
@State dragOffsetY: number = 0
private longPressTimer: number = -1

Image(item.coverUrl)
  .gesture(
    GestureGroup(GestureMode.Exclusive,
      LongPressGesture({ repeat: false, duration: 400 })
        .onAction(() => {
          this.showContextMenu(item);
        }),
      PanGesture({ distance: 10 })
        .onActionStart((event: GestureEvent) => {
          this.isDragging = true;
          this.dragOffsetX = 0;
          this.dragOffsetY = 0;
        })
        .onActionUpdate((event: GestureEvent) => {
          this.dragOffsetX = event.offsetX;
          this.dragOffsetY = event.offsetY;
          this.checkDropTarget(event.offsetX, event.offsetY);
        })
        .onActionEnd(() => {
          if (this.isDragOverTarget) {
            this.handleDrop();
          }
          this.isDragging = false;
          this.dragOffsetX = 0;
          this.dragOffsetY = 0;
        })
    )
  )

// 拖拽时显示预览
if (this.isDragging) {
  Column() {
    Image(this.dragItem!.coverUrl)
      .width(80)
      .height(60)
  }
  .position({
    x: this.dragStartPositionX + this.dragOffsetX,
    y: this.dragStartPositionY + this.dragOffsetY
  })
  .scale({ x: 0.8, y: 0.8 })
  .opacity(0.9)
  .zIndex(999)
}

PanGesture的distance=10表示手指移动10px以上才识别为拖拽,避免误触。手动管理预览的位置(offsetX/offsetY)和目标检测。

拖拽目标检测

手动拖拽时需要自己判断手指是否在目标区域上方:

typescript 复制代码
private targetArea: { x: number; y: number; width: number; height: number } = {
  x: 0, y: 600, width: 360, height: 80
};

private checkDropTarget(offsetX: number, offsetY: number): void {
  let fingerX = this.dragStartX + offsetX;
  let fingerY = this.dragStartY + offsetY;
  let target = this.targetArea;
  this.isDragOverTarget = fingerX >= target.x && fingerX <= target.x + target.width &&
                          fingerY >= target.y && fingerY <= target.y + target.height;
}

dragStartX/Y是拖拽起始时手指的位置。offsetX/Y是PanGesture提供的偏移。两者相加就是手指当前位置。判断是否在目标矩形内。

目标区域的位置需要在布局后获取,用onAreaChange:

typescript 复制代码
Column() {
  Text('拖到这里收藏')
}
.onAreaChange((oldArea: Area, newArea: Area) => {
  let width = Number(newArea.width);
  let height = Number(newArea.height);
  let globalOffset = newArea.globalPosition;
  if (globalOffset !== undefined) {
    this.targetArea = {
      x: Number(globalOffset.x),
      y: Number(globalOffset.y),
      width: width,
      height: height
    };
  }
})

globalPosition是组件在屏幕上的绝对位置,用于坐标判断。

列表项拖拽排序

列表项长按拖拽排序:

typescript 复制代码
List() {
  ForEach(this.items, (item: DataItem, index: number) => {
    ListItem() {
      Row() {
        Text('≡')
          .fontSize(20)
          .fontColor('#cccccc')
          .margin({ right: 12 })
        Text(item.title)
          .fontSize(16)
      }
      .width('100%')
      .padding(16)
      .backgroundColor(this.dragIndex === index ? '#e3f2fd' : Color.White)
    }
    .onDragStart((event: DragEvent) => {
      this.dragIndex = index;
      return this.itemDragPreview(item);
    })
    .onDrop((event: DragEvent) => {
      if (this.dragIndex >= 0 && this.dragIndex !== index) {
        this.moveItem(this.dragIndex, index);
      }
      this.dragIndex = -1;
    })
  }, (item: DataItem) => item.id)
}

拖拽源和目标都是ListItem。onDragStart记录拖拽源index,onDrop在目标位置插入。moveItem的实现在之前的文章讲过------数组元素移动后赋值新数组触发刷新。

踩坑清单

问题 原因 解决
菜单位置偏移 position坐标没考虑状态栏 用event.target.globalX/Y
菜单超出屏幕 长按位置在底部 检测位置,向上弹
长按菜单与拖拽冲突 两个手势同时绑定 GestureGroup Exclusive
拖拽预览太大 默认用组件原始尺寸 onDragStart返回小尺寸Builder
onDrop不触发 目标没注册onDrop 目标组件加onDrop
拖拽目标不高亮 没处理onDragEnter/Leave 用isDragOver状态
PanGesture跟Scroll冲突 List的滚动消费了手势 用专用拖拽手柄区域
拖拽预览位置跳动 offset计算没考虑组件位置 加上组件起始位置
目标区域坐标不准 用了组件本地坐标 用globalPosition
拖拽结束后状态残留 没重置isDragging onActionEnd中重置

长按+拖拽的核心是"手势分离"------长按500ms触发菜单,拖拽用PanGesture独立处理,两者通过GestureGroup Exclusive互斥。自定义预览让拖拽看起来更精致,目标区域高亮让用户知道"放这里"。

相关推荐
特立独行的猫a2 小时前
鸿蒙PC Node.js三方库移植的AI移植框架与社区贡献完整流程
华为·node.js·harmonyos·三方库移植·鸿蒙pc
FF2501_940228583 小时前
HarmonyOS应用《玄象》开发实战:FortuneTimelinePage 运势时间线:Timeline 自定义组件 + List 虚拟滚动
harmonyos·鸿蒙
进击的丸子3 小时前
APP人脸识别增值版Harmony Demo实操与关键代码解析
前端·程序员·harmonyos
LEO111104 小时前
HarmonyOS应用开发实战:猫猫大作战-样式抽成可复用类,一处定义、多处复用,还能针对不同状态(正常/按下/禁用)应用不同样式
harmonyos·鸿蒙
程序员黑豆7 小时前
鸿蒙应用开发:@Link 装饰器实现父子组件双向同步
前端·后端·harmonyos
2501_919749038 小时前
华为鸿蒙免费解压缩APP—小羊解压缩
华为·harmonyos
爱写代码的森8 小时前
鸿蒙三方库 | harmony-utils之EncryptUtil URL编解码详解
服务器·华为·harmonyos·鸿蒙·huawei
youtootech9 小时前
HarmonyOS 6.0 卡片式信息流与瀑布流布局
华为·harmonyos
luoyayun36111 小时前
【Qt for Harmony】 Qt 5.12.12 鸿蒙版 Windows 交叉编译
qt·harmonyos·qt for harmony