HarmonyOS APP开发:手势识别与体感交互

举报
Jack20 发表于 2026/06/21 14:13:27 2026/06/21
【摘要】 HarmonyOS APP开发:手势识别与体感交互核心要点:掌握HarmonyOS手势识别体系,理解触摸事件分发机制,实现自定义手势识别器,构建体感交互应用 一、背景与动机你有没有这样的体验——在厨房做饭时手上沾满了油,想翻个菜谱页面却怎么也划不动屏幕?或者冬天戴着厚手套出门,手机根本不响应你的触摸?又或者在演示PPT的时候,想远距离翻页却只能跑回电脑前按键盘?这些场景都指向了同一个问题:...

HarmonyOS APP开发:手势识别与体感交互

核心要点:掌握HarmonyOS手势识别体系,理解触摸事件分发机制,实现自定义手势识别器,构建体感交互应用


一、背景与动机

你有没有这样的体验——在厨房做饭时手上沾满了油,想翻个菜谱页面却怎么也划不动屏幕?或者冬天戴着厚手套出门,手机根本不响应你的触摸?又或者在演示PPT的时候,想远距离翻页却只能跑回电脑前按键盘?

这些场景都指向了同一个问题:传统的触摸交互方式太单一了。我们和设备之间的"对话",不应该只有"戳"和"滑"两种方式。

HarmonyOS的手势识别体系,给了我们一种全新的可能。从最基础的点击、长按,到复杂的捏合、旋转、拖拽,再到多指协同和自定义手势——它提供了一套完整的、层次分明的手势处理框架。而体感交互更进一步,让设备能"看懂"你的动作,而不仅仅是"感受"你的触摸。

想象一下:隔空挥手就能切歌,捏合手指就能缩放地图,画个圈就能唤醒助手——这不是科幻,这是HarmonyOS开发者现在就能实现的能力。


二、核心原理

2.1 手势识别体系架构

HarmonyOS的手势识别体系分为三个层次:基础手势组合手势自定义手势

flowchart TB
    A[HarmonyOS手势识别体系] --> B[基础手势层]
    A --> C[组合手势层]
    A --> D[自定义手势层]
    
    B --> B1[点击手势 TapGesture]
    B --> B2[长按手势 LongPressGesture]
    B --> B3[拖动手势 PanGesture]
    B --> B4[捏合手势 PinchGesture]
    B --> B5[旋转手势 RotationGesture]
    B --> B6[滑动手势 SwipeGesture]
    
    C --> C1[串行组合 Sequential]
    C --> C2[并行组合 Parallel]
    C --> C3[互斥组合 Exclusive]
    
    D --> D1[手势控制器 GestureController]
    D --> D2[触摸事件原始数据]
    D --> D3[自定义识别算法]
    
    classDef primary fill:#4F46E5,stroke:#3730A3,color:#fff
    classDef warning fill:#F59E0B,stroke:#D97706,color:#fff
    classDef error fill:#EF4444,stroke:#DC2626,color:#fff
    classDef info fill:#06B6D4,stroke:#0891B2,color:#fff
    classDef purple fill:#8B5CF6,stroke:#7C3AED,color:#fff
    
    class A primary
    class B,B1,B2,B3,B4,B5,B6 info
    class C,C1,C2,C3 warning
    class D,D1,D2,D3 purple

2.2 手势事件分发机制

手势识别的关键在于事件分发。当用户触摸屏幕时,事件会按照以下路径传递:

  1. 触摸事件产生 → 系统捕获原始触摸点坐标
  2. 命中测试 → 确定事件目标组件
  3. 手势竞技场 → 多个手势识别器竞争决策
  4. 手势回调 → 胜出的识别器触发回调

这里最核心的概念是手势竞技场(Gesture Arena)。当多个手势识别器同时监听同一个触摸事件时,竞技场会根据优先级和识别进度来决定最终由哪个手势"胜出"。

2.3 手势冲突与优先级

手势冲突是开发中最头疼的问题之一。比如一个列表里面嵌套了卡片,卡片支持拖拽,列表支持滑动——用户在卡片上拖拽时,到底是拖卡片还是滑列表?

