滑动手势方向检测封面图

折腾了一下午,总算把SwipeGesture的方向检测给玩明白了。说实话,一开始我觉得滑动检测不就是判断上下左右嘛,能有多难?结果真正上手才发现,angle这个参数的含义跟我想象中完全不一样,踩了不少坑。今天就把这些经验整理出来,省得大家再走弯路。

为什么要做方向检测

在HarmonyOS6 PC端开发中,滑动手势是最常见的交互方式之一。你想想,翻页、切换Tab、展开收起菜单,哪个不需要判断滑动方向?但系统只给你一个SwipeGesture,它并不知道你到底是往上滑还是往右滑——这得你自己通过angle来判断。

坦白讲,很多教程只告诉你用SwipeDirection来限定方向,比如只监听水平或垂直滑动。但实际项目中,经常需要在全方向滑动的基础上做方向识别,比如一个画板应用,用户可能朝任何方向滑动,你需要知道具体是哪个方向。

SwipeGesture的angle到底怎么算

滑动手势方向检测概念图

这是最容易搞混的地方。SwipeGesture的onAction回调会给你一个SwipeGestureEvent对象,里面有angle和speed两个关键属性。

angle的单位是角度,表示滑动的方向。这里有个关键点:angle是以水平向右为0度,逆时针方向递增。也就是说:

  • 向右滑:angle接近0或360
  • 向上滑:angle接近90
  • 向左滑:angle接近180
  • 向下滑:angle接近270

搞清楚这个对应关系之后,方向判断就变成了一道简单的数学题。

方向判断的逻辑

我的判断策略是把360度分成四个扇区,每个扇区90度:

  • 上:angle >= 45 且 angle < 135
  • 左:angle >= 135 且 angle < 225
  • 下:angle >= 225 且 angle < 315
  • 右:angle >= 315 或 angle < 45

这个划分方式很直观,每个方向占据90度的范围,临界点就是45度、135度、225度、315度这几个对角线方向。

完整案例代码

下面这个例子做了一个方向检测面板,在中间的滑动区域任意方向滑动,就能识别出方向并统计次数:

@Entry
@Component
struct SwipeDirectionDemo {
  @State swipertit: string = '请在下方区域滑动'
  @State upCount: number = 0
  @State downCount: number = 0
  @State leftCount: number = 0
  @State rightCount: number = 0
  @State totalCount: number = 0
  @State lastAngle: number = 0
  @State lastSpeed: number = 0
  @State arrowRotation: number = 0

  getDirection(angle: number): string {
    if ((angle >= 315 || angle < 45)) {
      return '右'
    } else if (angle >= 45 && angle < 135) {
      return '上'
    } else if (angle >= 135 && angle < 225) {
      return '左'
    } else {
      return '下'
    }
  }

  getArrowAngle(dir: string): number {
    switch (dir) {
      case '右': return 0
      case '上': return -90
      case '左': return 180
      case '下': return 90
      default: return 0
    }
  }

