页面能点能滑还不够,得“会懂手势”才叫鸿蒙 App 啊?——ArkUI 手势系统完全指南
鸿蒙ArkUI手势系统开发实战指南 本文从实际项目需求出发,深入讲解鸿蒙ArkUI手势系统的开发要点。主要内容包括: 基础手势应用 详细解析Tap、Pan、Swipe、Pinch等基础手势的使用方法 提供拖动小球、图片缩放等实用示例代码 手势冲突解决方案 分析父子组件手势冲突的常见场景 介绍优先级手势(.priorityGesture)和并行手势(.parallelGesture)等调度策略 提供
大家好,我是[晚风依旧似温柔],新人一枚,欢迎大家关注~
本文目录:
前言
说句实在话,刚接触 ArkUI 的时候,我对“手势事件”的理解还停留在——Tap = 点击,Pan = 拖动,完事了。
结果一做真项目:
- 列表要支持上下滑,又要支持左滑删除;
- 卡片要能跟手拖动,还要松手自动弹回;
- 图片要双指缩放、旋转;
- 底部弹窗要跟着手势上拉、下拉,收起、展开要带动画;
- 父容器、子组件各自绑了一堆手势,结果谁都抢不过谁……
你就会发现:
“鸿蒙手势系统”这玩意,完全可以单独写一本小册子。
这篇就不走“官方文档式念条目”的路子了,我按你给的大纲,把真实项目里最常遇到的几块拆开讲:
- Tap / Swipe / Pinch 等基础手势怎么用?
- 父子组件 / 多手势之间的冲突怎么解?
- 手势 + 动画如何配合,才能做到“既跟手又顺滑”?
- 拖拽排序,一个从 0 到 1 的完整示例。
所有代码基于 ArkTS + ArkUI 手势 API(TapGesture / PanGesture / PinchGesture / SwipeGesture 等),结合官方文档和社区实践来写。
一、Tap / Swipe / Pinch 等基础手势:先把“招式表”吃透
ArkUI 的手势大体可以分三类:
- 单一手势:Tap、LongPress、Pan、Swipe、Pinch、Rotation…
- 组合手势:比如“单击 + 双击互斥”、“长按 + 拖动组合”等
- 多层级手势:父子组件都有手势时的协调问题(下一章讲)
先看怎么在组件上绑一个最普通的 Tap:
1.1 TapGesture:点击 / 双击 / 多指
@Entry
@Component
struct TapDemo {
@State text: string = '点我或者双击我';
build() {
Column() {
Text(this.text)
.fontSize(20)
.gesture(
TapGesture({ count: 1 }) // 单击
.onAction(() => {
this.text = '你单击了我 👆';
})
)
.gesture(
TapGesture({ count: 2 }) // 双击
.onAction(() => {
this.text = '你双击了我 ✌';
})
)
}
.width('100%').height('100%')
.justifyContent(FlexAlign.Center)
}
}
count: 单击 / 双击fingers: 多指点击(比如三指截图、两指特殊操作)
🔎 小坑:
单、双击同时存在时,双击依赖两次 Tap 事件,如果不做组合处理,单击会先把事件消费掉,双击触发失败,这属于“组合手势识别问题”,官方有互斥组合方案,这里先埋个伏笔。
1.2 PanGesture:拖动 / 跟手滑动
Pan 是滑动 / 拖动的底层基础,使用频率非常高。官方文档把它定义为**“滑动手势事件,当滑动最小距离达到设定值时触发”**。
简单实现一个“拖动小球”的例子:
@Entry
@Component
struct PanDemo {
@State offsetX: number = 0;
@State offsetY: number = 0;
build() {
Column() {
Circle()
.width(60).height(60)
.backgroundColor('#4A90E2')
.translate({ x: this.offsetX, y: this.offsetY })
.gesture(
PanGesture({ direction: PanDirection.All, distance: 1 })
.onActionUpdate((e) => {
this.offsetX += e.offsetX;
this.offsetY += e.offsetY;
})
)
}
.width('100%').height('100%')
.justifyContent(FlexAlign.Center)
}
}
direction: 支持 All / Horizontal / Vertical / Left / Right / Up / Down 等枚举,可用 & / | 组合;distance: 触发 Pan 的最小滑动距离(vp),设置太大会感觉“不跟手”。
1.3 SwipeGesture:快速滑动(翻页、删除很常用)
Swipe 更偏“快速”动作,适合做:
- 左右翻页
- 左滑删除
- 滑动切换 Tab
例:检测左右 Swipe:
@Entry
@Component
struct SwipeDemo {
@State info: string = '试试左右快速滑动';
build() {
Column() {
Text(this.info)
.fontSize(20)
.gesture(
SwipeGesture({ direction: SwipeDirection.Horizontal })
.onAction((e) => {
if (e.direction === SwipeDirection.Left) {
this.info = '检测到向左滑 👉';
} else if (e.direction === SwipeDirection.Right) {
this.info = '检测到向右滑 👈';
}
})
)
}
.width('100%').height('100%')
.justifyContent(FlexAlign.Center)
}
}
1.4 PinchGesture:双指缩放(图像 / 地图必备)
Pinch 可以拿到缩放比例,常用于图片查看、地图缩放等场景。
@Entry
@Component
struct PinchDemo {
@State scale: number = 1;
build() {
Column() {
Image($rawfile('demo_pic.png'))
.width(200 * this.scale)
.height(200 * this.scale)
.gesture(
PinchGesture()
.onActionStart(() => {
console.info('Pinch start');
})
.onActionUpdate((e) => {
// e.scale: 当前缩放比例,通常基于 1 上下浮动
this.scale *= e.scale;
// 防止缩得太小或太大
this.scale = Math.max(0.5, Math.min(3, this.scale));
})
)
}
.width('100%').height('100%')
.justifyContent(FlexAlign.Center)
}
}
1.5 LongPress / Rotation 等就不一一展开了
你可以把“基础单一手势”记成这几大类:
- TapGesture:点击 / 多击
- LongPressGesture:长按(配合菜单、拖拽开启)
- PanGesture:拖动 / 跟手滑动
- SwipeGesture:快速滑动(不跟手,只告诉你“方向+速度”)
- PinchGesture:双指缩放
- RotationGesture:双指旋转
一条经验:
跟手的 → 用 Pan/Pinch/Rotation 的 onActionUpdate;
只需要知道“发生了一个动作” → 用 Tap/Swipe/LongPress 的 onAction。
二、手势冲突解决:Tap、Pan、父子组件抢事件,谁听谁的?
讲真,手势 API 自身并不难,真正恶心人的,是冲突。
典型问题:
- 父组件有 Pan(整体拖动),子组件也有 Tap(单独点击)
- 列表可以滚动,列表项还要支持左右滑动
- 同一个组件上同时有 Tap + LongPress + Swipe
- 系统返回手势(边缘滑动)和你自定义边缘滑动冲突
ArkUI 给了几套“调度策略”:
.gesture():普通绑定(默认行为).priorityGesture():优先级手势(父子竞争时谁先认领).parallelGesture():并行手势(父子可以同时识别)event.stopPropagation():阻止事件继续冒泡(针对手势事件对象)
2.1 父子组件:父有 Pan,子要 Tap
最经典问题:
- 外层 Column 想通过 Pan 拖动整块区域;
- 内部按钮又要能单独点击。
错误写法:都直接 .gesture(...) 绑上去——谁抢到谁算。
推荐处理:让子组件优先识别点击,父组件再兜底拖动。
@Entry
@Component
struct ParentChildGestureDemo {
@State offsetX: number = 0;
build() {
Column() {
Column() {
Button('点我,不要拖我')
.gesture(
TapGesture()
.onAction(() => console.info('Button tapped'))
)
}
.width(200).height(80)
.backgroundColor('#EEEEEE')
.translate({ x: this.offsetX })
// 父容器的拖动,用 priorityGesture,说明“如果子没吃掉,就轮到我”
.priorityGesture(
PanGesture({ direction: PanDirection.Horizontal })
.onActionUpdate((e) => {
this.offsetX += e.offsetX;
}),
GestureMask.Normal
)
}
.width('100%').height('100%')
.justifyContent(FlexAlign.Center)
}
}
- 子组件的 TapGesture 先有机会“吃掉”点击;
- 如果用户做的是明显的 Pan(拖),Tap 不会触发,PanGesture 才接管。
2.2 列表滚动 vs 列表项左右滑动
另一个高频场景:
- List 本身可以垂直滚动;
- ListItem 需要左右滑动做“删除 / 更多操作”。
解决思路:
- List 本身用
List自带的滚动; - ListItem 上绑定
PanGesture时,把direction限定为Horizontal; - 同时给 ListItem 用
.priorityGesture(),优先识别左右滑,如果识别失败再交给父级滚动。
伪代码示意:
List() {
ForEach(this.items, (item) => {
ListItem() {
Row() {
Text(item.title)
}
.width('100%')
.height(60)
.priorityGesture(
PanGesture({ direction: PanDirection.Horizontal, distance: 2 })
.onActionUpdate((e) => {
// 左滑/右滑逻辑,比如偏移一个 Action 区域
}),
GestureMask.Normal
)
}
})
}
关键点:
direction精准限定,避免垂直滑也被误判为“左右滑动”;distance设小一点让侧滑更灵敏,否则体验会很奇怪。
2.3 手势并行:点击 + 长按同时可用
有时候你希望:
- 点击执行 A 操作;
- 长按执行 B 操作;
- 且二者都能识别,不互相吞掉。
这时候可以用 .parallelGesture():
@Component
struct ParallelDemo {
@State info: string = '试试点我 / 长按我';
build() {
Text(this.info)
.fontSize(20)
.parallelGesture(
TapGesture().onAction(() => {
this.info = 'Tap 手势触发 ✅';
}),
GestureMask.Normal
)
.parallelGesture(
LongPressGesture({ repeat: false }).onAction(() => {
this.info = 'LongPress 手势触发 ✅';
}),
GestureMask.Normal
)
}
}
简单记法:
gesture():最普通的绑定priorityGesture():谁先上场parallelGesture():我俩一起上
2.4 和系统手势冲突:必要时用 stopPropagation
某些情况下,你可能做了一个“屏幕边缘右滑返回上一页”的自定义手势,但系统本身也有类似手势。
这种场景下一般建议:
- 优先尊重系统默认交互(别轻易覆盖);
- 如果业务真的需要,尽量缩小自定义手势激活范围(例如只在某个内嵌区域生效);
- 在手势回调中视情况调用
event.stopPropagation(),阻止冒泡,让事件不再上传给父级。
三、手势 + 动画联动:跟手的是物理感,收尾的是高级感
没有动画配合的手势,交互会显得“很干”。
组合方式一般有两种模式:
- 跟手阶段: 手势回调里直接修改
@State,不要动画,保证实时; - 收尾阶段: 手势结束(onActionEnd)时,用
animateTo做一段“吸附 / 弹回 / 过渡”动画。
3.1 Tap + Scale:快速反馈
用 .animation() + onTouch 就能做一个非常舒服的“按压缩放”:
@Entry
@Component
struct PressButton {
@State scale: number = 1;
build() {
Button('提交')
.scale({ x: this.scale, y: this.scale })
.animation({ duration: 80, curve: Curve.EaseOut })
.onTouch((e) => {
if (e.type === TouchType.Down) {
this.scale = 0.95;
} else if (e.type === TouchType.Up || e.type === TouchType.Cancel) {
this.scale = 1;
}
})
}
}
注意:
- 这里不需要显式 animateTo;
.animation()会自动让 scale 的变化带上动效。
3.2 Pan + Bottom Sheet:上拉跟手,松手吸附
这是手势+动画联动的“教科书级”场景。
@Entry
@Component
struct BottomSheet {
private readonly MIN_H: number = 80;
private readonly MAX_H: number = 400;
@State sheetHeight: number = 80;
build() {
Stack() {
// 背景内容略…
Column() {
Row() {
Text('上拉查看更多').fontSize(16)
}.height(40).justifyContent(FlexAlign.Center)
// 这里是 sheet 内容
Text('这里是 BottomSheet 内容').margin(16)
}
.width('100%')
.height(this.sheetHeight)
.backgroundColor('#FFFFFF')
.borderRadius({ topLeft: 16, topRight: 16 })
.align(Alignment.Bottom)
.gesture(
PanGesture({ direction: PanDirection.Vertical, distance: 1 })
.onActionUpdate((e) => {
// 手势跟手:不要动画,直接改数值
let next = this.sheetHeight - e.offsetY; // 注意滑动方向
this.sheetHeight = Math.min(this.MAX_H, Math.max(this.MIN_H, next));
})
.onActionEnd(() => {
// 收尾:吸附到展开或收起位置
const mid = (this.MAX_H + this.MIN_H) / 2;
const target = this.sheetHeight > mid ? this.MAX_H : this.MIN_H;
this.getUIContext()?.animateTo(
{ duration: 250, curve: Curve.EaseOut },
() => this.sheetHeight = target
);
})
)
}
.width('100%').height('100%')
}
}
这一套下来,交互自然、多数用户一上手就懂,体感也足够顺滑。
模式可以复用到各种场景:
- 上拉抽屉
- 侧滑菜单
- 拖动卡片
四、拖拽排序示例:从“能拖动”到“能排序”的完整流程
终于来到最有实战味道的部分——拖拽排序。
目标:
- 有一个竖直列表
- 长按某一行进入“拖拽模式”
- 拖动时,该行跟手移动,其他行让出空间
- 松手后,列表顺序更新
实现思路:
-
列表数据:
@State items: Item[] -
额外状态:
draggingId:当前拖拽的 item iddragOffsetY:当前拖拽位移itemHeight:每项高度(固定高度较简单)
-
拖拽逻辑:
- 长按某行 → 记录
draggingId - PanGesture onActionUpdate → 改
dragOffsetY - 根据
dragOffsetY计算实际应该落在哪个 index → 动态重排items - onActionEnd → 动画归位、清空拖拽状态
- 长按某行 → 记录
下面写一个简化版(重点放在思路而不是所有边界情况):
type SortItem = {
id: number;
title: string;
};
@Entry
@Component
struct DragSortList {
private readonly ITEM_H: number = 56;
@State items: SortItem[] = [
{ id: 1, title: '任务一' },
{ id: 2, title: '任务二' },
{ id: 3, title: '任务三' },
{ id: 4, title: '任务四' },
];
@State draggingId: number | null = null;
@State dragOffsetY: number = 0; // 当前 item 相对它原始位置的偏移
@State startIndex: number = -1; // 拖拽起始 index
build() {
Column() {
ForEach(this.items, (item, index) => {
// 是否是当前拖动的那一项
const isDragging = (this.draggingId === item.id);
const baseY = index * this.ITEM_H;
// 计算额外偏移(非常简化:除了正在拖的,不做平移)
let translateY = 0;
if (isDragging) {
translateY = this.dragOffsetY;
}
this.renderItem(item, index, isDragging, translateY);
}, (it: SortItem) => it.id.toString())
}
.width('100%')
.padding({ top: 40 })
}
@Builder
renderItem(item: SortItem, index: number, isDragging: boolean, translateY: number) {
// 提前捕获 this
let self = this;
Row() {
Text(item.title)
.fontSize(18)
.margin({ left: 16 })
}
.width('100%')
.height(this.ITEM_H)
.backgroundColor(isDragging ? '#FFD54F' : '#FFFFFF')
.borderRadius(8)
.shadow({ radius: isDragging ? 12 : 4, color: Color.fromARGB(50, 0, 0, 0) })
.margin({ left: 16, right: 16, bottom: 8 })
.translate({ y: translateY })
.animation({ duration: 150, curve: Curve.EaseOut })
.gesture(
LongPressGesture({ repeat: false }).onActionStart(() => {
// 开始拖拽
self.draggingId = item.id;
self.startIndex = index;
self.dragOffsetY = 0;
})
)
.gesture(
// 拖动时,用 PanGesture
PanGesture({ direction: PanDirection.Vertical, distance: 1 })
.onActionUpdate((e) => {
if (self.draggingId !== item.id) return; // 非当前拖动项忽略
self.dragOffsetY += e.offsetY;
// 根据偏移量计算“目标 index”
const currentIndex = self.items.findIndex(it => it.id === item.id);
const moved = Math.round(self.dragOffsetY / self.ITEM_H);
let targetIndex = self.startIndex + moved;
targetIndex = Math.max(0, Math.min(self.items.length - 1, targetIndex));
if (targetIndex !== currentIndex) {
// 重新排序数组
const newList = [...self.items];
const [removed] = newList.splice(currentIndex, 1);
newList.splice(targetIndex, 0, removed);
self.items = newList;
}
})
.onActionEnd(() => {
if (self.draggingId === item.id) {
// 回弹归位
self.getUIContext()?.animateTo(
{ duration: 200, curve: Curve.EaseOut },
() => {
self.dragOffsetY = 0;
}
);
self.draggingId = null;
self.startIndex = -1;
}
})
)
}
}
⚠️ 这个实现还比较粗糙,但几个关键点有了:
- 用 LongPress 开启拖拽模式(避免手指一碰就乱拖)
- 用 PanGesture 的 offsetY 来计算要插入的位置
- 数组 reorder 是在 onActionUpdate 里实时完成
- 非拖拽项,只依赖
items的顺序变化 +.animation()的过渡,自动实现“流畅位置切换”
你可以在这个基础上继续进化:
- 对非拖拽项,根据拖拽方向给一个向上 / 向下的位移动画;
- 拖拽过程中减弱透明度、加大阴影;
- 拖拽超出列表区域时触发自动滚动;
- 拖动到边缘删除等高级交互。
但核心模式其实就是这一个:
手势做输入,动画做表现,状态做真相。
小结:手势是“交互层的语言”,别只把它当成事件回调
如果看到这里,你能在脑子里大致串起这条线:
- Tap / LongPress / Pan / Swipe / Pinch / Rotation 分别适合什么场景;
.gesture/.priorityGesture/.parallelGesture各自的职责是什么;- 跟手交互时,不要滥用 animateTo,而是在收尾阶段配合动画做“吸附 / 回弹”;
- 拖拽排序这种看起来“复杂”的交互,其实就是 Pan + 状态重排 + 动画 的组合;
那你在鸿蒙这边做手势交互,已经从“能用”进阶到了“可维护 + 有体验”的阶段了。
如果觉得有帮助,别忘了点个赞+关注支持一下~
喜欢记得关注,别让好内容被埋没~
更多推荐


所有评论(0)