HarmonyOS提供了三种解决策略:

策略 说明 适用场景
.priorityGesture() 优先识别,子组件手势优先 父子组件手势互斥
.parallelGesture() 并行识别,同时触发 父子手势不冲突
.gesture() 默认策略,父组件优先 通用场景

三、代码实战

3.1 基础手势识别器:多手势画板

这个示例展示了所有基础手势的用法,构建一个支持多种手势操作的画板应用。

// MultiGestureCanvas.ets - 多手势画板组件
// 功能:演示点击、长按、拖动、捏合、旋转、滑动六种基础手势

@Entry
@Component
struct MultiGestureCanvas {
  // 画板状态
  @State canvasScale: number = 1.0        // 缩放比例
  @State canvasRotation: number = 0        // 旋转角度
  @State canvasOffsetX: number = 0         // X轴偏移
  @State canvasOffsetY: number = 0         // Y轴偏移
  @State lastGestureInfo: string = '等待手势输入...'  // 最近手势信息
  @State dotList: Array<DotInfo> = []      // 画点列表
  @State bgColor: string = '#1a1a2e'       // 画板背景色

  // 构建画板主体
  build() {
    Column() {
      // 顶部状态栏:显示当前手势信息
      Row() {
        Text('🎨 手势画板')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#e0e0ff')
        Blank()
        Text(this.lastGestureInfo)
          .fontSize(14)
          .fontColor('#a0a0cc')
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
      }
      .width('100%')
      .height(56)
      .padding({ left: 16, right: 16 })
      .backgroundColor('#16213e')

      // 画板区域
      Stack() {
        // 画点层
        ForEach(this.dotList, (dot: DotInfo, index: number) => {
          Circle()
            .width(dot.size)
            .height(dot.size)
            .fill(dot.color)
            .position({ x: dot.x - dot.size / 2, y: dot.y - dot.size / 2 })
        }, (dot: DotInfo, index: number) => `${index}`)

        // 缩放和旋转信息叠加层
        Column() {
          Text(`缩放: ${this.canvasScale.toFixed(2)}x`)
            .fontSize(12)
            .fontColor('#808090')
          Text(`旋转: ${this.canvasRotation.toFixed(1)}°`)
            .fontSize(12)
            .fontColor('#808090')
        }
        .alignItems(HorizontalAlign.Start)
        .position({ x: 12, y: 12 })
      }
      .width('100%')
      .layoutWeight(1)
      .backgroundColor(this.bgColor)
      .scale({ x: this.canvasScale, y: this.canvasScale })
      .rotate({ angle: this.canvasRotation })
      .translate({ x: this.canvasOffsetX, y: this.canvasOffsetY })
      // 绑定手势组合:并行识别多种手势
      .gesture(
        GestureGroup(GestureMode.Exclusive,
          // 1. 点击手势:双击添加画点
          TapGesture({ count: 2 })
            .onAction((event: GestureEvent) => {
              const dot: DotInfo = {
                x: event.fingerList[0].localX,
                y: event.fingerList[0].localY,
                size: 20,
                color: this.getRandomColor()
              }
              this.dotList.push(dot)
              this.lastGestureInfo = `双击添加画点 (${dot.x.toFixed(0)}, ${dot.y.toFixed(0)})`
            }),
          // 2. 长按手势:改变背景色
          LongPressGesture({ repeat: true, duration: 800 })
            .onAction((event: GestureEvent) => {
              if (event.repeat) {
                this.bgColor = this.getRandomBgColor()
                this.lastGestureInfo = `长按切换背景 #${this.bgColor}`
              }
            })
            .onActionEnd(() => {
              this.lastGestureInfo = '长按结束'
            }),
          // 3. 拖动手势:移动画板
          PanGesture({ fingers: 1, distance: 5 })
            .onActionStart(() => {
              this.lastGestureInfo = '开始拖动画板'
            })
            .onActionUpdate((event: GestureEvent) => {
              this.canvasOffsetX += event.offsetX
              this.canvasOffsetY += event.offsetY
              this.lastGestureInfo = `拖动中 (${this.canvasOffsetX.toFixed(0)}, ${this.canvasOffsetY.toFixed(0)})`
            })
            .onActionEnd(() => {
              this.lastGestureInfo = '拖动结束'
            }),
          // 4. 捏合手势:缩放画板
          PinchGesture({ fingers: 2, distance: 5 })
            .onActionStart(() => {
              this.lastGestureInfo = '开始捏合缩放'
            })
            .onActionUpdate((event: GestureEvent) => {
              this.canvasScale *= event.scale
              // 限制缩放范围
              this.canvasScale = Math.max(0.3, Math.min(3.0, this.canvasScale))
              this.lastGestureInfo = `缩放中 ${this.canvasScale.toFixed(2)}x`
            })
            .onActionEnd(() => {
              this.lastGestureInfo = '缩放结束'
            }),
          // 5. 旋转手势:旋转画板
          RotationGesture({ fingers: 2, angle: 5 })
            .onActionStart(() => {
              this.lastGestureInfo = '开始旋转'
            })
            .onActionUpdate((event: GestureEvent) => {
              this.canvasRotation += event.angle
              this.lastGestureInfo = `旋转中 ${this.canvasRotation.toFixed(1)}°`
            })
            .onActionEnd(() => {
              this.lastGestureInfo = '旋转结束'
            }),
          // 6. 滑动手势:快速滑动清除画点
          SwipeGesture({ fingers: 1, speed: 500 })
            .onAction((event: GestureEvent) => {
              const direction = this.getSwipeDirection(event.angle)
              if (direction === 'down') {
                this.dotList = []
                this.lastGestureInfo = '下滑清除所有画点'
              }
            })
        )
      )

      // 底部操作栏
      Row() {
        Button('重置')
          .fontSize(14)
          .backgroundColor('#4F46E5')
          .onClick(() => {
            this.canvasScale = 1.0
            this.canvasRotation = 0
            this.canvasOffsetX = 0
            this.canvasOffsetY = 0
            this.dotList = []
            this.bgColor = '#1a1a2e'
            this.lastGestureInfo = '已重置'
          })
      }
      .width('100%')
      .height(60)
      .padding({ left: 16, right: 16 })
      .justifyContent(FlexAlign.Center)
      .backgroundColor('#16213e')
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#0f0f23')
  }

  // 生成随机画点颜色
  private getRandomColor(): string {
    const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8']
    return colors[Math.floor(Math.random() * colors.length)]
  }

  // 生成随机背景色(深色系)
  private getRandomBgColor(): string {
    const colors = ['#1a1a2e', '#16213e', '#0f3460', '#1a1a40', '#2d132c', '#1b1b2f']
    return colors[Math.floor(Math.random() * colors.length)]
  }

  // 根据角度判断滑动方向
  private getSwipeDirection(angle: number): string {
    if (angle >= -45 && angle < 45) return 'right'
    if (angle >= 45 && angle < 135) return 'down'
    if (angle >= -135 && angle < -45) return 'up'
    return 'left'
  }
}

// 画点数据结构
interface DotInfo {
  x: number       // X坐标
  y: number       // Y坐标
  size: number     // 大小
  color: string    // 颜色
}

3.2 自定义手势识别:手势密码锁

这个示例展示了如何通过原始触摸事件实现自定义手势识别——九宫格手势密码。

// GesturePassword.ets - 手势密码锁组件
// 功能:通过触摸事件原始数据实现九宫格手势密码绘制与验证

@Entry
@Component
struct GesturePassword {
  @State selectedNodes: number[] = []        // 已选中的节点
  @State linePath: string = ''               // 连线路径
  @State message: string = '请绘制手势密码'   // 提示信息
  @State messageColor: string = '#a0a0cc'    // 提示颜色
  @State isDrawing: boolean = false           // 是否正在绘制
  @State currentNodeX: number = 0            // 当前触摸X
  @State currentNodeY: number = 0            // 当前触摸Y
  @State savedPassword: string = ''           // 已保存的密码
  @State isSettingMode: boolean = true        // 是否为设置模式
  @State confirmPassword: string = ''         // 确认密码

