HarmonyOS应用<节气通>开发第42篇:手势识别与交互——打造流畅的触摸体验

引言

手势识别(Gesture)是移动应用交互的核心方式之一。从最基础的点击、长按,到复杂的拖拽、缩放、旋转,手势让用户能以直观自然的方式与应用进行交互。HarmonyOS ArkUI 提供了完整的手势系统,包括 TapGesture(点击)、LongPressGesture(长按)、PanGesture(拖拽)、PinchGesture(捏合缩放)、RotationGesture(旋转)以及组合手势 GestureGroup

一个优秀的手势交互系统应该具备以下特点:

  • 响应灵敏:手势识别延迟低,跟手性好
  • 冲突处理:多手势共存时正确区分优先级
  • 反馈明确:每个手势都有清晰的视觉/触觉反馈
  • 边界保护:防止手势触发越界或异常状态
  • 可访问性:支持无障碍操作和替代方案

本文为实战总结版本 ,涵盖 ArkUI 全部 6 种基础手势、组合手势、手势竞争与优先级、以及完整的列表拖拽排序实现。


学习目标

完成本文后,你将能够:

  • ✅ 理解ArkUI手势系统的架构与事件模型
  • ✅ 使用 TapGesture 实现单击/双击/多击
  • ✅ 使用 LongPressGesture 实现长按菜单
  • ✅ 使用 PanGesture 实现拖拽跟随
  • ✅ 使用 PinchGesture / RotationGesture 实现缩放旋转
  • ✅ 使用 GestureGroup 处理组合/竞争手势
  • ✅ 实现完整的列表拖拽排序功能
  • ✅ 掌握手势冲突解决与优先级配置

需求分析

功能模块设计

模块 功能描述 技术要点
点击手势 单击/双击/多击检测 TapGesture、count参数
长按手势 长按触发上下文菜单 LongPressGesture、duration参数
拖拽手势 元素跟随手指移动 PanGesture、offset跟踪
缩放手势 双指捏合缩放 PinchGesture、scale值
旋转手势 双指旋转元素 RotationGesture、angle值
组合手势 多手势协同/互斥 GestureGroup、GestureMode
拖拽排序 列表项拖动重排 PanGesture + 动画 + 数据重排

支持的手势类型

手势类 触发条件 典型场景 返回数据
TapGesture 手指快速点击 按钮、卡片点击 count(点击次数)
LongPressGesture 手指按住不松开 长按菜单、预览 duration(按住时长)
PanGesture 手指在屏幕上滑动 拖拽、滑动删除 offsetX/Y(偏移量)
PinchGesture 双指靠近/远离 图片缩放、地图 scale(缩放比例)
RotationGesture 双指旋转 图片旋转、方向盘 angle(旋转角度)
GestureGroup 多手势组合 同时缩放+旋转 各子手势数据

项目中的手势使用场景

场景 页面 手势类型 效果
卡片点击进入详情 Index/首页 TapGesture 路由跳转
FAQ展开/折叠 FAQPage TapGesture 切换展开状态
长按复制文字 Detail页 LongPressGesture 显示复制菜单
下拉刷新 列表页 PanGesture 触发刷新
图片查看器 详情大图 Pinch + Rotation 缩放旋转图片

核心实现

步骤1: TapGesture 点击手势

基础单击
typescript 复制代码
import { TapGesture } from '@kit.ArkUI';

@Component
struct TappableCard {
  @State tapCount: number = 0;

  build() {
    Column() {
      Text(`已点击 ${this.tapCount} 次`)
        .fontSize(16)
        .fontColor('#333333')
    }
    .width('100%')
    .height(100)
    .backgroundColor('#F8F7F2')
    .borderRadius(12)
    .justifyContent(FlexAlign.Center)
    // 绑定点击手势
    .gesture(
      TapGesture()
        .onAction((event: GestureEvent) => {
          this.tapCount++;
          console.info(`点击位置: (${event.offsetX}, ${event.offsetY})`);
        })
    )
  }
}
双击与多击
typescript 复制代码
/** 区分单击和双击 */
.gesture(
  TapGesture({ count: 2 })  // count=2 表示双击
    .onAction((event: GestureEvent) => {
      // 双击逻辑:如放大图片
      console.info('双击 detected');
    })
)

