《相机焦距缩放》二、捏合手势使用指南

HarmonyOS PinchGesture 捏合手势使用指南

本指南系统讲解 HarmonyOS ArkUI 中 PinchGesture 捏合手势的使用方法,从基础概念到实战示例,帮助开发者快速掌握双指缩放手势的实现。

效果

一、概述

PinchGesture 是 ArkUI 提供的基础手势之一,用于识别双指(或多指)捏合手势。典型应用场景包括:

  • 图片/地图的缩放
  • 相机焦距缩放控制
  • 文档/网页的放大缩小
  • 游戏界面的视角缩放

核心特性

特性 说明
最少手指 2 指
最多手指 5 指
最小识别距离 5vp
鼠标/键盘支持 Ctrl + 鼠标滚轮(在支持设备上)
起始版本 API Version 7

二、基本语法

2.1 构造函数

typescript 复制代码
PinchGesture(value?: { fingers?: number; distance?: number })
参数 类型 默认值 说明
fingers number 2 触发手势所需的最少手指数量(2~5)
distance number 5 最小识别距离,单位 vp

2.2 回调事件

PinchGesture 提供三个回调:

回调 触发时机 参数
onActionStart 手势识别成功时 event: GestureEvent
onActionUpdate 手势状态持续更新时(手指移动) event: GestureEvent
onActionEnd 手势结束时(手指抬起) event: GestureEvent

2.3 GestureEvent 属性

typescript 复制代码
interface GestureEvent {
  scale: number;      // 捏合缩放比例(相对于手势开始时的比例)
  centerX: number;    // 双指中心点 X 坐标
  centerY: number;    // 双指中心点 Y 坐标
  offsetX: number;    // X 方向偏移量
  offsetY: number;    // Y 方向偏移量
}

关键属性 scale 说明:

  • 手势开始时 scale = 1.0
  • 双指张开(放大)时 scale > 1.0
  • 双指捏合(缩小)时 scale < 1.0
  • 该值是相对于手势开始时的累积比例,不是增量

三、基础用法

3.1 最简单的捏合手势

typescript 复制代码
@ComponentV2
struct SimplePinchExample {
  @Local scaleValue: number = 1;

