大家好,我是[晚风依旧似温柔],新人一枚,欢迎大家关注~

前言

说句实在话,刚接触 ArkUI 的时候,我对“手势事件”的理解还停留在——Tap = 点击,Pan = 拖动,完事了
  结果一做真项目:

  • 列表要支持上下滑,又要支持左滑删除;
  • 卡片要能跟手拖动,还要松手自动弹回;
  • 图片要双指缩放、旋转;
  • 底部弹窗要跟着手势上拉、下拉,收起、展开要带动画;
  • 父容器、子组件各自绑了一堆手势,结果谁都抢不过谁……

你就会发现:
“鸿蒙手势系统”这玩意,完全可以单独写一本小册子。

这篇就不走“官方文档式念条目”的路子了,我按你给的大纲,把真实项目里最常遇到的几块拆开讲:

  1. Tap / Swipe / Pinch 等基础手势怎么用?
  2. 父子组件 / 多手势之间的冲突怎么解?
  3. 手势 + 动画如何配合,才能做到“既跟手又顺滑”?
  4. 拖拽排序,一个从 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

某些情况下,你可能做了一个“屏幕边缘右滑返回上一页”的自定义手势,但系统本身也有类似手势。

这种场景下一般建议:

  1. 优先尊重系统默认交互(别轻易覆盖);
  2. 如果业务真的需要,尽量缩小自定义手势激活范围(例如只在某个内嵌区域生效);
  3. 在手势回调中视情况调用 event.stopPropagation(),阻止冒泡,让事件不再上传给父级。

三、手势 + 动画联动:跟手的是物理感,收尾的是高级感

没有动画配合的手势,交互会显得“很干”。
组合方式一般有两种模式:

  1. 跟手阶段: 手势回调里直接修改 @State,不要动画,保证实时;
  2. 收尾阶段: 手势结束(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%')
  }
}

这一套下来,交互自然、多数用户一上手就懂,体感也足够顺滑。

模式可以复用到各种场景:

  • 上拉抽屉
  • 侧滑菜单
  • 拖动卡片

四、拖拽排序示例:从“能拖动”到“能排序”的完整流程

终于来到最有实战味道的部分——拖拽排序

目标:

  • 有一个竖直列表
  • 长按某一行进入“拖拽模式”
  • 拖动时,该行跟手移动,其他行让出空间
  • 松手后,列表顺序更新

实现思路:

  1. 列表数据:@State items: Item[]

  2. 额外状态:

    • draggingId:当前拖拽的 item id
    • dragOffsetY:当前拖拽位移
    • itemHeight:每项高度(固定高度较简单)
  3. 拖拽逻辑:

    • 长按某行 → 记录 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 + 状态重排 + 动画 的组合;

那你在鸿蒙这边做手势交互,已经从“能用”进阶到了“可维护 + 有体验”的阶段了。

如果觉得有帮助,别忘了点个赞+关注支持一下~
喜欢记得关注,别让好内容被埋没~

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