  build() {
    Column() {
      Text('滑动方向检测')
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1A1A2E')
        .margin({ top: 30, bottom: 10 })

      Text(`累计滑动: ${this.totalCount}`)
        .fontSize(14)
        .fontColor('#8E8E93')
        .margin({ bottom: 20 })

      // 方向指示器
      Row() {
        this.DirectionCard('上', this.upCount, '#FF6B6B')
        this.DirectionCard('左', this.leftCount, '#4ECDC4')
        this.DirectionCard('右', this.rightCount, '#45B7D1')
        this.DirectionCard('下', this.downCount, '#96CEB4')
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceEvenly)
      .padding({ left: 16, right: 16 })

      // 当前方向显示
      Column() {
        Text(this.swipertit)
          .fontSize(36)
          .fontWeight(FontWeight.Bold)
          .fontColor(this.swipertit === '请在下方区域滑动' ? '#C7C7CC' : '#1A1A2E')

        if (this.swipertit !== '请在下方区域滑动') {
          Text(`角度: ${this.lastAngle.toFixed(1)}° | 速度: ${this.lastSpeed.toFixed(0)} px/s`)
            .fontSize(13)
            .fontColor('#8E8E93')
            .margin({ top: 8 })
        }
      }
      .width('90%')
      .height(120)
      .justifyContent(FlexAlign.Center)
      .backgroundColor('#F5F6FA')
      .borderRadius(16)
      .margin({ top: 20 })

      // 滑动检测区域
      Column() {
        Text('在此区域任意方向滑动')
          .fontSize(16)
          .fontColor('#FFFFFF')
          .opacity(0.8)

        Text('支持上下左右全方向检测')
          .fontSize(13)
          .fontColor('#FFFFFF')
          .opacity(0.6)
          .margin({ top: 8 })
      }
      .width('90%')
      .height(280)
      .justifyContent(FlexAlign.Center)
      .backgroundColor('#6C5CE7')
      .borderRadius(20)
      .margin({ top: 20 })
      .gesture(
        SwipeGesture({ direction: SwipeDirection.All })
          .onAction((e: GestureEvent) => {
            const angle = e.angle
            const speed = e.speed
            const dir = this.getDirection(angle)

            this.swipertit = `滑动方向: ${dir}`
            this.lastAngle = angle
            this.lastSpeed = speed
            this.totalCount += 1
            this.arrowRotation = this.getArrowAngle(dir)

            switch (dir) {
              case '上':
                this.upCount += 1
                break
              case '下':
                this.downCount += 1
                break
              case '左':
                this.leftCount += 1
                break
              case '右':
                this.rightCount += 1
                break
            }
          })
      )

      // 重置按钮
      Button('重置统计')
        .fontSize(15)
        .fontColor('#6C5CE7')
        .backgroundColor('#F5F6FA')
        .borderRadius(12)
        .width('40%')
        .height(44)
        .margin({ top: 20 })
        .onClick(() => {
          this.swipertit = '请在下方区域滑动'
          this.upCount = 0
          this.downCount = 0
          this.leftCount = 0
          this.rightCount = 0
          this.totalCount = 0
          this.lastAngle = 0
          this.lastSpeed = 0
        })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFFFFF')
  }

  @Builder
  DirectionCard(label: string, count: number, color: string) {
    Column() {
      Text(label)
        .fontSize(14)
        .fontColor(color)
        .fontWeight(FontWeight.Medium)

      Text(`${count}`)
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1A1A2E')
        .margin({ top: 4 })
    }
    .width(70)
    .height(70)
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#F5F6FA')
    .borderRadius(12)
  }
}

运行效果如图:
在这里插入图片描述

代码解析

这段代码的核心思路其实很简单。最外层是一个Column布局,从上到下依次是标题、方向统计卡片、当前方向显示区、滑动检测区和重置按钮。

方向判断的关键在getDirection这个方法。它接收SwipeGesture回调中的angle值,根据角度范围返回对应的方向字符串。四个方向的划分就是前面说的四个90度扇区。

统计部分用了五个@State变量分别记录上、下、左、右和总次数。每次滑动触发onAction回调时,先判断方向,再给对应的计数器加1。因为都是@State修饰的,UI会自动刷新,不需要手动触发更新。

底部的重置按钮就是把所有状态变量清零,简单粗暴但有效。

PC端适配注意事项

在HarmonyOS6 PC端使用SwipeGesture时,有几个点需要注意。

鼠标操作和触屏操作的行为不太一样。PC端用户更习惯用鼠标滚轮和拖拽,纯滑动手势的触发频率可能比手机端低。所以在设计交互时,建议给PC端用户多提供一种操作方式,比如键盘方向键或者按钮点击作为备选。

另外,PC端窗口大小变化比较频繁,滑动区域的尺寸最好用百分比而不是固定值,这样窗口缩放时体验不会太差。上面代码里用的width('90%')就是这个考虑。

还有一点,PC端的鼠标精度比手指高得多,所以angle的判断阈值可以适当收紧。比如手机上45度的扇区范围,PC端可以缩小到30度,让方向判断更精确。不过这个要看具体场景,如果是游戏类应用就保持宽松一些。

关于angle的一些补充

有同学可能会问,angle的值域到底是什么?实测下来,SwipeGesture的angle范围是0到360度,不会出现负数。这跟一些其他平台的手势API不太一样,有的平台会用-180到180的范围。HarmonyOS统一用0到360,反而更简单。

还有一个细节,如果用户的滑动轨迹不是直线而是弧线,angle返回的是起点到终点的直线方向,不是整个轨迹的方向。这一点在做手写识别之类的高级功能时要特别注意,不过对于基本的方向检测来说影响不大。

SwipeDirection参数的选择

SwipeGesture构造函数的direction参数支持SwipeDirection.All、SwipeDirection.Horizontal、SwipeDirection.Vertical等选项。很多人以为设了Horizontal就只检测左右滑动,设了Vertical就只检测上下滑动。这个理解没错,但有个容易忽略的点:即便限定了方向,onAction回调里的angle值仍然是精确的角度值,不会因为你限定了Horizontal就只返回0或180。

所以在实际使用中,如果你想同时检测四个方向,就设成All。如果只想检测水平方向的滑动(比如左右翻页),可以设成Horizontal,这样垂直方向的滑动就不会误触发了。代码里我用的就是All,因为要检测四个方向。

对角线方向怎么处理

上面的四方向判断把对角线方向(45度、135度等)当作了临界值。如果用户的滑动角度恰好是45度,到底算"上"还是"右"?在我这个实现里,45度被归入了"上"的扇区(>= 45且 < 135)。这种处理方式在大多数场景下没问题,因为用户很难精确地沿45度方向滑动,偏差个几度很正常。

但如果你的应用需要八方向检测(上、下、左、右、左上、右上、左下、右下),那就得把360度分成八个扇区,每个45度。实现方式跟四方向类似,只是多了一层判断。八方向检测在手写输入和游戏场景里用得比较多。

方向检测的抖动问题

实际测试中会发现一个问题:用户滑动的时候手指会有轻微抖动,导致连续多次滑动检测出来的方向不太稳定。比如用户明明是想向右滑,但第一次检测出来是"右上",第二次又变成了"右"。

解决这个问题有两个思路。一是增加一个方向确认机制——连续两次检测到相同方向才最终确认,这样可以过滤掉偶发的抖动。二是对angle做滑动平均,取最近几次检测的角度平均值来判断方向。两种方法都有效,具体用哪种看你的场景需求。

实际项目中的应用场景

方向检测在实际项目中用得最多的场景大概就是图片浏览器的翻页了。左滑下一张,右滑上一张,这是最基本的。再进阶一点,上滑显示详情,下滑返回列表,四个方向各管一个功能。

另一个常见场景是游戏。比如做一个拼图游戏,用户滑动拼图块到指定位置,需要精确判断方向。或者做一个2048那样的数字游戏,四个方向的滑动直接控制数字方块的移动。

还有一个场景是音乐播放器。很多音乐App用手势控制播放——左滑下一首、右滑上一首、上滑查看歌词、下滑最小化播放界面。四个方向对应四个高频操作,用起来非常顺手。

调试小技巧

调试SwipeGesture的时候,建议先把angle和speed的原始值打印出来看看。我当时的做法就是在onAction回调里加一个console.log,然后在预览器里各种方向滑,观察不同方向对应的angle值。滑了大概二十几次之后,就对手感和angle的对应关系有了直觉。

另外,Previewer里用鼠标模拟滑动有时候不太方便,特别是斜向滑动。建议在真机上测试,或者用鼠标按住Shift键来模拟更精确的方向。调试阶段多花点时间在数据观察上,后面写判断逻辑就会顺利很多。

写在最后

SwipeGesture的方向检测本质上就是角度判断,搞清楚angle的对应关系就没什么难度了。上面这个例子虽然简单,但覆盖了方向检测的核心逻辑,拿到项目中稍微改改就能用。

后面还会继续写手势相关的系列文章,包括速度检测、手势组合这些更高级的玩法,感兴趣的可以关注一下。

Logo

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

更多推荐