ArkUI 常见手势:手指一动,卡片就该乖乖听话

举报
蓝瘦的蜕变 发表于 2026/10/19 18:11:12 2026/10/19
【摘要】 今天我们用 onTouch 做一个常见手势小实验:单指移动、双击缩放、单指旋转、双指旋转和双指捏合缩放。 如果把这些动作全靠多个 Gesture 堆到同一个节点上,新手很容易先被冲突绕进去。所以这一篇我们直接读触点:一个手指看位置变化,两个手指看距离和角度变化。操作对象用本地的 iconhuawei.svg,这样你能很

ArkUI 常见手势:手指一动,卡片就该乖乖听话

今天我们用 onTouch 做一个常见手势小实验:单指移动、双击缩放、单指旋转、双指旋转和双指捏合缩放。

如果把这些动作全靠多个 Gesture 堆到同一个节点上,新手很容易先被冲突绕进去。所以这一篇我们直接读触点:一个手指看位置变化,两个手指看距离和角度变化。操作对象用本地的 icon_huawei.svg,这样你能很直观看到图片怎么跟着手指走。

参考文档:

  • https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-interaction-development-guide-overview

先选模式

单指移动和单指旋转都会走 TouchType.Move,所以 Demo 里先给它们拆成两个模式。

Button({ type: ButtonType.Capsule }) {
  Text('单指移动')
}
.onClick(() => {
  this.updateMode(CommonGestureMode.Move, '移动模式:单指拖动图片')
})

这一步很简单,但很关键。不要让同一个单指移动同时承担“拖动”和“旋转”两个含义,用户会猜不出来页面到底想让他干什么。

用 onTouch 接住触点

核心事件只有一个:

.onTouch((event: TouchEvent) => {
  this.handleTouch(event)
})

TouchEvent 里有 type、touches 和 changedTouches。我们根据 Down、Move、Up 分别记录起点、计算变化、收尾提示。

这里还有一个容易踩的点:手势区域外面如果套了 Scroll,拖图片时页面也会跟着滚。Demo 里会在触摸图片区域时临时关闭外层滚动,松手后再恢复;双指操作结束后,也不会让残留的单指继续接管移动,必须重新按下才开始新的单指手势。

双指同时算缩放和旋转

双指场景里,距离变化就是缩放,连线角度变化就是旋转。

let currentDistance: number = this.distanceBetween(first, second)
let currentAngle: number = this.angleBetween(first, second)
this.scaleValue = this.clamp(this.startPinchScale * currentDistance / this.startPinchDistance, 0.65, 2.2)
this.rotateAngle = this.startRotateAngle + currentAngle - this.startPinchAngle

这不是手势识别框架的替代品,只是一个能看懂原理的入门写法。你要做图片编辑器、地图、画布编辑这类功能时,先理解这个计算过程会轻松很多。

你平时最常遇到的是哪类手势:图片预览、地图缩放,还是卡片拖动?这会决定我们下一篇是继续讲冲突处理,还是先把动画反馈补上。

完整示例代码

文件位置:entry/src/main/ets/pages/arkUI/interaction/ArkUICommonGestureDemo.ets

enum CommonGestureMode {
  Move,
  Rotate,
  Transform
}

@Entry
@Component
struct ArkUICommonGestureDemo {
  @State mode: CommonGestureMode = CommonGestureMode.Move
  @State translateX: number = 0
  @State translateY: number = 0
  @State scaleValue: number = 1
  @State rotateAngle: number = 0
  @State feedbackText: string = '选择一个模式后,在华为 SVG 图标上动手试试'
  @State gestureAreaActive: boolean = false

  private baseTranslateX: number = 0
  private baseTranslateY: number = 0
  private touchStartX: number = 0
  private touchStartY: number = 0
  private startRotateAngle: number = 0
  private startTouchAngle: number = 0
  private startPinchDistance: number = 0
  private startPinchAngle: number = 0
  private startPinchScale: number = 1
  private lastTapTime: number = 0
  private isMultiTouching: boolean = false
  private waitForFreshSingleTouch: boolean = false

  private showTip(message: string): void {
    this.getUIContext().getPromptAction().showToast({
      message: message,
      duration: 1200,
      bottom: 120
    })
  }