/** 三击:快速连续点击3次 */
TapGesture({ count: 3 })
  .onAction(() => {
    // 三击逻辑:如选中整段文字
  })
TapGesture 关键属性
属性 类型 默认值 说明
count number 1 触发所需的点击次数
fingers number 1 触发所需的手指数量

步骤2: LongPressGesture 长按手势

typescript 复制代码
import { LongPressGesture } from '@kit.ArkUI';

@Component
struct LongPressMenu {
  @State showMenu: boolean = false;
  @State menuX: number = 0;
  @State menuY: number = 0;

  build() {
    Stack({ alignContent: Alignment.TopStart }) {
      // 目标内容区域
      Column() {
        Text('长按我显示菜单')
          .fontSize(16)
      }
      .width('100%')
      .height(150)
      .backgroundColor('#FFFFFF')
      .borderRadius(12)
      .justifyContent(FlexAlign.Center)
      .gesture(
        LongPressGesture({ duration: 500 })  // 按住500ms触发
          .onAction((event: GestureEvent) => {
            // 长按过程中持续回调(可用于进度反馈)
            if (event.repeat) {
              console.info(`长按中... ${Math.floor(event.duration)}ms`);
            }
          })
          .onActionEnd((event: GestureEvent) => {
            // 长按结束时显示菜单
            this.showMenu = true;
            this.menuX = event.offsetX;
            this.menuY = event.offsetY;
          })
      )

      // 长按弹出菜单
      if (this.showMenu) {
        Column({ space: 8 }) {
          this.buildMenuItem('复制', $r('app.media.icon_copy'))
          this.buildMenuItem('分享', $r('app.media.icon_share'))
          this.buildMenuItem('收藏', $r('app.media.icon_star'))
        }
        .padding(12)
        .backgroundColor('#333333')
        .borderRadius(8)
        .position({ x: this.menuX, y: this.menuY })
        .shadow({ radius: 8, color: '#20000000' })
        .zIndex(100)
      }
    }
  }

  @Builder
  buildMenuItem(label: string, icon: Resource): void {
    Row({ space: 8 }) {
      Image(icon).width(18).height(18).fillColor('#FFFFFF')
      Text(label).fontSize(14).fontColor('#FFFFFF')
    }
    .padding({ left: 12, right: 12, top: 8, bottom: 8 })
    .onClick(() => {
      this.showMenu = false;
    })
  }
}
LongPressGesture 关键属性
属性 类型 默认值 说明
duration number 500 触发所需的按住时长(ms)
fingers number 1 触发所需的手指数量
repeat boolean false 是否重复触发(用于振动反馈等)

步骤3: PanGesture 拖拽手势

基础拖拽
typescript 复制代码
import { PanGesture } from '@kit.ArkUI';

@Component
struct DraggableItem {
  /** 当前偏移量 */
  @State translateX: number = 0;
  @State translateY: number = 0;

  build() {
    Column() {
      Text('拖拽我')
        .fontSize(16)
        .fontColor('#FFFFFF')
    }
    .width(120)
    .height(120)
    .backgroundColor('#4A9B6D')
    .borderRadius(16)
    .justifyContent(FlexAlign.Center)
    // 应用偏移量
    .translate({ x: this.translateX, y: this.translateY })
    // 绑定拖拽手势
    .gesture(
      PanGesture()
        .onActionUpdate((event: GestureEvent) => {
          // 持续更新位置,实现跟手效果
          this.translateX = event.offsetX;
          this.translateY = event.offsetY;
        })
        .onActionEnd((event: GestureEvent) => {
          // 拖拽结束时的处理
          // 可选:吸附到目标位置 or 回弹到原位
          const velocity = Math.abs(event.velocityX) + Math.abs(event.velocityY);
          if (velocity > 500) {
            // 快速甩出 → 吸附
            console.info('快速滑动,执行吸附');
          } else {
            // 慢速释放 → 回弹
            animateTo({ duration: 300, curve: 'easeOut' }, () => {
              this.translateX = 0;
              this.translateY = 0;
            });
          }
        })
    )
  }
}
PanGesture 关键属性与返回值
属性 说明
direction 限制拖拽方向(Horizontal/Vertical/All/None)
distance 最小拖拽距离才触发(默认5vp)
offsetX/Y 累计偏移量(相对于起始点)
velocityX/Y 当前速度(像素/秒),用于判断"甩出"动作
方向限制示例
typescript 复制代码
// 仅允许水平拖拽(适用于滑动删除)
PanGesture({ direction: PanDirection.Horizontal })

