HarmonyOS6 PC端并行手势组实战——拖拽缩放旋转同时进行

之前一直觉得多个手势同时操作是系统底层的事情,应用层做不了。直到看到GestureGroup配合GestureMode.Parallel的用法,才发现HarmonyOS6已经把并行手势的能力开放给开发者了。搞定了这个功能之后,做了一个图片编辑器的demo,拖拽、缩放、旋转三个操作同时进行,丝滑得不像话。
什么是并行手势
先说下手势组GestureGroup的概念。它可以把多个手势组合在一起,然后通过不同的GestureMode来控制这些手势之间的关系。
GestureMode.Parallel就是并行模式——组内的所有手势可以同时生效。用户一根手指拖拽的同时,另一只手可以缩放或旋转,三个操作互不干扰。这在图片编辑、地图操作、画板应用里非常常见。
与之对比的是Sequence(顺序模式,手势要按顺序触发)和Exclusive(互斥模式,只有第一个识别成功的手势生效)。这两种模式后面会有专门的文章来讲,这篇先聚焦并行模式。
为什么需要并行手势

很多场景里,用户天然地期望同时做多个操作。想想你在手机上看地图的时候——一只手拖拽平移地图,另一只手双指缩放,有时候还要旋转一下看看方向。这三个操作如果只能一个一个来,体验会差到什么程度?所以并行手势不是锦上添花的功能,是很多交互场景的刚需。
在游戏场景里更是如此。比如一个RTS游戏,用户需要同时拖拽视角和控制缩放比例。或者一个图片标注工具,用户一边缩放图片一边移动标注位置。这些场景下如果没有并行手势,操作效率会低到让人崩溃。
三个手势的准备工作
并行手势组需要三个手势:PanGesture负责拖拽、PinchGesture负责缩放、RotationGesture负责旋转。每个手势各自维护自己的状态变量,然后通过transform属性把变换效果叠加到一个元素上。
这里有个关键点:拖拽用translate属性,缩放用scale属性,旋转用rotate属性。这三个属性是可以叠加的,系统会按照正确的顺序来应用变换。你不需要自己去算矩阵变换,ArkUI帮你搞定了。
完整案例代码
下面是一个支持三指并行操作的demo,中间的元素可以拖拽移动、双指缩放、双指旋转,还有一个重置按钮:
@Entry
@Component
struct ParallelGestureDemo {
@State offsetX: number = 0
@State offsetY: number = 0
@State startOffsetX: number = 0
@State startOffsetY: number = 0
@State scaleValue: number = 1
@State startScale: number = 1
@State rotateValue: number = 0
@State startRotate: number = 0
@State activeGestures: string[] = []
@State bgColor: string = '#6C5CE7'
updateGestureStatus(gesture: string, active: boolean) {
if (active) {
if (this.activeGestures.indexOf(gesture) === -1) {
this.activeGestures.push(gesture)
}
} else {
const index = this.activeGestures.indexOf(gesture)
if (index !== -1) {
this.activeGestures.splice(index, 1)
}
}
}
get statusText(): string {
if (this.activeGestures.length === 0) {
return '触摸元素开始操作'
}
return '当前: ' + this.activeGestures.join(' + ')
}
build() {
Column() {
Text('并行手势组')
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#1A1A2E')
.margin({ top: 30, bottom: 6 })
Text('拖拽 + 缩放 + 旋转同时进行')
.fontSize(14)
.fontColor('#8E8E93')
.margin({ bottom: 16 })
// 状态显示
Row() {
this.GestureTag('拖拽', this.activeGestures.indexOf('拖拽') !== -1, '#FF6B6B')
this.GestureTag('缩放', this.activeGestures.indexOf('缩放') !== -1, '#4ECDC4')
this.GestureTag('旋转', this.activeGestures.indexOf('旋转') !== -1, '#45B7D1')
}
.width('100%')
.justifyContent(FlexAlign.Center)
Text(this.statusText)
.fontSize(14)
.fontColor('#8E8E93')
.margin({ top: 12 })
// 变换参数显示
Row() {
Text(`X:${this.offsetX.toFixed(0)}`)
.fontSize(12)
.fontColor('#8E8E93')
Text(`Y:${this.offsetY.toFixed(0)}`)
.fontSize(12)
.fontColor('#8E8E93')
.margin({ left: 12 })
Text(`缩放:${this.scaleValue.toFixed(2)}x`)
.fontSize(12)
.fontColor('#8E8E93')
.margin({ left: 12 })
Text(`旋转:${this.rotateValue.toFixed(1)}°`)
.fontSize(12)
.fontColor('#8E8E93')
.margin({ left: 12 })
}
.margin({ top: 8 })
// 操作区域
Stack() {
// 操作元素
Column() {
Text('操作我')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('拖拽 / 缩放 / 旋转')
.fontSize(12)
.fontColor('#FFFFFF')
.opacity(0.7)
.margin({ top: 6 })
}
.width(160)
.height(160)
.justifyContent(FlexAlign.Center)
.backgroundColor(this.bgColor)
.borderRadius(20)
.shadow({ radius: 12, color: '#20000000', offsetX: 0, offsetY: 4 })
.translate({ x: this.offsetX, y: this.offsetY })
.scale({ x: this.scaleValue, y: this.scaleValue })
.rotate({ angle: this.rotateValue })
.gesture(
GestureGroup(GestureMode.Parallel,
PanGesture()
.onActionStart(() => {
this.updateGestureStatus('拖拽', true)
})
.onActionUpdate((e: GestureEvent) => {
this.offsetX = this.startOffsetX + e.offsetX
this.offsetY = this.startOffsetY + e.offsetY
})
.onActionEnd(() => {
this.startOffsetX = this.offsetX
this.startOffsetY = this.offsetY
this.updateGestureStatus('拖拽', false)
}),
PinchGesture({ fingers: 2 })
.onActionStart(() => {
this.updateGestureStatus('缩放', true)
})
.onActionUpdate((e: GestureEvent) => {
this.scaleValue = this.startScale * e.scale
})
.onActionEnd(() => {
this.startScale = this.scaleValue
this.updateGestureStatus('缩放', false)
}),
RotationGesture()
.onActionStart(() => {
this.updateGestureStatus('旋转', true)
})
.onActionUpdate((e: GestureEvent) => {
this.rotateValue = this.startRotate + e.angle
})
.onActionEnd(() => {
this.startRotate = this.rotateValue
this.updateGestureStatus('旋转', false)
})
)
)
}
.width('100%')
.height(350)
.backgroundColor('#F5F6FA')
.borderRadius(20)
.margin({ top: 16 })
.clip(true)
// 重置按钮
Button('重置所有变换')
.fontSize(15)
.fontColor('#FFFFFF')
.backgroundColor('#6C5CE7')
.borderRadius(12)
.width('50%')
.height(44)
.margin({ top: 20 })
.onClick(() => {
animateTo({ duration: 400, curve: Curve.EaseOut }, () => {
this.offsetX = 0
this.offsetY = 0
this.startOffsetX = 0
this.startOffsetY = 0
this.scaleValue = 1
this.startScale = 1
this.rotateValue = 0
this.startRotate = 0
})
})
}
.width('100%')
.height('100%')
.backgroundColor('#FFFFFF')
}
@Builder
GestureTag(label: string, active: boolean, color: string) {
Text(label)
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor(active ? '#FFFFFF' : color)
.backgroundColor(active ? color : '#F5F6FA')
.borderRadius(16)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.animation({ duration: 200 })
}
}
运行效果如图:

