一、背景说明

HarmonyOS 7 (API 26.0.1) Developer Beta 今日向开发者定向推送,ArkUI 在新版本中继续增强交互能力(如 Navigation 页面预加载、动画组件 C API 扩展),而无论版本怎么演进,手势交互始终是 ArkUI 应用侧最常用的能力。本文聚焦一个具体技术点:使用 ArkUI 的手势系统实现组合手势——长按后拖拽组件、双击与捏合并行缩放、互斥手势的优先级控制,附完整可运行代码。

二、技术原理

ArkUI 提供 6 种基础手势(TapGesture、LongPressGesture、PanGesture、PinchGesture、RotationGesture、SwipeGesture),通过 GestureGroup 可将多个基础手势组合,三种识别模式如下:

  • Sequence(顺序识别):按注册顺序依次识别,前一个成功才能识别下一个;典型场景是"长按后拖拽"——只有长按达标后拖动才生效,等价于系统级拖拽事件。
  • Parallel(并行识别):所有手势同时识别、互不影响;典型场景是"双击与捏合并行",双击切换开关状态的同时捏合调整缩放比例。
  • Exclusive(互斥识别):手势同时参与竞争,任一成功则其余失败;典型场景是"左滑与长按只能命中其一"。

绑定方式有三种:gesture() 普通绑定(子组件优先)、priorityGesture() 父组件优先识别、parallelGesture() 父子同时触发。关键回调:onActionStart/onActionUpdate/onActionEnd,其中 GestureEvent.offsetX/offsetY 是相对手势起点的位移(vp),scale 是捏合比例增量。

数据流:手指触摸 → 手势识别器命中 → GestureGroup 按模式裁决 → 回调更新 @State → UI 声明式重渲染(translate/scale 属性)。

三、实现步骤

工程为 Stage 模型(API 12+),纯 UI 能力无需任何权限配置,module.json5 保持默认。

1. 长按后拖拽(Sequence 顺序识别)

实现一个"先长按进入拖拽态、再拖动移动位置"的卡片:

@Entry
@Component
struct DragCard {
  @State offsetX: number = 0;
  @State offsetY: number = 0;
  @State positionX: number = 0; // 上一次拖拽结束的位置
  @State positionY: number = 0;
  @State dragging: boolean = false; // 拖拽态标记
  @State pressCount: number = 0;    // 长按触发计数