  // 九宫格节点坐标(3x3布局)
  private nodePositions: Array<{ x: number, y: number }> = []
  private nodeRadius: number = 22
  private gridPadding: number = 60
  private gridSpacing: number = 100

  aboutToAppear() {
    // 初始化九宫格节点位置
    for (let row = 0; row < 3; row++) {
      for (let col = 0; col < 3; col++) {
        this.nodePositions.push({
          x: this.gridPadding + col * this.gridSpacing,
          y: this.gridPadding + row * this.gridSpacing
        })
      }
    }
  }

  build() {
    Column() {
      // 标题区域
      Column() {
        Text('🔒 手势密码')
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
          .fontColor('#e0e0ff')
        Text(this.message)
          .fontSize(14)
          .fontColor(this.messageColor)
          .margin({ top: 8 })
      }
      .width('100%')
      .padding({ top: 40, bottom: 20 })

      // 九宫格绘制区域
      Stack() {
        // 连线层
        Polyline()
          .points(this.getLinePoints())
          .fillOpacity(0)
          .stroke('#4F46E5')
          .strokeWidth(4)
          .strokeLineJoin(LineJoinStyle.ROUND)
          .strokeLineCap(LineCapStyle.ROUND)

        // 当前绘制中的跟随线
        if (this.isDrawing && this.selectedNodes.length > 0) {
          Line()
            .width('100%')
            .height('100%')
            .startPoint({
              x: this.nodePositions[this.selectedNodes[this.selectedNodes.length - 1]].x,
              y: this.nodePositions[this.selectedNodes[this.selectedNodes.length - 1]].y
            })
            .endPoint({ x: this.currentNodeX, y: this.currentNodeY })
            .stroke('#4F46E5')
            .strokeWidth(2)
            .strokeOpacity(0.5)
        }

        // 节点层
        ForEach(this.nodePositions, (pos: { x: number, y: number }, index: number) => {
          Stack() {
            // 外圈
            Circle()
              .width(this.nodeRadius * 2)
              .height(this.nodeRadius * 2)
              .fill('transparent')
              .stroke(this.isSelected(index) ? '#4F46E5' : '#3a3a5c')
              .strokeWidth(2)
            // 内圈
            Circle()
              .width(this.isSelected(index) ? 16 : 8)
              .height(this.isSelected(index) ? 16 : 8)
              .fill(this.isSelected(index) ? '#4F46E5' : '#5a5a7c')
          }
          .position({ x: pos.x - this.nodeRadius, y: pos.y - this.nodeRadius })
        }, (pos: { x: number, y: number }, index: number) => `${index}`)
      }
      .width(320)
      .height(320)
      .margin({ top: 20 })
      .onTouch((event: TouchEvent) => this.handleTouchEvent(event))

      // 操作按钮
      Row() {
        Button('重新绘制')
          .fontSize(14)
          .backgroundColor('#2d2d4e')
          .fontColor('#a0a0cc')
          .onClick(() => this.resetGesture())
        Button(this.isSettingMode ? '切换验证模式' : '切换设置模式')
          .fontSize(14)
          .backgroundColor('#4F46E5')
          .onClick(() => {
            this.isSettingMode = !this.isSettingMode
            this.resetGesture()
            this.message = this.isSettingMode ? '请绘制手势密码' : '请验证手势密码'
          })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceEvenly)
      .padding({ left: 20, right: 20 })
      .margin({ top: 40 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#0f0f23')
    .alignItems(HorizontalAlign.Center)
  }

  // 处理触摸事件:实现自定义手势识别
  private handleTouchEvent(event: TouchEvent) {
    const touch = event.touches[0]
    const x = touch.x
    const y = touch.y

    switch (event.type) {
      case TouchType.Down:
        // 手指按下:开始绘制
        this.isDrawing = true
        this.selectedNodes = []
        this.linePath = ''
        this.checkNodeHit(x, y)
        break

      case TouchType.Move:
        // 手指移动:检测经过的节点
        this.currentNodeX = x
        this.currentNodeY = y
        this.checkNodeHit(x, y)
        break

      case TouchType.Up:
      case TouchType.Cancel:
        // 手指抬起:结束绘制,验证密码
        this.isDrawing = false
        this.validateGesture()
        break
    }
  }

  // 检测触摸点是否命中某个节点
  private checkNodeHit(x: number, y: number) {
    for (let i = 0; i < this.nodePositions.length; i++) {
      const pos = this.nodePositions[i]
      const distance = Math.sqrt(
        Math.pow(x - pos.x, 2) + Math.pow(y - pos.y, 2)
      )
      // 命中判定:触摸点在节点半径内,且该节点未被选中
      if (distance <= this.nodeRadius && !this.selectedNodes.includes(i)) {
        this.selectedNodes.push(i)
        break
      }
    }
  }

  // 验证手势密码
  private validateGesture() {
    const currentPassword = this.selectedNodes.join('-')

    if (this.selectedNodes.length < 4) {
      this.message = '至少需要连接4个点'
      this.messageColor = '#EF4444'
      this.resetGesture()
      return
    }

    if (this.isSettingMode) {
      // 设置模式:两次绘制一致则保存密码
      if (this.confirmPassword === '') {
        this.confirmPassword = currentPassword
        this.message = '请再次绘制以确认'
        this.messageColor = '#F59E0B'
        this.selectedNodes = []
      } else if (currentPassword === this.confirmPassword) {
        this.savedPassword = currentPassword
        this.message = '✅ 密码设置成功!'
        this.messageColor = '#10B981'
        this.isSettingMode = false
        this.confirmPassword = ''
      } else {
        this.message = '❌ 两次绘制不一致,请重试'
        this.messageColor = '#EF4444'
        this.confirmPassword = ''
        this.selectedNodes = []
      }
    } else {
      // 验证模式:与已保存密码比对
      if (currentPassword === this.savedPassword) {
        this.message = '✅ 验证通过!'
        this.messageColor = '#10B981'
      } else {
        this.message = '❌ 密码错误,请重试'
        this.messageColor = '#EF4444'
      }
    }
  }

  // 判断节点是否被选中
  private isSelected(index: number): boolean {
    return this.selectedNodes.includes(index)
  }

  // 获取连线路径点
  private getLinePoints(): Array<Point> {
    return this.selectedNodes.map((nodeIndex: number) => {
      return {
        x: this.nodePositions[nodeIndex].x,
        y: this.nodePositions[nodeIndex].y
      } as Point
    })
  }

  // 重置手势状态
  private resetGesture() {
    this.selectedNodes = []
    this.isDrawing = false
  }
}

3.3 体感交互:隔空手势控制器

这个示例展示了如何结合加速度传感器和陀螺仪实现体感交互——通过晃动和倾斜设备来控制界面。

// MotionController.ets - 体感交互控制器
// 功能:通过设备运动传感器实现隔空手势控制

import sensor from '@ohos.sensor'
import vibrator from '@ohos.vibrator'

@Entry
@Component
struct MotionController {
  @State ballX: number = 180           // 球体X坐标
  @State ballY: number = 300           // 球体Y坐标
  @State ballColor: string = '#4F46E5' // 球体颜色
  @State score: number = 0             // 得分
  @State shakeCount: number = 0        // 摇晃次数
  @State motionInfo: string = '等待传感器...'  // 运动信息
  @State isGaming: boolean = false     // 游戏是否进行中
  @State targets: Array<TargetInfo> = []      // 目标列表
  @State trailPoints: Array<Point> = []       // 轨迹点

  private sensorSubscription: number = -1  // 传感器订阅ID
  private lastShakeTime: number = 0        // 上次摇晃时间
  private screenWidth: number = 360        // 屏幕宽度
  private screenHeight: number = 640       // 屏幕高度
  private ballRadius: number = 20          // 球体半径

  aboutToAppear() {
    this.initSensor()
  }

  aboutToDisappear() {
    this.releaseSensor()
  }

  build() {
    Column() {
      // 顶部信息栏
      Row() {
        Column() {
          Text('🎯 体感控制')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor('#e0e0ff')
          Text(this.motionInfo)
            .fontSize(12)
            .fontColor('#808090')
            .margin({ top: 4 })
        }
        .alignItems(HorizontalAlign.Start)
        Blank()
        Column() {
          Text(`得分: ${this.score}`)
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor('#4ECDC4')
          Text(`摇晃: ${this.shakeCount}`)
            .fontSize(12)
            .fontColor('#808090')
        }
        .alignItems(HorizontalAlign.End)
      }
      .width('100%')
      .height(70)
      .padding({ left: 16, right: 16 })
      .backgroundColor('#16213e')

      // 游戏区域
      Stack() {
        // 轨迹层
        if (this.trailPoints.length > 1) {
          Polyline()
            .points(this.trailPoints)
            .fillOpacity(0)
            .stroke('#4F46E5')
            .strokeWidth(2)
            .strokeOpacity(0.3)
        }

        // 目标层
        ForEach(this.targets, (target: TargetInfo, index: number) => {
          Circle()
            .width(target.size)
            .height(target.size)
            .fill(target.color)
            .opacity(target.opacity)
            .position({ x: target.x - target.size / 2, y: target.y - target.size / 2 })
        }, (target: TargetInfo, index: number) => `${index}`)

        // 球体(受重力控制)
        Circle()
          .width(this.ballRadius * 2)
          .height(this.ballRadius * 2)
          .fill(this.ballColor)
          .shadow({ radius: 12, color: this.ballColor, offsetX: 0, offsetY: 4 })
          .position({ x: this.ballX - this.ballRadius, y: this.ballY - this.ballRadius })
      }
      .width('100%')
      .layoutWeight(1)
      .backgroundColor('#0f0f23')
      .clip(true)

      // 底部控制栏
      Row() {
        Button(this.isGaming ? '结束游戏' : '开始游戏')
          .fontSize(14)
          .backgroundColor(this.isGaming ? '#EF4444' : '#4F46E5')
          .onClick(() => {
            this.isGaming = !this.isGaming
            if (this.isGaming) {
              this.score = 0
              this.shakeCount = 0
              this.targets = []
              this.generateTargets()
            }
          })
        Button('重置位置')
          .fontSize(14)
          .backgroundColor('#2d2d4e')
          .fontColor('#a0a0cc')
          .onClick(() => {
            this.ballX = this.screenWidth / 2
            this.ballY = this.screenHeight / 2
            this.trailPoints = []
          })
      }
      .width('100%')
      .height(60)
      .padding({ left: 16, right: 16 })
      .justifyContent(FlexAlign.SpaceEvenly)
      .backgroundColor('#16213e')
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#0f0f23')
  }

  // 初始化加速度传感器
  private initSensor() {
    try {
      // 订阅加速度传感器数据
      this.sensorSubscription = sensor.on(sensor.SensorType.ACCELEROMETER, (data: sensor.AccelerometerResponse) => {
        if (!this.isGaming) {
          this.motionInfo = `加速度 X:${data.x.toFixed(1)} Y:${data.y.toFixed(1)} Z:${data.z.toFixed(1)}`
          return
        }

        // 利用重力加速度控制球体移动
        // x轴加速度控制左右移动(设备倾斜)
        const moveX = -data.x * 3  // 灵敏度系数
        // y轴加速度控制上下移动
        const moveY = data.y * 3

        // 更新球体位置
        this.ballX = Math.max(this.ballRadius, Math.min(this.screenWidth - this.ballRadius, this.ballX + moveX))
        this.ballY = Math.max(this.ballRadius, Math.min(this.screenHeight - this.ballRadius, this.ballY + moveY))

        // 记录轨迹
        this.trailPoints.push({ x: this.ballX, y: this.ballY } as Point)
        if (this.trailPoints.length > 50) {
          this.trailPoints.shift()
        }

        // 检测摇晃(加速度突变)
        const totalAccel = Math.sqrt(data.x * data.x + data.y * data.y + data.z * data.z)
        const now = Date.now()
        if (totalAccel > 20 && now - this.lastShakeTime > 500) {
          this.lastShakeTime = now
          this.shakeCount++
          this.ballColor = this.getRandomColor()
          this.motionInfo = `💥 检测到摇晃!力度: ${totalAccel.toFixed(1)}`
          // 触发振动反馈
          try {
            vibrator.startVibration({ type: 'time', duration: 100 })
          } catch (e) {
            // 振动不可用时忽略
          }
        } else {
          this.motionInfo = `倾斜控制中 X:${data.x.toFixed(1)} Y:${data.y.toFixed(1)}`
        }

        // 碰撞检测:球体与目标
        this.checkCollision()
      }, { interval: 100000000 }) // 100ms采样间隔
    } catch (error) {
      this.motionInfo = '传感器不可用,请检查设备'
    }
  }

  // 释放传感器资源
  private releaseSensor() {
    if (this.sensorSubscription !== -1) {
      try {
        sensor.off(sensor.SensorType.ACCELEROMETER, this.sensorSubscription)
      } catch (e) {
        // 忽略释放错误
      }
    }
  }

  // 生成随机目标
  private generateTargets() {
    const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFEAA7', '#DDA0DD']
    for (let i = 0; i < 5; i++) {
      this.targets.push({
        x: 40 + Math.random() * (this.screenWidth - 80),
        y: 80 + Math.random() * (this.screenHeight - 160),
        size: 30 + Math.random() * 20,
        color: colors[i % colors.length],
        opacity: 1.0
      })
    }
  }

  // 碰撞检测
  private checkCollision() {
    this.targets = this.targets.filter((target: TargetInfo) => {
      const distance = Math.sqrt(
        Math.pow(this.ballX - target.x, 2) + Math.pow(this.ballY - target.y, 2)
      )
      if (distance < this.ballRadius + target.size / 2) {
        this.score += 10
        return false  // 移除被吃掉的目标
      }
      return true
    })

    // 所有目标被吃掉,生成新一批
    if (this.targets.length === 0 && this.isGaming) {
      this.generateTargets()
    }
  }

  // 随机颜色
  private getRandomColor(): string {
    const colors = ['#4F46E5', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#06B6D4']
    return colors[Math.floor(Math.random() * colors.length)]
  }
}

// 目标数据结构
interface TargetInfo {
  x: number       // X坐标
  y: number       // Y坐标
  size: number    // 大小
  color: string   // 颜色
  opacity: number // 透明度
}

四、踩坑与注意事项

4.1 手势冲突:嵌套滚动的噩梦

问题:在 Scroll 组件内部放置可拖拽的卡片,拖拽时列表也在滚动。

解决方案:使用 .priorityGesture() 让子组件手势优先,或者在拖拽开始时动态禁用父组件滚动。

// 方案一:优先手势
Column() {
  Scroll() {
    Column() {
      // 可拖拽卡片
      Text('拖我')
        .gesture(
          PanGesture()
            .onActionUpdate((event) => { /* 拖拽逻辑 */ }),
          // 使用priorityGesture确保拖拽优先
          PriorityGesture.High
        )
    }
  }
}

// 方案二:动态控制滚动
@State scrollEnabled: boolean = true

Scroll() {
  // ...
}
.scrollable(this.scrollEnabled ? ScrollDirection.Vertical : ScrollDirection.None)

4.2 捏合手势缩放异常抖动

问题:捏合缩放时画面抖动,因为每次 onActionUpdateevent.scale相对于上一次的增量,而不是绝对值。

解决方案:在 onActionStart 时记录初始缩放值,在 onActionUpdate 中用初始值乘以增量。

@State baseScale: number = 1.0  // 记录手势开始时的缩放值

PinchGesture()
  .onActionStart(() => {
    this.baseScale = this.canvasScale  // 记录当前缩放
  })
  .onActionUpdate((event: GestureEvent) => {
    // 正确:用基础值乘以增量
    this.canvasScale = this.baseScale * event.scale
  })

4.3 传感器权限与生命周期

问题:加速度传感器在页面隐藏后仍在持续上报数据,导致耗电和性能问题。

解决方案:在 onPageShow / onPageHide 生命周期中管理传感器订阅。

onPageShow() {
  this.initSensor()  // 页面可见时订阅
}

onPageHide() {
  this.releaseSensor()  // 页面不可见时释放
}

4.4 多指手势的 finger 识别

问题:多指手势(如两指旋转)在某些设备上识别不稳定。

注意事项

  • fingers 参数指定最少手指数量,但用户可能用更多手指
  • RotationGestureangle增量值,需要累加
  • onActionStart 中重置基准值,避免角度跳变

五、HarmonyOS 6适配

5.1 手势API变更

变更项 HarmonyOS 5 HarmonyOS 6
手势回调参数 GestureEvent GestureEventV2(新增velocity字段)
传感器订阅 sensor.on() sensor.subscribeAccelerometer()(新命名)
振动API vibrator.startVibration() vibrator.startVibrationV2()(支持自定义波形)
手势竞技场 隐式竞争 支持 GestureArena 显式配置

5.2 迁移要点

  1. 手势事件升级:HarmonyOS 6中 GestureEvent 扩展了 velocity 字段,可直接获取手势速度,无需手动计算
  2. 传感器API重命名sensor.on() 改为 sensor.subscribeAccelerometer(),参数结构不变
  3. 新增手势控制器GestureController 支持编程式触发手势,可用于自动化测试
  4. 无障碍适配:HarmonyOS 6要求所有手势操作必须提供替代的无障碍操作路径
// HarmonyOS 6 新增:手势速度获取
PanGesture()
  .onActionEnd((event: GestureEventV2) => {
    // 直接获取手势结束时的速度
    const velocity = event.velocity
    if (velocity > 1000) {
      console.info('快速滑动,执行惯性动画')
    }
  })

六、总结

mindmap
  root((手势识别与体感交互))
    基础手势
      点击 TapGesture
      长按 LongPressGesture
      拖动 PanGesture
      捏合 PinchGesture
      旋转 RotationGesture
      滑动 SwipeGesture
    组合手势
      串行 Sequential
      并行 Parallel
      互斥 Exclusive
    自定义手势
      onTouch原始事件
      命中检测算法
      路径识别
    体感交互
      加速度传感器
      陀螺仪
      振动反馈
    关键技巧
      手势冲突处理
      增量值vs绝对值
      传感器生命周期
      多指识别稳定性
    
    classDef primary fill:#4F46E5,stroke:#3730A3,color:#fff
    classDef warning fill:#F59E0B,stroke:#D97706,color:#fff
    classDef error fill:#EF4444,stroke:#DC2626,color:#fff
    classDef info fill:#06B6D4,stroke:#0891B2,color:#fff
    classDef purple fill:#8B5CF6,stroke:#7C3AED,color:#fff
知识点 核心内容
手势体系 基础手势→组合手势→自定义手势,三层递进
事件分发 命中测试→手势竞技场→回调触发
冲突解决 priorityGesture / parallelGesture / 动态禁用
自定义识别 onTouch 原始事件 + 命中检测 + 路径算法
体感交互 加速度传感器控制 + 振动反馈 + 摇晃检测
增量陷阱 捏合/旋转的scale/angle是增量,需记录基准值
传感器管理 页面生命周期中订阅/释放,避免后台耗电
HarmonyOS 6 GestureEventV2、传感器API重命名、GestureController

手势识别是智能交互的基石。从最简单的点击到复杂的自定义手势,HarmonyOS提供了完整的工具链。掌握手势竞技场机制和增量值陷阱,你就能构建出流畅自然的体感交互体验。下一篇,我们将深入姿态估计与动作捕捉,让设备真正"看懂"你的全身动作。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。