  private clamp(value: number, min: number, max: number): number {
    return Math.max(min, Math.min(max, value))
  }

  private angleFromCenter(touch: TouchObject): number {
    return Math.atan2(touch.y - 150, touch.x - 150) * 180 / Math.PI
  }

  private distanceBetween(first: TouchObject, second: TouchObject): number {
    let dx: number = second.x - first.x
    let dy: number = second.y - first.y
    return Math.sqrt(dx * dx + dy * dy)
  }

  private angleBetween(first: TouchObject, second: TouchObject): number {
    return Math.atan2(second.y - first.y, second.x - first.x) * 180 / Math.PI
  }

  private updateMode(mode: CommonGestureMode, text: string): void {
    this.mode = mode
    this.feedbackText = text
    this.showTip(text)
  }

  private resetTransform(): void {
    this.translateX = 0
    this.translateY = 0
    this.scaleValue = 1
    this.rotateAngle = 0
    this.baseTranslateX = 0
    this.baseTranslateY = 0
    this.startPinchScale = 1
    this.lastTapTime = 0
    this.isMultiTouching = false
    this.waitForFreshSingleTouch = false
    this.gestureAreaActive = false
    this.feedbackText = '已经重置:图片回到原始位置、大小和角度'
    this.showTip('手势效果已重置')
  }

  private handleDoubleTap(): void {
    let nextScale: number = this.scaleValue > 1.05 ? 1 : 1.35
    this.scaleValue = nextScale
    this.startPinchScale = nextScale
    this.feedbackText = `双击缩放:当前缩放 ${nextScale.toFixed(2)}`
    this.showTip(nextScale > 1 ? '双击放大' : '双击还原')
  }

  private beginSingleTouch(touch: TouchObject): void {
    this.isMultiTouching = false
    this.waitForFreshSingleTouch = false
    this.touchStartX = touch.x
    this.touchStartY = touch.y
    this.baseTranslateX = this.translateX
    this.baseTranslateY = this.translateY
    this.startRotateAngle = this.rotateAngle
    this.startTouchAngle = this.angleFromCenter(touch)
    this.feedbackText = this.mode === CommonGestureMode.Rotate ? '单指旋转开始:围着图片中心转动' : '单指触摸开始'
  }

  private beginMultiTouch(first: TouchObject, second: TouchObject): void {
    this.isMultiTouching = true
    this.waitForFreshSingleTouch = true
    this.lastTapTime = 0
    this.startPinchDistance = this.distanceBetween(first, second)
    this.startPinchAngle = this.angleBetween(first, second)
    this.startPinchScale = this.scaleValue
    this.startRotateAngle = this.rotateAngle
    this.feedbackText = '双指变换开始:距离控制缩放,连线角度控制旋转'
  }

  private handleTouchStart(event: TouchEvent): void {
    this.gestureAreaActive = true

    if (event.touches.length === 1) {
      let touch: TouchObject = event.touches[0]
      this.beginSingleTouch(touch)
    }

    if (event.touches.length >= 2) {
      let first: TouchObject = event.touches[0]
      let second: TouchObject = event.touches[1]
      this.beginMultiTouch(first, second)
    }
  }

  private handleTouchMove(event: TouchEvent): void {
    this.gestureAreaActive = true

    if (event.touches.length >= 2) {
      let first: TouchObject = event.touches[0]
      let second: TouchObject = event.touches[1]
      if (!this.isMultiTouching) {
        this.beginMultiTouch(first, second)
      }
      let currentDistance: number = this.distanceBetween(first, second)
      let currentAngle: number = this.angleBetween(first, second)

      if (this.startPinchDistance > 0) {
        this.scaleValue = this.clamp(this.startPinchScale * currentDistance / this.startPinchDistance, 0.65, 2.2)
      }
      this.rotateAngle = this.startRotateAngle + currentAngle - this.startPinchAngle
      this.feedbackText = `双指变换:缩放 ${this.scaleValue.toFixed(2)},旋转 ${Math.round(this.rotateAngle)}°`
      return
    }

    if (event.touches.length === 1) {
      if (this.isMultiTouching || this.waitForFreshSingleTouch) {
        this.feedbackText = '双指操作刚结束:先松手,再重新开始单指移动或旋转'
        return
      }

      let touch: TouchObject = event.touches[0]
      if (this.mode === CommonGestureMode.Move) {
        this.translateX = this.baseTranslateX + touch.x - this.touchStartX
        this.translateY = this.baseTranslateY + touch.y - this.touchStartY
        this.feedbackText = `单指移动:x ${Math.round(this.translateX)},y ${Math.round(this.translateY)}`
      } else if (this.mode === CommonGestureMode.Rotate) {
        this.rotateAngle = this.startRotateAngle + this.angleFromCenter(touch) - this.startTouchAngle
        this.feedbackText = `单指旋转:${Math.round(this.rotateAngle)}°`
      }
    }
  }

