
引言
手势识别(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 提升层级
- 无障碍:重要手势提供了替代操作方式(如按钮)