  build() {
    Column() {
      Column() {
        Text(this.dragging ? '拖拽中…' : '长按后拖动我')
          .fontSize(16)
          .fontColor('#FFFFFF')
      }
      .width(200)
      .height(100)
      .borderRadius(12)
      .backgroundColor(this.dragging ? '#E5484D' : '#3A7BD5')
      .justifyContent(FlexAlign.Center)
      // 位移直接驱动 translate 属性
      .translate({ x: this.offsetX, y: this.offsetY, z: 0 })
      .shadow({ radius: this.dragging ? 20 : 5, color: '#33000000' })
      .gesture(
        // 顺序识别:长按成功后才允许拖动
        GestureGroup(GestureMode.Sequence,
          LongPressGesture({ duration: 500 })
            .onAction(() => {
              this.dragging = true;
              this.pressCount++;
            })
            .onActionEnd(() => {
              // 长按中途抬起视为取消拖拽
              this.dragging = false;
            }),
          PanGesture()
            .onActionStart(() => {
              if (!this.dragging) { return; }
            })
            .onActionUpdate((event?: GestureEvent) => {
              if (event && this.dragging) {
                this.offsetX = this.positionX + event.offsetX;
                this.offsetY = this.positionY + event.offsetY;
              }
            })
            .onActionEnd(() => {
              // 记录终点位置,供下次拖拽累计
              this.positionX = this.offsetX;
              this.positionY = this.offsetY;
              this.dragging = false;
            })
        ).onCancel(() => {
          this.dragging = false; // 手势被系统打断时复位状态
        })
      )
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}
2. 双击与捏合并行(Parallel 并行识别)

双击在"放大/复原"间切换,捏合手势实时调整缩放,两者同时生效:

@Entry
@Component
struct ZoomCard {
  @State scaleValue: number = 1.0;
  @State lastScale: number = 1.0;  // 捏合结束后的累计缩放
  @State expanded: boolean = false;

  build() {
    Column() {
      Column() {
        Text('双击或捏合缩放')
          .fontSize(15)
          .fontColor('#FFFFFF')
      }
      .width(180)
      .height(180)
      .borderRadius(16)
      .linearGradient({ angle: 135, colors: [['#3A7BD5', 0.0], ['#00D2FF', 1.0]] })
      .justifyContent(FlexAlign.Center)
      .scale({ x: this.scaleValue, y: this.scaleValue })
      .gesture(
        GestureGroup(GestureMode.Parallel,
          // 双击:在 1.0 与 1.5 之间切换
          TapGesture({ count: 2 })
            .onAction(() => {
              this.expanded = !this.expanded;
              this.scaleValue = this.expanded ? 1.5 : 1.0;
              this.lastScale = this.scaleValue;
            }),
          // 捏合:在上一状态基础上连续缩放
          PinchGesture({ fingers: 2 })
            .onActionUpdate((event?: GestureEvent) => {
              if (event) {
                this.scaleValue = Math.min(3, Math.max(0.5, this.lastScale * event.scale));
              }
            })
            .onActionEnd(() => {
              this.lastScale = this.scaleValue;
            })
        )
      )
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}
3. 互斥识别(Exclusive)

同一组件上左滑与长按互斥,先满足条件者胜出:

@Entry
@Component
struct ExclusiveDemo {
  @State result: string = '等待手势';

  build() {
    Column() {
      Text(this.result)
        .fontSize(16)
        .width(240)
        .height(80)
        .textAlign(TextAlign.Center)
        .borderRadius(12)
        .backgroundColor('#F2F3F5')
        .gesture(
          GestureGroup(GestureMode.Exclusive,
            PanGesture({ direction: PanDirection.Horizontal, distance: 20 })
              .onActionStart(() => {
                this.result = '左滑生效(长按被排除)';
              }),
            LongPressGesture({ duration: 800 })
              .onAction(() => {
                this.result = '长按生效(左滑被排除)';
              })
          )
        )
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}
四、应用场景示例

实际业务中最常见的组合是"长按进入编辑态 + 拖动排序 + 点击打开",把本文第 1 节与单击手势放进一个并行组即可同时支持点击与拖拽:

.gesture(
  GestureGroup(GestureMode.Parallel,
    // 点击:非拖拽态下打开详情
    TapGesture()
      .onAction(() => {
        if (!this.dragging) {
          router.pushUrl({ url: 'pages/Detail' });
        }
      }),
    // 长按拖拽:沿用第 1 节的 Sequence 组合
    GestureGroup(GestureMode.Sequence,
      LongPressGesture({ duration: 500 })
        .onAction(() => { this.dragging = true; }),
      PanGesture()
        .onActionUpdate((event?: GestureEvent) => {
          if (event && this.dragging) {
            this.offsetX = this.positionX + event.offsetX;
            this.offsetY = this.positionY + event.offsetY;
          }
        })
        .onActionEnd(() => {
          this.positionX = this.offsetX;
          this.positionY = this.offsetY;
          this.dragging = false;
        })
    )
  )
)
五、技术要点
1. 手势冲突与优先级
  • 子组件与父组件绑定了同类型手势时,子组件默认优先;需要父组件优先时用 priorityGesture,需要父子同时触发时用 parallelGesture;
  • Text、TextInput、Image 等组件自带长按(选择/复制/拖拽)行为,与自定义 LongPressGesture 冲突:长按时间 <500ms 时系统优先响应长按,≥500ms 时优先响应组件自带拖拽,必要时用 duration 调节错开。
2. 状态复位

onActionEnd 只在手势正常结束时触发;手势被来电、弹窗等系统事件打断时会走 onCancel——拖拽态标记务必在 onCancel 中复位,否则组件会卡在"拖拽中"样式。

3. 常见问题
// 问题1:双击永远不触发(单击注册在双击之前吞掉了第一次点击)
// 错误:GestureGroup(GestureMode.Exclusive, TapGesture({count:1}), TapGesture({count:2}))
// 正确:双击注册在前,或两者放进同一 Sequence 组
GestureGroup(GestureMode.Sequence,
  TapGesture({ count: 2 }).onAction(() => { /* 双击 */ }),
  TapGesture({ count: 1 }).onAction(() => { /* 单击(双击失败后回退到单击) */ })
)

// 问题2:拖拽位置跳变——onActionUpdate 的 offset 是相对手势起点的增量
// 正确做法:offsetX = positionX + event.offsetX,onActionEnd 时更新 positionX
4. 性能建议
  • onActionUpdate 每帧回调一次,回调内只做 @State 赋值,不要执行布局计算或日志;
  • 不需要响应手势的隐藏组件应避免绑定手势,识别器数量过多会增加命中测试开销;
  • 高频拖拽场景可配合 translate 属性动画(而非每帧修改布局参数)避免重新测量。
六、扩展建议
  1. 拖拽排序:把本文长按拖拽与 List/Grid 的 onItemMove 或 dragPreview 结合,实现列表项长按排序;
  2. 多指旋转:RotationGesture 与 PinchGesture 并行组合,可实现图片查看器的"捏合缩放 + 双指旋转";
  3. 自定义手势判定:API 12+ 提供 onGestureJudgeBegin 回调,可在手势参与竞争前按 sourceTool(手指/鼠标/触控笔)动态放行或拦截:
.gesture(
  PanGesture()
    .onGestureJudgeBegin((info: GestureJudgeResultInfo, event: BaseGestureEvent) => {
      // 仅手指触发时才参与识别
      return info.sourceTool === SourceTool.FINGER
        ? GestureJudgeResult.ACCEPT : GestureJudgeResult.REJECT;
    })
)
结语

本文用 ArkUI 手势系统实现了顺序(长按拖拽)、并行(双击+捏合缩放)、互斥(左滑 vs 长按)三种组合手势的完整实现,核心代码约 200 行,均可直接编译运行。掌握 GestureGroup 的三种模式与父子组件手势优先级规则后,应用内的拖拽排序、图片查看、卡片交互等复杂手势都能组合出来,而不依赖任何三方库。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