  build() {
    Column() {
      Text(`缩放比例: ${this.scaleValue.toFixed(2)}x`)
        .fontSize(20)
        .margin({ bottom: 20 })

      Box()
        .width(200)
        .height(200)
        .backgroundColor('#4FC08D')
        .borderRadius(16)
        .scale({ x: this.scaleValue, y: this.scaleValue })
        .gesture(
          PinchGesture()
            .onActionUpdate((event: GestureEvent) => {
              this.scaleValue = event.scale;
            })
            .onActionEnd(() => {
              // 手势结束后保留当前缩放值
              console.info('捏合结束, 最终比例: ' + this.scaleValue.toFixed(2));
            })
        )
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

3.2 带起始和结束回调

typescript 复制代码
@ComponentV2
struct PinchWithCallbacks {
  @Local scaleValue: number = 1;
  @Local statusText: string = '等待手势...';

  build() {
    Column({ space: 20 }) {
      Text(this.statusText)
        .fontSize(16)
        .fontColor('#666')

      Text(`当前缩放: ${this.scaleValue.toFixed(2)}x`)
        .fontSize(24)
        .fontWeight(FontWeight.Bold)

      Box()
        .width(150)
        .height(150)
        .backgroundColor('#3B82F6')
        .borderRadius(12)
        .scale({ x: this.scaleValue, y: this.scaleValue })
        .gesture(
          PinchGesture({ fingers: 2 })
            .onActionStart(() => {
              this.statusText = '手势开始 - 正在缩放';
            })
            .onActionUpdate((event: GestureEvent) => {
              this.scaleValue = event.scale;
            })
            .onActionEnd(() => {
              this.statusText = '手势结束';
            })
        )
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

四、进阶用法

4.1 累积缩放(基于初始值)

实际应用中,通常需要在每次捏合时基于当前已缩放的比例继续缩放,而非每次从 1.0 开始:

typescript 复制代码
@ComponentV2
struct AccumulativeZoom {
  @Local currentScale: number = 1;    // 当前显示的缩放比例
  @Local baseScale: number = 1;       // 手势开始时的基准比例

  build() {
    Column() {
      Text(`缩放: ${this.currentScale.toFixed(2)}x`)
        .fontSize(20)
        .margin({ bottom: 20 })

      Box()
        .width(200)
        .height(200)
        .backgroundColor('#8B5CF6')
        .borderRadius(16)
        .scale({ x: this.currentScale, y: this.currentScale })
        .gesture(
          PinchGesture()
            .onActionUpdate((event: GestureEvent) => {
              // 当前缩放 = 基准值 × 手势比例
              this.currentScale = this.baseScale * event.scale;
            })
            .onActionEnd(() => {
              // 手势结束后,将当前值保存为下次的基准值
              this.baseScale = this.currentScale;
            })
        )
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

4.2 限制缩放范围

typescript 复制代码
@ComponentV2
struct BoundedZoom {
  @Local currentScale: number = 1;
  @Local baseScale: number = 1;
  private minScale: number = 0.5;
  private maxScale: number = 5.0;

  build() {
    Column() {
      Text(`缩放: ${this.currentScale.toFixed(2)}x`)
        .fontSize(20)
        .margin({ bottom: 20 })

      Box()
        .width(200)
        .height(200)
        .backgroundColor('#EF4444')
        .borderRadius(16)
        .scale({ x: this.currentScale, y: this.currentScale })
        .gesture(
          PinchGesture()
            .onActionUpdate((event: GestureEvent) => {
              let newScale = this.baseScale * event.scale;
              // 限制在 [minScale, maxScale] 范围内
              newScale = Math.max(this.minScale, Math.min(this.maxScale, newScale));
              this.currentScale = newScale;
            })
            .onActionEnd(() => {
              this.baseScale = this.currentScale;
            })
        )
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

4.3 图片缩放查看器

typescript 复制代码
@ComponentV2
struct ImageZoomViewer {
  @Local imageScale: number = 1;
  @Local baseScale: number = 1;

  build() {
    Stack() {
      Image($r('app.media.sample_image'))
        .width('100%')
        .height('100%')
        .objectFit(ImageFit.Contain)
        .scale({ x: this.imageScale, y: this.imageScale })
        .gesture(
          PinchGesture()
            .onActionUpdate((event: GestureEvent) => {
              let newScale = this.baseScale * event.scale;
              newScale = Math.max(1.0, Math.min(5.0, newScale));
              this.imageScale = newScale;
            })
            .onActionEnd(() => {
              this.baseScale = this.imageScale;
            })
        )

      // 缩放比例提示
      Text(`${this.imageScale.toFixed(1)}x`)
        .fontSize(14)
        .fontColor(Color.White)
        .backgroundColor('rgba(0,0,0,0.5)')
        .padding({ left: 12, right: 12, top: 6, bottom: 6 })
        .borderRadius(20)
        .position({ x: '50%', y: '90%' })
        .translate({ x: -30 })
    }
    .width('100%')
    .height('100%')
  }
}

4.4 结合拖拽手势实现平移+缩放

typescript 复制代码
import { gestureModifier } from '@kit.ArkUI';

@ComponentV2
struct PanAndZoom {
  @Local imageScale: number = 1;
  @Local baseScale: number = 1;
  @Local offsetX: number = 0;
  @Local offsetY: number = 0;
  @Local startOffsetX: number = 0;
  @Local startOffsetY: number = 0;

  build() {
    Column() {
      Image($r('app.media.sample_image'))
        .width('100%')
        .height(400)
        .objectFit(ImageFit.Contain)
        .scale({ x: this.imageScale, y: this.imageScale })
        .translate({ x: this.offsetX, y: this.offsetY })
        .gesture(
          PinchGesture()
            .onActionUpdate((event: GestureEvent) => {
              this.imageScale = Math.max(0.5,
                Math.min(5.0, this.baseScale * event.scale));
            })
            .onActionEnd(() => {
              this.baseScale = this.imageScale;
            })
        )
        .parallelGesture(
          PanGesture()
            .onActionUpdate((event: GestureEvent) => {
              this.offsetX = this.startOffsetX + event.offsetX;
              this.offsetY = this.startOffsetY + event.offsetY;
            })
            .onActionEnd(() => {
              this.startOffsetX = this.offsetX;
              this.startOffsetY = this.offsetY;
            })
        )
    }
  }
}

五、在相机焦距缩放中的应用

以下示例展示如何将 PinchGesture 与相机焦距控制结合:

typescript 复制代码
// 假设 photoSession 已初始化
let zoomRatioRange: number[] = [1.0, 10.0]; // 从 getZoomRatioRange() 获取
let currentZoom: number = 1.0;
let baseZoom: number = 1.0;

XComponent({
  type: XComponentType.SURFACE,
  controller: xComponentController
})
  .gesture(
    PinchGesture({ fingers: 2 })
      .onActionUpdate((event: GestureEvent) => {
        // 计算目标缩放值
        let targetZoom = baseZoom * event.scale;

        // 限制在相机支持范围内
        if (targetZoom > zoomRatioRange[1]) {
          targetZoom = zoomRatioRange[1];
        } else if (targetZoom < zoomRatioRange[0]) {
          targetZoom = zoomRatioRange[0];
        }

        currentZoom = targetZoom;
        // 设置相机焦距
        photoSession.setZoomRatio(targetZoom);
      })
      .onActionEnd(() => {
        // 保存当前焦距作为下次手势的基准
        baseZoom = photoSession.getZoomRatio();
      })
  )

六、手势组合

6.1 串行组合手势(GestureGroup - Sequence)

typescript 复制代码
// 先捏合再旋转
.gesture(
  GestureGroup(GestureMode.Sequence,
    PinchGesture(),
    RotationGesture()
  )
)

6.2 并行组合手势(GestureGroup - Parallel)

typescript 复制代码
// 同时支持捏合和拖拽
.gesture(
  GestureGroup(GestureMode.Parallel,
    PinchGesture(),
    PanGesture()
  )
)

6.3 互斥组合手势(GestureGroup - Exclusive)

typescript 复制代码
// 优先识别捏合,其次识别点击
.gesture(
  GestureGroup(GestureMode.Exclusive,
    PinchGesture(),
    TapGesture()
  )
)

七、常见问题与注意事项

7.1 scale 是累积值还是增量?

event.scale相对于手势开始时的累积比例,不是每次回调的增量。

复制代码
手势开始: scale = 1.0
手指张开: scale = 1.2 (表示比开始时放大了 20%)
继续张开: scale = 1.5 (表示比开始时放大了 50%)
手指捏合: scale = 0.8 (表示比开始时缩小了 20%)

7.2 如何实现连续累积缩放?

需要在 onActionEnd 中保存当前值,下次手势开始时作为基准:

typescript 复制代码
.onActionUpdate((event) => {
  this.currentScale = this.baseScale * event.scale;
})
.onActionEnd(() => {
  this.baseScale = this.currentScale; // 保存为下次基准
})

7.3 如何限制缩放范围?

onActionUpdate 中使用 Math.max()Math.min() 进行边界限制:

typescript 复制代码
let clampedScale = Math.max(minScale, Math.min(maxScale, computedScale));

7.4 PinchGesture 与 Scroll 冲突

当组件在 Scroll 容器中时,PinchGesture 可能与滚动冲突。解决方案:

  • 使用 .hitTestBehavior(HitTestMode.Block) 阻止事件穿透
  • 使用 parallelGesture() 替代 gesture() 使手势并行识别
  • 在缩放时临时禁用滚动

7.5 多指手势的手指数量

fingers 参数指定的是最少手指数量,不是固定手指数量。设置为 2 时,2~5 指均可触发。

八、API 速查表

API 说明
PinchGesture({ fingers?, distance? }) 创建捏合手势
.onActionStart(callback) 手势识别成功回调
.onActionUpdate(callback) 手势持续更新回调
.onActionEnd(callback) 手势结束回调
event.scale 缩放比例(累积值)
event.centerX / centerY 双指中心点坐标
GestureGroup(GestureMode.Parallel, ...) 并行组合手势
.parallelGesture(gesture) 组件并行手势(不阻止默认行为)

九、总结

PinchGesture 的使用核心流程:

复制代码
创建 PinchGesture → 绑定到组件 .gesture()
→ onActionUpdate 中处理缩放逻辑(注意累积缩放和范围限制)
→ onActionEnd 中保存基准值

关键要点:

  1. event.scale 是累积比例,需要配合基准值实现连续缩放
  2. 始终对缩放范围进行限制,避免超出合理值
  3. 在相机场景中,缩放范围由 getZoomRatioRange() 决定
  4. 结合 parallelGesture() 可与其他手势共存