// 仅允许垂直拖拽(适用于下拉刷新)
PanGesture({ direction: PanDirection.Vertical })

// 自定义触发距离
PanGesture({ distance: 10 })  // 至少移动10vp才开始响应

步骤4: PinchGesture 与 RotationGesture

双指缩放
typescript 复制代码
import { PinchGesture } from '@kit.ArkUI';

@Component
struct ScalableImage {
  @State currentScale: number = 1.0;
  private minScale: number = 0.5;
  private maxScale: number = 3.0;

  build() {
    Image($r('app.media.photo_detail'))
      .width(300 * this.currentScale)
      .height(200 * this.currentScale)
      .gesture(
        PinchGesture({ fingers: 2 })
          .onActionUpdate((event: GestureEvent) => {
            // 计算新的缩放比例,并限制范围
            const newScale = this.currentScale * event.scale;
            this.currentScale = clamp(newScale, this.minScale, this.maxScale);
          })
          .onActionEnd(() => {
            // 双击回弹到边界值
            if (this.currentScale < 0.8) {
              animateTo({ duration: 200 }, () => {
                this.currentScale = 1.0;
              });
            }
          })
      )
  }

  /** 数值限制工具函数 */
  clamp(value: number, min: number, max: number): number {
    return Math.max(min, Math.min(max, value));
  }
}
双指旋转
typescript 复制代码
import { RotationGesture } from '@kit.ArkUI';

@Component
struct RotatableElement {
  @State rotationAngle: number = 0;

  build() {
    Column() {
      Text('旋转我')
        .fontSize(16)
        .fontColor('#FFFFFF')
    }
    .width(120)
    .height(120)
    .backgroundColor('#E07B53')
    .borderRadius(60)  // 圆形
    .justifyContent(FlexAlign.Center)
    .rotate({ angle: this.rotationAngle })
    .gesture(
      RotationGesture({ fingers: 2 })
        .onActionUpdate((event: GestureEvent) => {
          this.rotationAngle += event.angle;  // 累加旋转角度
        })
    )
  }
}

步骤5: GestureGroup 组合手势

当需要同时响应多种手势时(如图片同时支持缩放和旋转),使用 GestureGroup 进行组合。

并行组合(Simultaneous)
typescript 复制代码
import { GestureGroup, GestureMode } from '@kit.ArkUI';

@Component
struct PhotoViewer {
  @State scaleValue: number = 1.0;
  @State rotateAngle: number = 0;
  @State offsetX: number = 0;
  @State offsetY: number = 0;

  build() {
    Image($r('app.media.photo_large'))
      .width(300 * this.scaleValue)
      .height(400 * this.scaleValue)
      .rotate({ angle: this.rotateAngle })
      .translate({ x: this.offsetX, y: this.offsetY })
      .gesture(
        // 组合手势:所有子手势同时生效
        GestureGroup(GestureMode.Exclusive,
          // 双指缩放
          PinchGesture({ fingers: 2 })
            .onActionUpdate((event: GestureEvent) => {
              this.scaleValue = clamp(this.scaleValue * event.scale, 0.5, 4.0);
            }),
          // 双指旋转
          RotationGesture({ fingers: 2 })
            .onActionUpdate((event: GestureEvent) => {
              this.rotateAngle += event.angle;
            }),
          // 单指平移(拖拽移动图片位置)
          PanGesture()
            .onActionUpdate((event: GestureEvent) => {
              this.offsetX += event.offsetX - (this._lastOffsetX || 0);
              this.offsetY += event.offsetY - (this._lastOffsetY || 0);
              this._lastOffsetX = event.offsetX;
              this._lastOffsetY = event.offsetY;
            })
            .onActionEnd(() => {
              this._lastOffsetX = 0;
              this._lastOffsetY = 0;
            })
        )
      )
  }

