在这里插入图片描述

📖 引言

如果说 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 手势的识别流程

手势识别是一个"竞争"的过程。当用户触摸屏幕时,所有绑定了手势的组件都会开始"竞争"——看谁先识别出手势。

比如,一个列表项,既有点击手势,又有滑动手势。用户按下手指时:

  1. 两个手势都开始检测
  2. 如果用户很快抬起 → 点击手势识别成功,滑动手势失败
  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:长按时长(毫秒),默认 500ms
  • finger:手指数量,默认 1
  • repeat:是否重复触发,默认 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),默认 5
  • finger:手指数量,默认 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;
    });
  }
}

关键技巧

  1. 拖动时零延迟:isDragging 为 true 时,animation duration 设为 0,完全跟手
  2. 松手时动画:松开后用 Spring 曲线弹回,有弹性
  3. 旋转效果:根据水平位移计算旋转角度,更有"甩出去"的感觉
  4. 阈值判断:滑过一定距离才触发操作,没滑够就弹回
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:手指数量,默认 2
  • distance:触发捏合的最小距离(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:手指数量,默认 2
  • angle:触发旋转的最小角度(度),默认 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;
        });
      }
    })
)

关键思路

  1. 只有当列表在顶部(isAtTop)且是下拉(offsetY > 0)时,才处理下拉手势
  2. 用阻尼效果:越往下拉,位移增加越慢,手感更好
  3. 松手时判断是否超过阈值,超过就触发刷新,否则收回

步骤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);
  }
}

设计要点

  1. 两层结构:底部是操作按钮,上面是内容卡片。内容卡片偏移时,露出底部的按钮
  2. 方向限制:只用 PanDirection.Horizontal,避免和纵向滚动冲突
  3. 阈值判断:滑动距离超过阈值才展开,否则收回
  4. 点击处理:展开状态下点击卡片,先收回,不跳转
  5. 删除动画:先向左滑走,再淡出,体验更流畅
  6. 拖动跟手: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:父子组件同方向手势冲突

父组件(纵向滚动)
  子组件(纵向滑动)

这种情况用 priorityGesturegesture 调整优先级。

情况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
  • 🎨 掌握自定义弹窗的深度定制技巧
  • 📚 理解弹窗的栈管理与多个弹窗的协调
  • 🎬 实现弹窗的动画与转场效果
  • 🚀 用「民族分享弹窗」「筛选弹窗」等实战案例,带你掌握弹窗开发的精髓

🔗 相关链接


💡 提示:手势交互是一个"手感活"——技术好学,手感难调。同样的功能,100ms 的差别、阈值差 10px、阻尼系数差一点,感觉就完全不一样。最好的学习方法就是多玩优秀的 App,注意它们的手势细节——侧滑多少才展开?下拉多少才刷新?松手后弹回来的速度是多少?玩得多了,再回到自己的项目里,一点点调,你会发现——好的交互,就是"说不出来哪里好,但就是舒服"。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