代码解析
代码看着挺多,核心逻辑其实就三块。
第一块是三个手势的定义。PanGesture监听拖拽,在onActionUpdate中把offsetX和offsetY加到当前偏移量上。PinchGesture监听缩放,用startScale乘以当前的scale系数。RotationGesture监听旋转,把旋转角度累加到startRotate上。
第二块是起始值的管理。每个手势都有start开头的变量来记录"本次手势开始时的值"。这是为了避免多次手势叠加时出现跳变。比如你拖拽到一半松手,下次再拖的时候,startOffsetX/Y就是上次结束时的位置,不会从0开始。
第三块是顶部的状态标签。通过activeGestures数组来追踪当前有哪些手势正在执行。每个手势的onActionStart把名字加入数组,onActionEnd把名字移除。上面的GestureTag组件根据数组内容切换激活态和未激活态的样式。
重置按钮用了animateTo来做一个平滑的回弹动画,比直接跳到初始值舒服多了。
并行手势的踩坑经验
说实话,并行手势有几个容易踩的坑,我挨个说。
第一个坑是PinchGesture的fingers参数。默认值是2,意味着需要两根手指同时捏合才能触发。在手机上这很合理,但在PC端用鼠标的时候,你没法模拟双指操作。所以PC端调试的时候,建议先用单指测试拖拽和旋转,缩放的部分等部署到触屏设备上再测。
第二个坑是onActionEnd中的起始值更新。很多人忘了在onActionEnd里把startScale更新为当前的scaleValue,结果下次缩放的时候就会从1开始,导致元素突然跳回原始大小。这个错误很常见,排查起来也有点烦,因为代码逻辑上没有明显的bug。
第三个坑是Stack容器的大小。操作区域用Stack包裹,如果不设置clip(true),元素被拖到Stack外面之后还是可见的,视觉效果不太好。加上clip之后超出的部分会被裁切,看起来更干净。
PC端适配要点
在HarmonyOS6 PC端,并行手势的体验和手机端有明显差异。
最核心的问题是鼠标没有多点触控。PC用户没法像手机上那样双指缩放和旋转。解决方案有两种:一是给PC端增加键盘快捷键,比如Ctrl+滚轮缩放、Alt+拖拽旋转;二是加一些UI控件,比如缩放滑块和旋转角度输入框,作为手势操作的补充。
另外,PC端窗口大小不固定,操作区域的绝对尺寸会随窗口变化。建议用百分比布局,同时给操作元素设置一个最小尺寸,防止窗口缩得太小时元素看不见。
变换叠加的顺序问题
坦白讲,translate、scale、rotate三个变换属性同时应用到一个元素上时,系统内部的计算顺序是固定的:先rotate,再scale,最后translate。这个顺序跟CSS的transform类似,不同的顺序会产生不同的视觉效果。
举个例子,如果先translate再rotate,元素会先移动到目标位置,然后在目标位置上旋转——旋转的中心点还是元素自身的中心。但如果先rotate再translate,元素先在原位旋转,然后沿着坐标轴移动——这时候移动方向跟旋转角度无关。
HarmonyOS默认的变换顺序在大多数场景下是合理的。如果你发现变换效果跟预期不一样,可能是顺序的问题。这种情况下可以考虑用Matrix4来做自定义变换矩阵,完全控制变换顺序。不过对于大多数应用场景来说,默认顺序就够用了。
实际应用场景扩展
并行手势组除了做图片编辑器,还有很多应用场景。比如一个3D产品展示页面,用户可以拖拽旋转产品模型、双指缩放查看细节、同时平移调整位置。再比如一个思维导图应用,用户可以缩放整个画布、平移视野、同时拖拽某个节点到新位置。
另一个有意思的场景是视频编辑器。用户在时间轴上可以双指缩放调整时间精度、单指拖拽移动时间线位置、旋转来做某些特殊的转场预览。这些操作同时进行的话,编辑效率会高很多。
还有一个场景是教育类应用。比如一个地理教学App,学生在地图上同时缩放查看不同区域、拖拽移动视野、旋转调整方向。三个操作并行让探索过程非常自然,学生可以像操作真实地球仪一样操作数字地图。这种交互方式在教育领域的应用会越来越广泛。
写在最后
GestureMode.Parallel是三个手势模式里最直观的一个——所有手势同时生效,互不干扰。用它来做图片编辑器、地图操作、画板应用都很合适。关键是把每个手势的起始值管理好,避免多次操作之间的状态串扰。
更多推荐


所有评论(0)