  private _lastOffsetX: number = 0;
  private _lastOffsetY: number = 0;

  clamp(value: number, min: number, max: number): number {
    return Math.max(min, Math.min(max, value));
  }
}
GestureMode 对比
模式 行为 适用场景
Exclusive 第一个识别到的手势生效,其他忽略 点击 vs 长按
Parallel 所有子手势同时生效 缩放 + 旋转同时进行
Sequence 按顺序依次识别 先长按再拖拽(重新排列)
竞争手势示例(点击 vs 长按)
typescript 复制代码
// 点击和长按是典型的竞争关系:
// 用户按下后不知道是要点击还是长按
// 使用 Exclusive 模式,系统会自动判断

.gesture(
  GestureGroup(GestureMode.Exclusive,
    // 点击:快速抬起时触发
    TapGesture()
      .onAction(() => {
        console.info('单击');
      }),
    // 长按:按住超过阈值时触发
    LongPressGesture({ duration: 500 })
      .onActionEnd(() => {
        console.info('长按');
      })
  )
)

系统判断流程

复制代码
手指按下
   │
   ├── < 500ms 抬起 ──→ TapGesture 胜出(单击)
   │
   ├── ≥ 500ms 未抬 ──→ LongPressGesture 胜出(长按中)
   │
   └── ≥ 500ms 后抬起 ──→ LongPressGesture 胜出(长按完成)

步骤6: 完整实现 ------ 列表拖拽排序

这是手势识别的综合实战案例,结合了 PanGesture、动画、状态管理。

typescript 复制代码
// components/DragSortList.ets

import { PanGesture, PanDirection } from '@kit.ArkUI';

interface SortableItem {
  id: string;
  title: string;
}

@Component
export struct DragSortList {
  /** 列表数据源 */
  @State items: SortableItem[] = [
    { id: '1', title: '立春 · 万物复苏' },
    { id: '2', title: '雨水 · 润物无声' },
    { id: '3', title: '惊蛰 · 春雷乍动' },
    { id: '4', title: '春分 · 昼夜平分' },
    { id: '5', title: '清明 · 气清景明' },
    { id: '6', title: '谷雨 · 雨生百谷' },
  ];

  /** 正在拖拽的项索引,-1表示未拖拽 */
  @State dragIndex: number = -1;
  /** 拖拽项的Y偏移量 */
  @State dragOffsetY: number = 0;
  /** 占位项高度(拖拽时空出位置) */
  @State placeholderHeight: number = 0;

  /** 每项高度 */
  private itemHeight: number = 72;

