HarmonyOS应用《民族图鉴》开发第49篇:手势处理与交互——点击/长按/滑动/捏合手势深度解析

📖 引言
如果说 UI 是应用的"脸",那手势就是用户和应用"握手"的方式。
在触屏时代,用户不再通过鼠标键盘操作,而是用手指直接在屏幕上点、滑、捏、转。手势操作的体验好不好,直接决定了用户对应用的第一印象——点了没反应、滑了不跟手、误触频繁,这些都会让用户觉得"这个 App 很烂"。
但很多开发者对手势的理解,停留在"加个 onClick 就行"的层面。结果就是:
- 列表项的点击和滑动冲突,不知道该滚还是该点
- 想做个侧滑删除,结果滑着滑着就触发了点击
- 图片缩放时,捏合和旋转打架,体验很差
- 下拉刷新和列表滚动冲突,经常误触发
ArkUI 提供了一套完整的手势系统:TapGesture、LongPressGesture、PanGesture、PinchGesture、RotationGesture……还有手势优先级、手势组、手势绑定等高级能力。但很多人只会用最简单的 onClick,其他的都不太清楚。
本文我们就从手势的本质讲起,深入到 ArkUI 的手势系统,结合「民族图鉴」项目的实际场景(可拖拽卡片、侧滑删除、下拉刷新、图片缩放等),带你系统性地掌握鸿蒙手势开发。
🎯 学习目标
完成本文后,你将能够:
- ✅ 理解手势的本质与分类体系
- ✅ 掌握五大基础手势:点击、长按、滑动、捏合、旋转
- ✅ 理解手势事件的三个阶段:Start / Update / End
- ✅ 掌握手势优先级与冲突处理策略
- ✅ 学会手势绑定(bindingGesture)与手势组(GestureGroup)
- ✅ 实现可拖拽卡片、侧滑删除、下拉刷新等高级交互
- ✅ 掌握手势+动画的高级组合技巧
- ✅ 避开手势开发的常见坑:冲突、不跟手、误触
💡 需求分析
为什么需要手势系统?
在触屏设备上,用户的每一次触摸,都是一串"原始事件流"——手指按下、移动、抬起。系统需要把这些原始事件"翻译"成用户能理解的操作:这是一次点击?还是一次滑动?还是一次捏合?
这就是手势系统要做的事:识别用户的触摸意图,把原始事件转化为有意义的手势。
手势的分类
ArkUI 中的手势可以分为几类:
| 类别 | 手势 | 典型场景 |
|---|---|---|
| 点击类 | TapGesture | 按钮点击、卡片点击 |
| 长按类 | LongPressGesture | 长按弹出菜单、拖拽排序 |
| 滑动类 | PanGesture | 页面滚动、侧滑删除、拖拽 |
| 捏合类 | PinchGesture | 图片缩放、地图缩放 |
| 旋转类 | RotationGesture | 图片旋转、罗盘旋转 |
| 组合类 | GestureGroup | 同时支持缩放+旋转 |
「民族图鉴」的手势场景
「民族图鉴」项目中有很多典型的手势场景:
| 场景 | 手势类型 | 技术方案 |
|---|---|---|
| 卡片点击跳转 | TapGesture | onClick / TapGesture |
| 收藏长按弹出菜单 | LongPressGesture | LongPressGesture |
| 可拖拽民族卡片 | PanGesture | PanGesture + 动画 |
| 侧滑删除收藏 | PanGesture | 横向 Pan + 滑动阈值 |
| 下拉刷新列表 | PanGesture | 纵向 Pan + 阈值判断 |
| 图片查看缩放 | PinchGesture | PinchGesture + 缩放 |
| 图片旋转 | RotationGesture | RotationGesture + 旋转 |
| 图片缩放+旋转 | GestureGroup | Pinch + Rotation 并行 |
🛠️ 核心实现
步骤1:手势的核心概念
在讲具体的 API 之前,先搞懂几个核心概念。
1.1 手势的本质
手势的本质是什么?
手势 = 对原始触摸事件的模式识别
用户手指在屏幕上操作,产生一连串的 TouchEvent(Down、Move、Up)。手势系统根据这些事件的特征(位置、时间、速度、方向、手指数量等),判断用户想做什么操作。
比如:
- 按下很快抬起 → 点击
- 按下不松手 → 长按
- 按下后移动一段距离 → 滑动
- 两个手指同时靠近/远离 → 捏合
1.2 手势的三个阶段
所有手势都有三个阶段,对应三个回调:
| 阶段 | 回调 | 触发时机 |
|---|---|---|
| 开始 | onActionStart | 手势识别成功,开始执行 |
| 更新 | onActionUpdate | 手势过程中,持续触发 |
| 结束 | onActionEnd | 手势正常结束 |
| 取消 | onActionCancel | 手势被打断(比如来电、切后台) |
.gesture(
PanGesture()
.onActionStart((event: GestureEvent) => {
// 滑动开始
console.info('Pan start');
})
.onActionUpdate((event: GestureEvent) => {
// 滑动中,event.offsetX / event.offsetY 是位移
console.info('Pan update: ' + event.offsetX);
})
.onActionEnd(() => {
// 滑动结束
console.info('Pan end');
})
.onActionCancel(() => {
// 滑动取消
console.info('Pan cancel');
})
)
1.3 手势的识别流程
手势识别是一个"竞争"的过程。当用户触摸屏幕时,所有绑定了手势的组件都会开始"竞争"——看谁先识别出手势。
比如,一个列表项,既有点击手势,又有滑动手势。用户按下手指时:
- 两个手势都开始检测
- 如果用户很快抬起 → 点击手势识别成功,滑动手势失败
- 如果用户移动了足够距离 → 滑动手势识别成功,点击手势失败
这个"竞争机制"是手势系统的核心,也是手势冲突的根源。
步骤2:基础手势详解
2.1 TapGesture(点击手势)
TapGesture 是最基础的手势,用于识别点击操作。
基本用法:
Image($r('app.media.logo'))
.width(100)
.height(100)
.gesture(
TapGesture({ count: 1 })
.onAction(() => {
console.info('点击了');
})
)
参数说明:
count:点击次数,默认 1。设为 2 就是双击。finger:手指数量,默认 1。
onClick vs TapGesture:
你可能会问,既然有 onClick,为什么还要 TapGesture?
| 维度 | onClick | TapGesture |
|---|---|---|
| 用法 | 简单直接 | 配置灵活 |
| 双击 | 不支持 | 支持(count: 2) |
| 多指 | 不支持 | 支持(finger: 2) |
| 优先级控制 | 不能 | 可以(gesture/priorityGesture) |
| 和其他手势组合 | 不能 | 可以(GestureGroup) |
结论:简单点击用 onClick,复杂场景用 TapGesture。
实战:民族卡片双击点赞
「民族图鉴」的详情页,双击封面图可以快速点赞:
@Component
struct DetailCoverImage {
@State isLiked: boolean = false;
@State likeScale: number = 1;
private coverImage: string = '';
build() {
Stack({ alignContent: Alignment.Center }) {
Image($rawfile(this.coverImage))
.width('100%')
.height(240)
.objectFit(ImageFit.Cover)
// 点赞心形动画
if (this.isLiked) {
Text('\u2764\u{FE0F}')
.fontSize(80)
.scale({ x: this.likeScale, y: this.likeScale })
.opacity(this.likeScale > 0.5 ? 1 : 0)
.animation({ duration: 400, curve: Curve.EaseOut })
}
}
.gesture(
TapGesture({ count: 2 })
.onAction(() => {
this.handleDoubleTap();
})
)
}
private handleDoubleTap(): void {
this.isLiked = true;
this.likeScale = 0.3;
animateTo({ duration: 400, curve: Curve.EaseOut }, () => {
this.likeScale = 1.2;
});
animateTo({ duration: 300, curve: Curve.EaseIn, delay: 400 }, () => {
this.likeScale = 0;
});
// 400ms 后隐藏
setTimeout(() => {
this.isLiked = false;
}, 700);
}
}
2.2 LongPressGesture(长按手势)
LongPressGesture 用于识别长按操作。
基本用法:
Image($r('app.media.logo'))
.width(100)
.height(100)
.gesture(
LongPressGesture({ duration: 500 })
.onActionStart(() => {
console.info('长按开始');
})
.onActionEnd(() => {
console.info('长按结束');
})
)
参数说明:
duration:长按时长(毫秒),默认 500msfinger:手指数量,默认 1repeat:是否重复触发,默认 false
实战:民族卡片长按弹出菜单
「民族图鉴」的收藏页,长按民族卡片可以弹出操作菜单:
@Component
struct CollectibleEthnicCard {
@State isPressed: boolean = false;
@State showMenu: boolean = false;
@Prop ethnic: EthnicGroup;
@Prop onDelete?: (id: string) => void;
@Prop onClick?: (ethnic: EthnicGroup) => void;
build() {
Stack({ alignContent: Alignment.TopEnd }) {
Column({ space: 8 }) {
Text(this.ethnic.name.charAt(0))
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.width(44)
.height(44)
.borderRadius(22)
.backgroundColor(this.ethnic.emblemColor)
.textAlign(TextAlign.Center)
Text(this.ethnic.name)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.padding(12)
.backgroundColor($r('app.color.card_background'))
.borderRadius(12)
.scale({ x: this.isPressed ? 0.95 : 1, y: this.isPressed ? 0.95 : 1 })
.animation({ duration: 150, curve: Curve.EaseInOut })
.onClick(() => {
if (this.onClick) {
this.onClick(this.ethnic);
}
})
.gesture(
LongPressGesture({ duration: 500 })
.onActionStart(() => {
this.isPressed = true;
// 震动反馈
vibrator.startVibration({ type: 'time', duration: 30 });
})
.onActionEnd(() => {
this.isPressed = false;
this.showMenu = true;
})
)
// 操作菜单
if (this.showMenu) {
this.buildMenu()
}
}
}
@Builder
buildMenu() {
Column({ space: 0 }) {
Text('删除收藏')
.fontSize(14)
.fontColor('#FF4D4F')
.width('100%')
.height(44)
.textAlign(TextAlign.Center)
.onClick(() => {
this.showMenu = false;
if (this.onDelete) {
this.onDelete(this.ethnic.id);
}
})
Divider()
Text('取消')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.height(44)
.textAlign(TextAlign.Center)
.onClick(() => {
this.showMenu = false;
})
}
.width(120)
.backgroundColor($r('app.color.card_background'))
.borderRadius(8)
.shadow({ radius: 8, color: '#22000000', offsetY: 2 })
.margin({ top: 8, right: 8 })
.transition({ type: TransitionType.All, opacity: 0, scale: { x: 0.9, y: 0.9 } })
.animation({ duration: 200, curve: Curve.EaseOut })
}
}
2.3 PanGesture(滑动手势)
PanGesture 是最常用的复杂手势,用于识别滑动/拖拽操作。
基本用法:
@State offsetX: number = 0;
@State offsetY: number = 0;
Image($r('app.media.logo'))
.width(100)
.height(100)
.translate({ x: this.offsetX, y: this.offsetY })
.gesture(
PanGesture({ direction: PanDirection.All })
.onActionUpdate((event: GestureEvent) => {
this.offsetX += event.offsetX;
this.offsetY += event.offsetY;
})
)
参数说明:
direction:滑动方向,默认 All。可以设为 Horizontal、Vertical、Left、Right、Up、Down 等distance:触发滑动的最小距离(vp),默认 5finger:手指数量,默认 1
方向过滤很重要:
如果只需要横向滑动,一定要指定 direction: PanDirection.Horizontal。这样可以避免和纵向滚动冲突。
// ✅ 好:只响应横向滑动
PanGesture({ direction: PanDirection.Horizontal })
// ❌ 不好:所有方向都响应,容易和滚动冲突
PanGesture()
实战:可拖拽的民族卡片
「民族图鉴」首页有一个"民族卡片"可以拖拽,松手后弹回原位:
@Component
struct DraggableEthnicCard {
@State offsetX: number = 0;
@State offsetY: number = 0;
@State isDragging: boolean = false;
@State rotateAngle: number = 0;
@Prop ethnic: EthnicGroup;
@Prop onSwipeLeft?: (id: string) => void;
@Prop onSwipeRight?: (id: string) => void;
private readonly SWIPE_THRESHOLD: number = 120;
build() {
Column({ space: 12 }) {
Text(this.ethnic.name.charAt(0))
.fontSize(48)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.width(120)
.height(120)
.borderRadius(60)
.backgroundColor(this.ethnic.emblemColor)
.textAlign(TextAlign.Center)
Text(this.ethnic.name)
.fontSize(18)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text(this.ethnic.region)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
}
.width(200)
.padding(20)
.backgroundColor($r('app.color.card_background'))
.borderRadius(16)
.shadow({
radius: this.isDragging ? 16 : 8,
color: '#22000000',
offsetY: this.isDragging ? 8 : 4
})
// 位置和旋转
.translate({ x: this.offsetX, y: this.offsetY })
.rotate({ angle: this.rotateAngle })
.scale({ x: this.isDragging ? 1.05 : 1, y: this.isDragging ? 1.05 : 1 })
.animation({
duration: this.isDragging ? 0 : 300,
curve: Curve.EaseOut
})
.gesture(
PanGesture({ direction: PanDirection.Horizontal })
.onActionStart(() => {
this.isDragging = true;
})
.onActionUpdate((event: GestureEvent) => {
this.offsetX += event.offsetX;
// 根据水平位移计算旋转角度,越偏越斜
this.rotateAngle = this.offsetX / 20;
})
.onActionEnd(() => {
this.isDragging = false;
this.handleSwipeEnd();
})
.onActionCancel(() => {
this.isDragging = false;
this.resetPosition();
})
)
}
private handleSwipeEnd(): void {
if (this.offsetX > this.SWIPE_THRESHOLD) {
// 右滑:喜欢
animateTo({ duration: 300, curve: Curve.EaseIn }, () => {
this.offsetX = 400;
this.rotateAngle = 30;
});
if (this.onSwipeRight) {
this.onSwipeRight(this.ethnic.id);
}
} else if (this.offsetX < -this.SWIPE_THRESHOLD) {
// 左滑:不喜欢
animateTo({ duration: 300, curve: Curve.EaseIn }, () => {
this.offsetX = -400;
this.rotateAngle = -30;
});
if (this.onSwipeLeft) {
this.onSwipeLeft(this.ethnic.id);
}
} else {
// 没滑够,弹回来
this.resetPosition();
}
}
private resetPosition(): void {
animateTo({ duration: 300, curve: Curve.Spring }, () => {
this.offsetX = 0;
this.offsetY = 0;
this.rotateAngle = 0;
});
}
}
关键技巧:
- 拖动时零延迟:isDragging 为 true 时,animation duration 设为 0,完全跟手
- 松手时动画:松开后用 Spring 曲线弹回,有弹性
- 旋转效果:根据水平位移计算旋转角度,更有"甩出去"的感觉
- 阈值判断:滑过一定距离才触发操作,没滑够就弹回
2.4 PinchGesture(捏合手势)
PinchGesture 用于识别双指捏合操作,通常用于缩放。
基本用法:
@State scale: number = 1;
Image($r('app.media.example'))
.width(200)
.height(200)
.scale({ x: this.scale, y: this.scale })
.gesture(
PinchGesture({ fingers: 2 })
.onActionUpdate((event: GestureEvent) => {
// event.pinchValue 是缩放比例,相对于上一次
this.scale *= event.pinchValue;
// 限制缩放范围
this.scale = Math.max(0.5, Math.min(3, this.scale));
})
)
参数说明:
fingers:手指数量,默认 2distance:触发捏合的最小距离(vp),默认 5
实战:民族封面图缩放查看
「民族图鉴」的详情页,点击封面图可以进入大图查看,支持双指缩放:
@Component
struct ImageViewer {
@State scale: number = 1;
@State translateX: number = 0;
@State translateY: number = 0;
@State isShow: boolean = false;
private imageUrl: string = '';
private lastScale: number = 1;
build() {
if (this.isShow) {
Stack({ alignContent: Alignment.Center }) {
// 半透明背景
Column()
.width('100%')
.height('100%')
.backgroundColor('#000000')
.opacity(0.8)
.onClick(() => {
this.close();
})
// 图片
Image($rawfile(this.imageUrl))
.width('90%')
.height('60%')
.objectFit(ImageFit.Contain)
.scale({ x: this.scale, y: this.scale })
.translate({ x: this.translateX, y: this.translateY })
.gesture(
PinchGesture({ fingers: 2 })
.onActionStart(() => {
this.lastScale = this.scale;
})
.onActionUpdate((event: GestureEvent) => {
this.scale = this.lastScale * event.pinchValue;
this.scale = Math.max(0.5, Math.min(4, this.scale));
})
)
.gesture(
PanGesture({ direction: PanDirection.All })
.onActionUpdate((event: GestureEvent) => {
if (this.scale > 1) {
this.translateX += event.offsetX;
this.translateY += event.offsetY;
}
})
)
// 关闭按钮
Text('\u2715')
.fontSize(20)
.fontColor('#FFFFFF')
.width(40)
.height(40)
.borderRadius(20)
.backgroundColor('rgba(255,255,255,0.2)')
.textAlign(TextAlign.Center)
.position({ x: 20, y: 40 })
.onClick(() => {
this.close();
})
}
.width('100%')
.height('100%')
.transition({ type: TransitionType.All, opacity: 0 })
.animation({ duration: 300, curve: Curve.EaseOut })
}
}
show(imageUrl: string): void {
this.imageUrl = imageUrl;
this.scale = 1;
this.translateX = 0;
this.translateY = 0;
this.isShow = true;
}
close(): void {
this.isShow = false;
}
}
2.5 RotationGesture(旋转手势)
RotationGesture 用于识别双指旋转操作。
基本用法:
@State rotateAngle: number = 0;
Image($r('app.media.example'))
.width(200)
.height(200)
.rotate({ angle: this.rotateAngle })
.gesture(
RotationGesture({ fingers: 2 })
.onActionUpdate((event: GestureEvent) => {
// event.angle 是旋转角度(度)
this.rotateAngle += event.angle;
})
)
参数说明:
fingers:手指数量,默认 2angle:触发旋转的最小角度(度),默认 5
步骤3:手势优先级与冲突处理
手势冲突是手势开发中最常见、最头疼的问题。比如:
- 列表项既有点击又有滑动,到底响应哪个?
- 父组件有滑动手势,子组件也有滑动手势,谁先响应?
ArkUI 提供了一套完整的手势优先级机制来解决这些问题。
3.1 三种手势绑定方式
ArkUI 有三种给组件绑定手势的方式,优先级不同:
| 方式 | 优先级 | 说明 |
|---|---|---|
gesture() |
普通优先级 | 默认方式,和子组件手势竞争 |
priorityGesture() |
高优先级 | 比子组件的 gesture 优先识别 |
parallelGesture() |
并行优先级 | 和子组件手势同时识别,不冲突 |
示意图:
父组件(priorityGesture) ← 最高优先级
↑
父组件(gesture) ← 和子组件竞争
↑
子组件(gesture) ← 最低优先级
3.2 gesture(普通手势)
默认的绑定方式。父子组件都有 gesture 时,谁先识别出来谁响应。
// 父组件
Column() {
// 子组件
Text('点击我')
.gesture(
TapGesture()
.onAction(() => {
console.info('子组件点击');
})
)
}
.gesture(
TapGesture()
.onAction(() => {
console.info('父组件点击');
})
)
这种情况下,点击 Text 时,子组件的 TapGesture 会先识别,所以只会触发"子组件点击"。
3.3 priorityGesture(优先手势)
如果想让父组件的手势优先响应,用 priorityGesture:
Column() {
Text('点击我')
.gesture(
TapGesture()
.onAction(() => {
console.info('子组件点击'); // 不会触发!
})
)
}
.priorityGesture(
TapGesture()
.onAction(() => {
console.info('父组件点击'); // 优先触发
})
)
这种情况下,点击 Text 时,父组件的 priorityGesture 优先识别,子组件的手势不会触发。
典型应用场景:
- 父容器需要拦截子组件的点击(比如整个卡片可点击,卡片里的按钮也可点击——不对,这个应该用冒泡)
- 下拉刷新:列表父组件的下拉手势优先于列表本身的滚动手势
3.4 parallelGesture(并动手势)
如果想让父子组件的手势同时响应,用 parallelGesture:
Column() {
Text('点击我')
.gesture(
TapGesture()
.onAction(() => {
console.info('子组件点击'); // 会触发
})
)
}
.parallelGesture(
TapGesture()
.onAction(() => {
console.info('父组件点击'); // 也会触发
})
)
这种情况下,点击 Text 时,两个手势都会触发。
典型应用场景:
- 按钮点击,父容器也需要响应点击(比如埋点统计)
- 多个手势需要同时生效(比如缩放+旋转)
3.5 手势冲突解决策略
遇到手势冲突时,怎么选择用哪种方式?
| 场景 | 推荐方案 |
|---|---|
| 只有一个手势该响应,父组件优先 | priorityGesture |
| 只有一个手势该响应,子组件优先 | gesture(默认) |
| 两个手势都要响应 | parallelGesture |
| 同方向滑动冲突 | 用 direction 限制方向 |
| 点击和滑动冲突 | 系统自动处理,滑动距离够就是滑动,否则是点击 |
实战:下拉刷新与列表滚动的冲突
下拉刷新是最经典的手势冲突场景。列表本身可以滚动,下拉又要触发刷新,怎么处理?
@State refreshOffset: number = 0;
@State isRefreshing: boolean = false;
Column() {
// 下拉刷新指示器
if (this.refreshOffset > 0 || this.isRefreshing) {
this.buildRefreshHeader()
}
List() {
// ...列表内容
}
.layoutWeight(1)
// 列表在顶部时,下拉用 priorityGesture 拦截
.onScrollIndex((first: number) => {
this.isAtTop = first === 0;
})
}
.gesture(
PanGesture({ direction: PanDirection.Vertical })
.onActionUpdate((event: GestureEvent) => {
if (this.isAtTop && event.offsetY > 0 && !this.isRefreshing) {
this.refreshOffset += event.offsetY;
// 阻尼效果:越往下拉越难拉
this.refreshOffset = Math.min(this.refreshOffset, 120);
}
})
.onActionEnd(() => {
if (this.refreshOffset > 80) {
// 拉够了,触发刷新
this.triggerRefresh();
} else {
// 没拉够,收回去
animateTo({ duration: 300, curve: Curve.EaseOut }, () => {
this.refreshOffset = 0;
});
}
})
)
关键思路:
- 只有当列表在顶部(isAtTop)且是下拉(offsetY > 0)时,才处理下拉手势
- 用阻尼效果:越往下拉,位移增加越慢,手感更好
- 松手时判断是否超过阈值,超过就触发刷新,否则收回
步骤4:手势组(GestureGroup)
有时候需要多个手势组合使用,比如图片查看时,既要支持缩放,又要支持旋转,还要支持拖动。这时候就需要 GestureGroup。
4.1 GestureGroup 的三种模式
GestureGroup 有三种模式:
| 模式 | 说明 | 典型场景 |
|---|---|---|
GestureMode.Sequence |
串行:一个识别成功后,下一个才开始 | 先长按再拖动 |
GestureMode.Parallel |
并行:同时识别,互不影响 | 缩放+旋转 |
GestureMode.Exclusive |
互斥:只有一个能成功 | 点击和长按 |
4.2 并行模式(Parallel)
最常用的模式,多个手势同时生效。
@State scale: number = 1;
@State rotateAngle: number = 0;
Image($r('app.media.example'))
.width(200)
.height(200)
.scale({ x: this.scale, y: this.scale })
.rotate({ angle: this.rotateAngle })
.gesture(
GestureGroup(GestureMode.Parallel,
PinchGesture()
.onActionUpdate((event: GestureEvent) => {
this.scale *= event.pinchValue;
}),
RotationGesture()
.onActionUpdate((event: GestureEvent) => {
this.rotateAngle += event.angle;
})
)
)
这样,双指操作时,缩放和旋转可以同时进行。
4.3 串行模式(Sequence)
一个手势识别成功后,下一个才开始检测。
// 先长按,再拖动
GestureGroup(GestureMode.Sequence,
LongPressGesture({ duration: 500 }),
PanGesture()
.onActionUpdate((event: GestureEvent) => {
// 长按成功后才能拖动
this.offsetX += event.offsetX;
})
)
典型场景:拖拽排序——先长按选中,再拖动排序。
4.4 互斥模式(Exclusive)
多个手势互斥,只有一个能成功。谁先识别出来谁赢。
// 点击和长按互斥
GestureGroup(GestureMode.Exclusive,
TapGesture()
.onAction(() => {
console.info('点击');
}),
LongPressGesture({ duration: 500 })
.onAction(() => {
console.info('长按');
})
)
实际上,onClick 和长按默认就是互斥的,不用特意写 GestureGroup。但如果你需要自定义互斥逻辑,可以用这个。
步骤5:实战:侧滑删除收藏
让我们用一个完整的实战案例,把上面讲的知识串起来——侧滑删除收藏项。
这是「民族图鉴」收藏页的一个功能:收藏项向左滑,露出"删除"按钮,点击删除。
@Component
struct SwipeableFavoriteItem {
@State offsetX: number = 0;
@State isSwiping: boolean = false;
@State isDeleted: boolean = false;
@Prop ethnic: EthnicGroup;
@Prop onDelete?: (id: string) => void;
@Prop onClick?: (ethnic: EthnicGroup) => void;
private readonly ACTION_WIDTH: number = 80; // 操作按钮宽度
private readonly SWIPE_THRESHOLD: number = 40; // 触发展开的阈值
build() {
Stack({ alignContent: Alignment.End }) {
// 底部的删除按钮
Row() {
Text('删除')
.fontSize(14)
.fontColor('#FFFFFF')
.width(this.ACTION_WIDTH)
.height('100%')
.backgroundColor('#FF4D4F')
.textAlign(TextAlign.Center)
.onClick(() => {
this.handleDelete();
})
}
.width(this.ACTION_WIDTH)
.height('100%')
// 上面的内容卡片
Row({ space: 12 }) {
Text(this.ethnic.name.charAt(0))
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.width(40)
.height(40)
.borderRadius(20)
.backgroundColor(this.ethnic.emblemColor)
.textAlign(TextAlign.Center)
Column({ space: 4 }) {
Text(this.ethnic.name)
.fontSize(16)
.fontColor($r('app.color.text_primary'))
.fontWeight(FontWeight.Medium)
Text(this.ethnic.region + ' · ' + this.ethnic.population)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text('>')
.fontSize(16)
.fontColor($r('app.color.text_hint'))
}
.width('100%')
.height(64)
.padding({ left: 16, right: 16 })
.backgroundColor($r('app.color.card_background'))
.borderRadius(12)
// 向左偏移,露出下面的删除按钮
.translate({ x: this.offsetX })
.animation({
duration: this.isSwiping ? 0 : 250,
curve: Curve.EaseOut
})
// 点击卡片
.onClick(() => {
if (this.offsetX === 0) {
// 没有展开时,点击才跳转
if (this.onClick) {
this.onClick(this.ethnic);
}
} else {
// 展开状态下,点击收回
this.closeActions();
}
})
// 横向滑动手势
.gesture(
PanGesture({ direction: PanDirection.Horizontal })
.onActionStart(() => {
this.isSwiping = true;
})
.onActionUpdate((event: GestureEvent) => {
let newOffset = this.offsetX + event.offsetX;
// 限制范围:最多向左滑出一个按钮宽度
newOffset = Math.max(-this.ACTION_WIDTH, Math.min(0, newOffset));
this.offsetX = newOffset;
})
.onActionEnd(() => {
this.isSwiping = false;
this.handleSwipeEnd();
})
.onActionCancel(() => {
this.isSwiping = false;
this.closeActions();
})
)
}
.width('100%')
.height(64)
.borderRadius(12)
.opacity(this.isDeleted ? 0 : 1)
.animation({ duration: 300, curve: Curve.EaseInOut })
}
private handleSwipeEnd(): void {
if (this.offsetX < -this.SWIPE_THRESHOLD) {
// 向左滑过阈值,展开
this.openActions();
} else {
// 没滑够,收回
this.closeActions();
}
}
private openActions(): void {
animateTo({ duration: 250, curve: Curve.EaseOut }, () => {
this.offsetX = -this.ACTION_WIDTH;
});
}
private closeActions(): void {
animateTo({ duration: 250, curve: Curve.EaseOut }, () => {
this.offsetX = 0;
});
}
private handleDelete(): void {
// 先把卡片缩回去,再删除
animateTo({ duration: 200, curve: Curve.EaseIn }, () => {
this.offsetX = -this.ACTION_WIDTH - 100;
});
animateTo({ duration: 300, curve: Curve.EaseOut, delay: 200 }, () => {
this.isDeleted = true;
});
// 通知父组件删除
setTimeout(() => {
if (this.onDelete) {
this.onDelete(this.ethnic.id);
}
}, 500);
}
}
设计要点:
- 两层结构:底部是操作按钮,上面是内容卡片。内容卡片偏移时,露出底部的按钮
- 方向限制:只用 PanDirection.Horizontal,避免和纵向滚动冲突
- 阈值判断:滑动距离超过阈值才展开,否则收回
- 点击处理:展开状态下点击卡片,先收回,不跳转
- 删除动画:先向左滑走,再淡出,体验更流畅
- 拖动跟手:isSwiping 时 animation duration 为 0,完全跟手
步骤6:手势+动画的高级交互
手势和动画是好搭档——手势负责"跟手"的部分,动画负责"松手后"的部分。两者结合,才能做出流畅自然的交互。
6.1 跟手 vs 动画
一个常见的错误:给手势控制的属性加了动画,导致拖不动或者有延迟。
// ❌ 不好:拖动时也有动画,不跟手
@State offsetX: number = 0;
Image(...)
.translate({ x: this.offsetX })
.animation({ duration: 300, curve: Curve.EaseOut }) // 一直有动画
.gesture(
PanGesture()
.onActionUpdate((event) => {
this.offsetX += event.offsetX;
})
)
这样写的话,你会发现拖动的时候有"滞后"——手指动了,图片要等一下才跟上。因为每一次位移变化都有 300ms 的动画过渡。
正确做法:用一个状态变量控制是否开启动画:
// ✅ 好:拖动时零延迟,松手时动画
@State offsetX: number = 0;
@State isDragging: boolean = false;
Image(...)
.translate({ x: this.offsetX })
.animation({
duration: this.isDragging ? 0 : 300, // 拖动时 0ms,完全跟手
curve: Curve.EaseOut
})
.gesture(
PanGesture()
.onActionStart(() => {
this.isDragging = true; // 开始拖动,关闭动画
})
.onActionUpdate((event) => {
this.offsetX += event.offsetX;
})
.onActionEnd(() => {
this.isDragging = false; // 结束拖动,开启动画
})
)
6.2 松手后的弹性动画
松手后用 Spring 曲线弹回,比 EaseOut 更有"弹"的感觉:
.onActionEnd(() => {
this.isDragging = false;
animateTo({
duration: 400,
curve: Curve.Spring,
}, () => {
this.offsetX = 0;
this.offsetY = 0;
});
})
6.3 阻尼效果
下拉刷新、滑动关闭这些场景,越往边缘越难拉,这就是阻尼效果。
.onActionUpdate((event: GestureEvent) => {
let newOffset = this.refreshOffset + event.offsetY;
// 阻尼:超过 80 后,位移只增加 1/3
if (newOffset > 80) {
newOffset = 80 + (newOffset - 80) * 0.3;
}
this.refreshOffset = Math.min(newOffset, 150);
})
这样用户会感觉"有东西在拉着",而不是可以无限下拉。
6.4 速度判断(惯性滑动)
滑动结束时,可以根据滑动速度判断是否触发操作,而不是只看位移:
.onActionEnd((event: GestureEvent) => {
const velocityX = Math.abs(event.velocityX);
// 快速滑动:速度超过阈值就算
if (velocityX > 500) {
if (event.velocityX > 0) {
this.swipeRight();
} else {
this.swipeLeft();
}
return;
}
// 慢速滑动:看位移
if (this.offsetX > this.SWIPE_THRESHOLD) {
this.swipeRight();
} else if (this.offsetX < -this.SWIPE_THRESHOLD) {
this.swipeLeft();
} else {
this.resetPosition();
}
})
为什么要考虑速度?因为有时候用户"甩"了一下,虽然位移不大,但速度很快,意图明显就是想滑走。
⚠️ 常见问题与解决方案
问题1:手势冲突,不知道该响应哪个
现象:
- 列表里的卡片,点击有时候不响应
- 横向滑动和纵向滚动打架
- 子组件的手势被父组件吃掉了
分析与解决方案:
情况1:父子组件同方向手势冲突
父组件(纵向滚动)
子组件(纵向滑动)
这种情况用 priorityGesture 或 gesture 调整优先级。
情况2:不同方向的手势
如果是横向 vs 纵向,用 direction 参数限制方向:
// 只响应横向滑动,不会和纵向滚动冲突
PanGesture({ direction: PanDirection.Horizontal })
情况3:点击和滑动冲突
系统默认的处理是:滑动距离超过阈值就是滑动,否则是点击。一般不用自己处理。
但如果你的点击区域很小,或者滑动阈值不合适,可以调整:
// 调整触发距离
PanGesture({ distance: 10 }) // 移动 10vp 才算滑动,默认 5
情况4:子组件手势被父组件拦截
父组件用了 priorityGesture,子组件的手势就没机会了。这时候检查一下父组件是不是用了 priorityGesture,如果不需要就改成 gesture。
问题2:滑动不跟手,有延迟
现象:
手指拖动元素,元素慢悠悠地跟,感觉有延迟,不跟手。
常见原因:
原因1:加了不必要的动画
// ❌ 不好:拖动过程中也有动画
.translate({ x: this.offsetX })
.animation({ duration: 300, curve: Curve.EaseOut })
解决:拖动时关闭动画,松手再开。
// ✅ 好:拖动时 0ms,跟手
.animation({
duration: this.isDragging ? 0 : 300,
curve: Curve.EaseOut
})
原因2:主线程太忙
滑动过程中做了耗时计算,导致 UI 线程阻塞。
// ❌ 不好:每一次 update 都做大量计算
.onActionUpdate((event) => {
this.offsetX += event.offsetX;
this.heavyCalculation(); // 耗时操作
})
解决:把耗时操作移到 onActionEnd,或者节流。
原因3:层级太深,重绘开销大
滑动的元素层级太深,每次位移都要重绘很多内容。
解决:
- 减少滑动元素的层级
- 加
.renderGroup(true)让它在独立渲染层
问题3:误触严重,经常点错
现象:
用户只是想滑动,结果触发了点击;或者想点按钮,结果按到了别的地方。
常见原因及解决方案:
原因1:点击区域太小
按钮太小,用户不容易点中。
解决:确保点击区域至少 44x44 vp(苹果的 HIG 推荐,安卓类似)。
// ✅ 好:足够大的点击区域
Image(...)
.width(24)
.height(24)
.padding(10) // 加上 padding,扩大点击区域
.onClick(...)
原因2:滑动和点击的阈值太小
用户稍微动一点就触发滑动,或者动了很多还没触发滑动。
解决:调整 distance 参数。
// 调大滑动触发距离,减少误触
PanGesture({ distance: 10 }) // 默认 5,设大一点更不容易误触
原因3:列表滚动时误触点击
用户滚动列表,手指按下和抬起的位置不一样,被识别成了点击。
解决:滚动过程中禁用点击,或者用系统默认的行为(通常系统已经处理好了)。
问题4:长按时震动/反馈时机不对
现象:
长按 500ms 才触发,但用户觉得"按了半天没反应"。
解决方案:
方案1:缩短长按时长
LongPressGesture({ duration: 300 }) // 从 500 改成 300
但太短了又容易误触,找个平衡点。
方案2:按下就有视觉反馈
不用等长按识别成功才反馈,按下就给反馈:
@State isPressed: boolean = false;
Image(...)
.scale({ x: this.isPressed ? 0.95 : 1, y: this.isPressed ? 0.95 : 1 })
.animation({ duration: 100, curve: Curve.EaseInOut })
.onTouch((event: TouchEvent) => {
if (event.type === TouchType.Down) {
this.isPressed = true;
} else if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
this.isPressed = false;
}
})
这样用户按下就有反馈,长按只是触发"长按动作",而不是"反馈"。
问题5:手势被取消后状态不对
现象:
手势过程中来电话、切后台,回来后状态不对——比如拖拽的元素停在半中间。
解决方案:
一定要处理 onActionCancel 回调,在取消时重置状态:
PanGesture()
.onActionStart(() => {
this.isDragging = true;
})
.onActionUpdate((event) => {
this.offsetX += event.offsetX;
})
.onActionEnd(() => {
this.isDragging = false;
this.handleEnd();
})
.onActionCancel(() => {
// 👇 一定要处理取消,重置状态
this.isDragging = false;
this.resetPosition();
})
常见的取消场景:
- 来电
- 切后台
- 系统弹窗
- 其他手势优先识别成功
📝 本章小结
核心知识点
本文从手势的本质讲起,系统介绍了 ArkUI 的手势系统:
1. 手势基础概念
- 手势 = 对原始触摸事件的模式识别
- 三个阶段:Start / Update / End(Cancel)
- 手势识别是一个"竞争"过程
2. 五大基础手势
- TapGesture:点击,支持双击、多指
- LongPressGesture:长按,可配置时长
- PanGesture:滑动/拖拽,可限制方向
- PinchGesture:捏合,用于缩放
- RotationGesture:旋转,配合捏合用
3. 手势优先级
- gesture:普通优先级,默认
- priorityGesture:高优先级,先于子组件
- parallelGesture:并行,父子同时响应
4. 手势组(GestureGroup)
- Parallel:并行(缩放+旋转)
- Sequence:串行(先长按再拖动)
- Exclusive:互斥(只有一个成功)
5. 手势+动画
- 拖动时零延迟,跟手
- 松手时动画,自然过渡
- 弹簧曲线 + 阻尼效果,手感更好
最佳实践总结
✅ 方向限制:能用 direction 就用 direction
只需要横向滑动?PanDirection.Horizontal 安排上。
限制方向不仅能减少冲突,还能让手势识别更灵敏。
✅ 跟手优先:拖动时零延迟,松手时动画
isDragging 为 true 时,animation duration = 0
isDragging 为 false 时,正常动画
这是手势交互的黄金法则。
✅ 阈值判断:不要一碰就触发
滑动有 distance 阈值
侧滑有 SWIPE_THRESHOLD
下拉有下拉阈值
给用户留"后悔"的空间,减少误触。
✅ 阻尼效果:越到边缘越难拉
下拉刷新、滑动关闭,都应该加阻尼。
不是线性的,而是越来越"沉"。
这样手感才像真的在拉一个有弹性的东西。
✅ 速度判断:快速滑动也要响应
不光看位移,还要看速度。
用户快速一甩,就算位移不够,也算。
这样才符合"甩出去"的直觉。
✅ 状态兜底:onActionCancel 一定要处理
手势随时可能被取消(来电、切后台)。
取消了要把状态恢复正常。
不然用户回来看到一个卡在半空的元素,会很懵。
下一步预告
在下一篇文章中,我们将:
- 💬 学习鸿蒙中各种弹窗的使用:AlertDialog、CustomDialog、ActionSheet、Toast
- 🎨 掌握自定义弹窗的深度定制技巧
- 📚 理解弹窗的栈管理与多个弹窗的协调
- 🎬 实现弹窗的动画与转场效果
- 🚀 用「民族分享弹窗」「筛选弹窗」等实战案例,带你掌握弹窗开发的精髓
🔗 相关链接
- 项目源码: GitCode 仓库
- ArkUI 手势开发指南: 官方文档
- TapGesture: 官方文档
- PanGesture: 官方文档
- PinchGesture: 官方文档
- GestureGroup: 官方文档
💡 提示:手势交互是一个"手感活"——技术好学,手感难调。同样的功能,100ms 的差别、阈值差 10px、阻尼系数差一点,感觉就完全不一样。最好的学习方法就是多玩优秀的 App,注意它们的手势细节——侧滑多少才展开?下拉多少才刷新?松手后弹回来的速度是多少?玩得多了,再回到自己的项目里,一点点调,你会发现——好的交互,就是"说不出来哪里好,但就是舒服"。
更多推荐



所有评论(0)