HarmonyOS 6.0 长按菜单与拖拽预览
长按弹出菜单、拖拽生成预览图——这两个交互在iOS上很常见,Android上也有类似实现。HarmonyOS提供了bindContextMenu和onDragStart/onDrop两套API,但组合使用时坑不少。拖拽预览图怎么生成?长按菜单跟拖拽手势怎么不冲突?拖拽到目标区域怎么高亮?这篇把长按+拖拽的完整交互链路走通。
长按菜单:bindContextMenu
最简单的长按菜单:
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控制显示:
@StatecontextMenuVisible:boolean=false@StatemenuTargetItem:DataItem|null=null@StatemenuPositionX:number=0@StatemenuPositionY:number=0Image(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+透明遮罩实现:
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自定义预览:
Image(item.coverUrl).width(120).height(80).onDragStart((event:DragEvent)=>{this.dragItem=item;returnthis.dragPreviewBuilder(item);})@BuilderdragPreviewBuilder(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接收拖拽放置:
// 拖拽源Image(item.coverUrl).onDragStart((event:DragEvent)=>{this.dragItem=item;returnthis.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控制目标区域的高亮——虚线边框变蓝、背景变浅蓝。
长按与拖拽的冲突
长按触发菜单,拖拽也需要长按启动——两个手势会打架。解决方式是让长按时间更短时触发菜单,更长时启动拖拽:
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手动实现拖拽,这样长按和拖拽的区分完全可控:
@StateisDragging:boolean=false@StatedragOffsetX:number=0@StatedragOffsetY:number=0privatelongPressTimer:number=-1Image(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)和目标检测。
拖拽目标检测
手动拖拽时需要自己判断手指是否在目标区域上方:
privatetargetArea:{x:number;y:number;width:number;height:number}={x:0,y:600,width:360,height:80};privatecheckDropTarget(offsetX:number,offsetY:number):void{letfingerX=this.dragStartX+offsetX;letfingerY=this.dragStartY+offsetY;lettarget=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:
Column(){Text('拖到这里收藏')}.onAreaChange((oldArea:Area,newArea:Area)=>{letwidth=Number(newArea.width);letheight=Number(newArea.height);letglobalOffset=newArea.globalPosition;if(globalOffset!==undefined){this.targetArea={x:Number(globalOffset.x),y:Number(globalOffset.y),width:width,height:height};}})globalPosition是组件在屏幕上的绝对位置,用于坐标判断。
列表项拖拽排序
列表项长按拖拽排序:
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;returnthis.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互斥。自定义预览让拖拽看起来更精致,目标区域高亮让用户知道"放这里"。