  build() {
    Column() {
      Text('节气列表(长按拖拽排序)')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .alignSelf(ItemAlign.Start)
        .margin({ bottom: 12 })

      // 列表容器
      Column() {
        ForEach(this.items, (item: SortableItem, index: number) => {
          // 正在拖拽的项:悬浮在其他项之上
          if (index === this.dragIndex) {
            // 拖拽中的项
            this.buildDragItem(item, index)
          } else {
            // 普通项
            this.buildNormalItem(item, index)
          }
        }, (item: SortableItem) => item.id)
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
    .padding(16)
  }

  /** 构建普通列表项 */
  @Builder
  buildNormalItem(item: SortableItem, index: number): void {
    Row() {
      Text(`${index + 1}`)
        .width(28)
        .height(28)
        .fontSize(13)
        .fontColor('#999999')
        .textAlign(TextAlign.Center)
        .borderRadius(14)
        .backgroundColor('#F0F0F0')

      Text(item.title)
        .fontSize(15)
        .fontColor('#333333')
        .margin({ left: 12 })
        .layoutWeight(1)

      Text('☰')
        .fontSize(18)
        .fontColor('#CCCCCC')
    }
    .width('100%')
    .height(this.itemHeight)
    .padding({ left: 16, right: 16 })
    .backgroundColor('#FFFFFF')
    .borderRadius(10)
    // 绑定长按开始拖拽的手势
    .gesture(
      LongPressGesture({ duration: 300 })
        .onActionEnd(() => {
          this.startDrag(index);
        })
    )
    // 进入/退出动画
    .animation({ duration: 250, curve: 'easeOut' })
  }

  /** 构建拖拽中的项 */
  @Builder
  buildDragItem(item: SortableItem, index: number): void {
    Row() {
      Text(`${index + 1}`)
        .width(28)
        .height(28)
        .fontSize(13)
        .fontColor('#4A9B6D')
        .textAlign(TextAlign.Center)
        .borderRadius(14)
        .backgroundColor('#E8F5EC')

      Text(item.title)
        .fontSize(15)
        .fontColor('#333333')
        .fontWeight(FontWeight.Medium)
        .margin({ left: 12 })
        .layoutWeight(1)

      Text('✥')
        .fontSize(18)
        .fontColor('#4A9B6D')
    }
    .width('100%')
    .height(this.itemHeight)
    .padding({ left: 16, right: 16 })
    .backgroundColor('#FFFFFF')
    .borderRadius(10)
    // 阴影效果表示悬浮
    .shadow({
      radius: 12,
      color: '#30000000',
      offsetX: 0,
      offsetY: 4
    })
    // 应用拖拽偏移
    .translate({ y: this.dragOffsetY })
    // 提高层级,确保在最上层
    .zIndex(100)
    .scale({ x: 1.03, y: 1.03 })  // 微放大强调
    // 绑定拖拽手势
    .gesture(
      PanGesture({ direction: PanDirection.Vertical })
        .onActionUpdate((event: GestureEvent) => {
          this.dragOffsetY = event.offsetY;
          this.handleDragOver(index, event.offsetY);
        })
        .onActionEnd(() => {
          this.endDrag();
        })
    )
  }

  /** 开始拖拽 */
  startDrag(index: number): void {
    this.dragIndex = index;
    this.dragOffsetY = 0;
  }

  /** 处理拖拽经过其他项时的位置交换 */
  handleDragOver(dragIdx: number, offsetY: number): void {
    // 根据偏移量计算应该插入的位置
    const targetIndex = dragIdx + Math.round(offsetY / this.itemHeight);

    // 边界检查
    if (targetIndex < 0 || targetIndex >= this.items.length ||
        targetIndex === dragIdx) {
      return;
    }

    // 交换数组中的位置
    const draggedItem = this.items[dragIdx];
    const itemsCopy = [...this.items];
    itemsCopy.splice(dragIdx, 1);       // 移除原位置
    itemsCopy.splice(targetIndex, 0, draggedItem);  // 插入新位置
    this.items = itemsCopy;
    this.dragIndex = targetIndex;
    this.dragOffsetY = 0;  // 重置偏移(因为数组已重排)
  }

  /** 结束拖拽 */
  endDrag(): void {
    // 回弹动画
    animateTo({ duration: 250, curve: 'easeOut' }, () => {
      this.dragOffsetY = 0;
    });

    // 延迟重置拖拽状态
    setTimeout(() => {
      this.dragIndex = -1;
    }, 260);
  }
}
拖拽排序核心原理
复制代码
┌─────────────────────────────────────┐
│  1. 立春 · 万物复苏                │  ← 普通项
│  2. 雨水 · 润物无声                │  ← 普通项
│  3. [惊蛰 · 春雷乍动] ★            │  ← 拖拽项(阴影+放大+zIndex提升)
│         ↓ 正在下移                  │     translateY = offsetY
│  4. 春分 · 昼夜平分                │  ← 普通项
│  5. 清明 · 气清景明                │  ← 普通项
└─────────────────────────────────────┘

拖拽过程:
  长按第3项(300ms) → dragIndex=3, 项变为悬浮样式
  ↓ 手指下移
  PanGesture.onActionUpdate → offsetY 增加
  ↓ 当 offsetY > itemHeight 时
  handleDragOver → 交换 items[3] 和 items[4]
  → dragIndex 变为 4, offsetY 归零
  ↓ 手指松开
  onActionEnd → 回弹动画, dragIndex = -1

架构总览

复制代码
┌──────────────────────────────────────────────────┐
│                  手势系统架构                      │
│                                                   │
│  ┌─────────┐  ┌──────────┐  ┌─────────────────┐  │
│  │TapGest- │  │LongPress │  │  PanGesture     │  │
│  │ure      │  │Gesture   │  │  (拖拽/滑动)    │  │
│  └────┬────┘  └────┬─────┘  └───────┬─────────┘  │
│       │             │                 │            │
│  ┌────┴─────────────┴─────────────────┴────────┐  │
│  │           GestureGroup (组合层)               │  │
│  │  Exclusive / Parallel / Sequence             │  │
│  └────────────────────┬────────────────────────┘  │
│                       │                            │
│  ┌────────────────────┴────────────────────────┐  │
│  │           .gesture() 绑定到组件               │  │
│  └─────────────────────────────────────────────┘  │
│                                                   │
│  ┌─────────────────────────────────────────────┐  │
│  │  PinchGesture    RotationGesture              │  │
│  │  (双指缩放)      (双指旋转)                   │  │
│  └─────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────┘

事件生命周期

复制代码
手势开始 (onActionStart)
    │
    ├─→ 持续更新 (onActionUpdate) ←── 可能多次调用
    │       │
    │       ├─→ offset/scale/angle 变化
    │       └─→ 更新 UI (@State 变量)
    │
    └─→ 结束 (onActionEnd)
            │
            ├─→ velocity 信息可用
            ├─→ 执行收尾动画
            └─→ 重置临时状态

关键注意事项

1. gesture() 和 parallelGesutre() 的区别

方法 特点 手势关系
.gesture() 外部手势优先,会拦截组件内部手势 竞争关系
.parallelGesture() 与组件内部手势并行,互不影响 并行关系
typescript 复制代码
// 场景:列表项既支持整体点击,又支持内部按钮点击
Row() {
  Button('操作').onClick(() => { ... })  // 内部有点击
}
.gesture(TapGesture().onAction(() => {
  // 整体行点击 ------ 如果用 gesture 会拦截内部按钮
}))
// 解决方案:使用 parallelGesture 让两者并存
.parallelGesture(TapGesture().onAction(() => {
  // 整体行点击 ------ 不影响内部按钮
}))

2. PanGesture 的 offset 是累计值

offsetX/Y 是从手势开始位置当前位置 的累计偏移,不是增量。每次 onActionUpdate 都拿到的是绝对偏移。

typescript 复制代码
// ❌ 错误理解:以为每次是增量
.onActionUpdate((event) => {
  this.x += event.offsetX;  // 这样会翻倍!
})

// ✅ 正确:offset 本身就是绝对值
.onActionUpdate((event) => {
  this.x = event.offsetX;  // 直接赋值
})

3. 手势与 TouchEvent 不要混用

.onTouch().gesture() 是两套独立的事件系统。同一组件上同时使用可能导致事件重复或冲突。建议统一使用手势 API。

4. 拖拽排序的性能优化

对于长列表(50+项),拖拽时频繁修改数组会导致性能问题。优化方案:

typescript 复制代码
// 方案:仅视觉重排,拖拽结束后才真正修改数据
@State displayOrder: number[] = [];  // 视觉顺序映射
// 拖拽过程中只修改 displayOrder
// endDrag 时才同步到 items

5. 手势区域要足够大

根据人机工程学,触控目标的最小尺寸为 48x48dp。过小的手势区域会导致用户难以准确触发。


最佳实践清单

在实现手势功能时,请逐项检查:

  • 点击目标区域 ≥ 48x48dp
  • 长按时长设置合理(300-500ms)
  • PanGesture 设置了正确的 direction 限制
  • 拖拽结束时有明确的反馈(吸附/回弹)
  • 组合手势选择了正确的 GestureMode
  • 竞选手势(如点击vs长按)使用 Exclusive 模式
  • 缩放设置了 min/max 边界
  • 手势回调中没有耗时操作(保持UI流畅)
  • 列表拖拽使用了 zIndex 提升层级
  • 无障碍:重要手势提供了替代操作方式(如按钮)

相关链接