  private handleTouchEnd(event: TouchEvent): void {
    this.gestureAreaActive = false

    if (this.isMultiTouching || this.waitForFreshSingleTouch) {
      this.isMultiTouching = false
      this.waitForFreshSingleTouch = true
      this.lastTapTime = 0
      this.baseTranslateX = this.translateX
      this.baseTranslateY = this.translateY
      this.startPinchScale = this.scaleValue
      this.startRotateAngle = this.rotateAngle
      this.feedbackText = `双指操作结束:x ${Math.round(this.translateX)},y ${Math.round(this.translateY)},缩放 ${this.scaleValue.toFixed(2)},旋转 ${Math.round(this.rotateAngle)}°`
      this.showTip('双指操作结束')
      return
    }

    if (event.changedTouches.length === 1) {
      let now: number = Date.now()
      if (now - this.lastTapTime < 280) {
        this.lastTapTime = 0
        this.handleDoubleTap()
        return
      }
      this.lastTapTime = now
    }

    this.baseTranslateX = this.translateX
    this.baseTranslateY = this.translateY
    this.startPinchScale = this.scaleValue
    this.startRotateAngle = this.rotateAngle
    this.feedbackText = `手势结束:x ${Math.round(this.translateX)},y ${Math.round(this.translateY)},缩放 ${this.scaleValue.toFixed(2)},旋转 ${Math.round(this.rotateAngle)}°`
    this.showTip('手势结束')
  }

  private handleTouch(event: TouchEvent): void {
    event.stopPropagation()

    if (event.type === TouchType.Down) {
      this.handleTouchStart(event)
    } else if (event.type === TouchType.Move) {
      this.handleTouchMove(event)
    } else if (event.type === TouchType.Up) {
      this.handleTouchEnd(event)
    } else if (event.type === TouchType.Cancel) {
      this.gestureAreaActive = false
      this.isMultiTouching = false
      this.waitForFreshSingleTouch = false
      this.feedbackText = '触摸被系统取消,当前变换保持不变'
      this.showTip('触摸已取消')
    }
  }

