并行手势组封面图

之前一直觉得多个手势同时操作是系统底层的事情,应用层做不了。直到看到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是三个手势模式里最直观的一个——所有手势同时生效,互不干扰。用它来做图片编辑器、地图操作、画板应用都很合适。关键是把每个手势的起始值管理好,避免多次操作之间的状态串扰。

Logo

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

更多推荐