  @Builder
  sectionTitle(title: string, desc: string) {
    Column({ space: 6 }) {
      Text(title)
        .fontSize(19)
        .fontWeight(FontWeight.Bold)
        .fontColor('#111827')
      Text(desc)
        .fontSize(13)
        .fontColor('#6B7280')
        .lineHeight(20)
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
  }

  build() {
    Scroll() {
      Column({ space: 22 }) {
        Column({ space: 8 }) {
          Text('ArkUI 常见手势')
            .fontSize(28)
            .fontWeight(FontWeight.Bold)
            .fontColor('#111827')
          Text('手指一动,卡片就该乖乖听话')
            .fontSize(16)
            .fontColor('#4B5563')
          Text('这一页不用多个 Gesture 硬叠在一个节点上,而是用 onTouch 统一读触点。图片用本地 SVG 资源 icon_huawei,移动、双击、旋转、捏合都围着它做。')
            .fontSize(14)
            .fontColor('#6B7280')
            .lineHeight(22)
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)

        this.sectionTitle('先选模式,再动手', '单指移动和单指旋转都会用到 Move 事件,所以这里用模式把意图分开;双指时同时计算缩放和旋转。')
        Row({ space: 8 }) {
          Button({ type: ButtonType.Capsule }) {
            Text('单指移动')
              .fontSize(13)
              .fontWeight(this.mode === CommonGestureMode.Move ? FontWeight.Bold : FontWeight.Medium)
              .fontColor(this.mode === CommonGestureMode.Move ? '#FFFFFF' : '#475569')
          }
          .height(36)
          .padding({ left: 14, right: 14 })
          .backgroundColor(this.mode === CommonGestureMode.Move ? '#111827' : '#FFFFFF')
          .border({ width: 1, color: this.mode === CommonGestureMode.Move ? '#111827' : '#CBD5E1' })
          .onClick(() => {
            this.updateMode(CommonGestureMode.Move, '移动模式:单指拖动图片')
          })

          Button({ type: ButtonType.Capsule }) {
            Text('单指旋转')
              .fontSize(13)
              .fontWeight(this.mode === CommonGestureMode.Rotate ? FontWeight.Bold : FontWeight.Medium)
              .fontColor(this.mode === CommonGestureMode.Rotate ? '#FFFFFF' : '#475569')
          }
          .height(36)
          .padding({ left: 14, right: 14 })
          .backgroundColor(this.mode === CommonGestureMode.Rotate ? '#111827' : '#FFFFFF')
          .border({ width: 1, color: this.mode === CommonGestureMode.Rotate ? '#111827' : '#CBD5E1' })
          .onClick(() => {
            this.updateMode(CommonGestureMode.Rotate, '单指旋转模式:围着图片中心转动')
          })

          Button({ type: ButtonType.Capsule }) {
            Text('双指变换')
              .fontSize(13)
              .fontWeight(this.mode === CommonGestureMode.Transform ? FontWeight.Bold : FontWeight.Medium)
              .fontColor(this.mode === CommonGestureMode.Transform ? '#FFFFFF' : '#475569')
          }
          .height(36)
          .padding({ left: 14, right: 14 })
          .backgroundColor(this.mode === CommonGestureMode.Transform ? '#111827' : '#FFFFFF')
          .border({ width: 1, color: this.mode === CommonGestureMode.Transform ? '#111827' : '#CBD5E1' })
          .onClick(() => {
            this.updateMode(CommonGestureMode.Transform, '双指变换模式:两指缩放和旋转')
          })
        }
        .width('100%')

        Stack({ alignContent: Alignment.Center }) {
          Column({ space: 12 }) {
            Image($r('app.media.icon_huawei'))
              .width(116)
              .height(116)
              .objectFit(ImageFit.Contain)
            Text('双击缩放,单指或双指继续操作')
              .fontSize(13)
              .fontColor('#475569')
          }
          .alignItems(HorizontalAlign.Center)
          .padding(24)
          .borderRadius(24)
          .backgroundColor('#FFFFFF')
          .shadow({ radius: 18, color: '#0000001A', offsetX: 0, offsetY: 8 })
          .translate({ x: this.translateX, y: this.translateY })
          .scale({ x: this.scaleValue, y: this.scaleValue })
          .rotate({ angle: this.rotateAngle })
        }
        .width(300)
        .height(300)
        .alignSelf(ItemAlign.Center)
        .borderRadius(28)
        .backgroundColor('#E0F2FE')
        .border({ width: 1, color: '#BAE6FD' })
        .onTouch((event: TouchEvent) => {
          this.handleTouch(event)
        })

        Column({ space: 8 }) {
          Text(this.feedbackText)
            .fontSize(14)
            .fontColor('#111827')
            .lineHeight(22)
          Text(`当前:x ${Math.round(this.translateX)},y ${Math.round(this.translateY)},缩放 ${this.scaleValue.toFixed(2)},旋转 ${Math.round(this.rotateAngle)}°`)
            .fontSize(13)
            .fontColor('#64748B')
            .lineHeight(20)
        }
        .width('100%')
        .padding(16)
        .borderRadius(14)
        .backgroundColor('#FFFFFF')
        .border({ width: 1, color: '#E5E7EB' })

        Button({ type: ButtonType.Capsule }) {
          Text('重置图片')
            .fontSize(14)
            .fontColor('#FFFFFF')
            .fontWeight(FontWeight.Bold)
        }
        .height(40)
        .width('100%')
        .backgroundColor('#2563EB')
        .onClick(() => {
          this.resetTransform()
        })
      }
      .width('100%')
      .padding(24)
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .height('100%')
    .scrollable(this.gestureAreaActive ? ScrollDirection.None : ScrollDirection.Vertical)
    .backgroundColor('#F8FAFC')
  }
}
【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。