滑动速度检测封面图

搞了半天才发现,SwipeGesture不仅能检测滑动方向,还能通过speed属性拿到滑动速度。这个功能说实话挺实用的,很多交互场景都需要区分用户是快速甩了一下还是慢慢拖了一下。比如新闻App里快速滑动是翻页,慢速滑动只是滚动内容,这两种操作的体验完全不同。

speed属性是什么

SwipeGesture的onAction回调会给你一个GestureEvent对象,里面有个speed属性,单位是像素每秒(px/s)。这个值表示的是手指(或鼠标)在屏幕上滑动的瞬时速度。

坦白讲,这个speed值比我预期的好用很多。它不是简单地算位移除以时间,而是系统内部做了平滑处理,不会因为手指抖动就出现很大的波动。实测下来,正常速度的滑动大概在100到500 px/s之间,用力甩一下可以到1000以上,慢慢拖的话一般在100以下。

速度阈值的设定

滑动速度检测概念图

区分快滑和慢滑的关键在于阈值怎么定。我给的建议是:

  • 慢速:< 200 px/s,用户在仔细浏览内容
  • 中速:200 ~ 600 px/s,正常滑动
  • 快速:> 600 px/s,用户在快速翻页或甩动

当然这个阈值不是死的,具体要根据你的应用场景来调。比如列表很长的时候,用户滑得自然就快一些,阈值可以适当提高。

完整案例代码

下面这个例子做一个水平滑动区域,根据滑动速度显示不同的视觉反馈:

@Entry
@Component
struct SwipeSpeedDemo {
  @State speedLevel: string = '在下方区域滑动试试'
  @State speedValue: number = 0
  @State bgColor: string = '#F5F6FA'
  @State textColor: string = '#8E8E93'
  @State emoji: string = '👆'
  @State fastCount: number = 0
  @State normalCount: number = 0
  @State slowCount: number = 0
  @State barWidth: number = 0
  @State barColor: string = '#C7C7CC'

  classifySpeed(speed: number): string {
    if (speed > 600) {
      return 'fast'
    } else if (speed > 200) {
      return 'normal'
    } else {
      return 'slow'
    }
  }

  updateUI(level: string, speed: number) {
    this.speedValue = speed

    if (level === 'fast') {
      this.speedLevel = '快速滑动!'
      this.bgColor = '#E8F5E9'
      this.textColor = '#2E7D32'
      this.emoji = '🚀'
      this.fastCount += 1
      this.barColor = '#4CAF50'
    } else if (level === 'normal') {
      this.speedLevel = '正常滑动'
      this.bgColor = '#E3F2FD'
      this.textColor = '#1565C0'
      this.emoji = '👌'
      this.normalCount += 1
      this.barColor = '#2196F3'
    } else {
      this.speedLevel = '慢速滑动'
      this.bgColor = '#FFF3E0'
      this.textColor = '#E65100'
      this.emoji = '🐢'
      this.slowCount += 1
      this.barColor = '#FF9800'
    }

    this.barWidth = Math.min(speed / 10, 100)
  }

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

      Text('通过速度区分快滑、正常滑动和慢速滑动')
        .fontSize(14)
        .fontColor('#8E8E93')
        .margin({ bottom: 20 })

      // 统计区域
      Row() {
        this.SpeedStat('快速', this.fastCount, '#4CAF50', '>600 px/s')
        this.SpeedStat('正常', this.normalCount, '#2196F3', '200-600')
        this.SpeedStat('慢速', this.slowCount, '#FF9800', '<200 px/s')
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceEvenly)
      .padding({ left: 16, right: 16 })

      // 速度结果显示
      Column() {
        Text(this.emoji)
          .fontSize(48)
          .margin({ bottom: 8 })

        Text(this.speedLevel)
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
          .fontColor(this.textColor)

        if (this.speedValue > 0) {
          Text(`${this.speedValue.toFixed(0)} px/s`)
            .fontSize(14)
            .fontColor('#8E8E93')
            .margin({ top: 6 })
        }
      }
      .width('90%')
      .height(160)
      .justifyContent(FlexAlign.Center)
      .backgroundColor(this.bgColor)
      .borderRadius(16)
      .margin({ top: 20 })
      .animation({ duration: 300, curve: Curve.EaseOut })

      // 速度条
      Column() {
        Stack({ alignContent: Alignment.Start }) {
          // 背景条
          Row()
            .width('100%')
            .height(12)
            .backgroundColor('#E0E0E0')
            .borderRadius(6)

          // 速度填充条
          Row()
            .width(`${this.barWidth}%`)
            .height(12)
            .backgroundColor(this.barColor)
            .borderRadius(6)
            .animation({ duration: 300, curve: Curve.EaseOut })
        }
        .width('100%')

        Row() {
          Text('0')
            .fontSize(11)
            .fontColor('#8E8E93')
          Blank()
          Text('200')
            .fontSize(11)
            .fontColor('#8E8E93')
          Blank()
          Text('600')
            .fontSize(11)
            .fontColor('#8E8E93')
          Blank()
          Text('1000+')
            .fontSize(11)
            .fontColor('#8E8E93')
        }
        .width('100%')
        .margin({ top: 4 })
      }
      .width('90%')
      .padding({ left: 10, right: 10 })
      .margin({ top: 16 })

      // 滑动区域
      Column() {
        Text('在此区域滑动')
          .fontSize(18)
          .fontWeight(FontWeight.Medium)
          .fontColor('#FFFFFF')
          .opacity(0.9)

        Text('试试不同速度的滑动效果')
          .fontSize(13)
          .fontColor('#FFFFFF')
          .opacity(0.6)
          .margin({ top: 8 })
      }
      .width('90%')
      .height(200)
      .justifyContent(FlexAlign.Center)
      .backgroundColor('#6C5CE7')
      .borderRadius(20)
      .margin({ top: 20 })
      .gesture(
        SwipeGesture({ direction: SwipeDirection.Horizontal })
          .onAction((e: GestureEvent) => {
            const speed = e.speed
            const level = this.classifySpeed(speed)
            this.updateUI(level, speed)
          })
      )

      // 阈值说明
      Row() {
        Text('阈值参考: ')
          .fontSize(12)
          .fontColor('#8E8E93')
        Text('慢速 < 200')
          .fontSize(12)
          .fontColor('#FF9800')
        Text(' | ')
          .fontSize(12)
          .fontColor('#C7C7CC')
        Text('正常 200-600')
          .fontSize(12)
          .fontColor('#2196F3')
        Text(' | ')
          .fontSize(12)
          .fontColor('#C7C7CC')
        Text('快速 > 600')
          .fontSize(12)
          .fontColor('#4CAF50')
      }
      .margin({ top: 16 })

      Button('重置')
        .fontSize(15)
        .fontColor('#6C5CE7')
        .backgroundColor('#F5F6FA')
        .borderRadius(12)
        .width('30%')
        .height(40)
        .margin({ top: 12 })
        .onClick(() => {
          this.speedLevel = '在下方区域滑动试试'
          this.speedValue = 0
          this.bgColor = '#F5F6FA'
          this.textColor = '#8E8E93'
          this.emoji = '👆'
          this.fastCount = 0
          this.normalCount = 0
          this.slowCount = 0
          this.barWidth = 0
        })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFFFFF')
  }

  @Builder
  SpeedStat(label: string, count: number, color: string, range: string) {
    Column() {
      Text(label)
        .fontSize(13)
        .fontColor(color)
        .fontWeight(FontWeight.Medium)
      Text(`${count}`)
        .fontSize(26)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1A1A2E')
        .margin({ top: 2 })
      Text(range)
        .fontSize(10)
        .fontColor('#C7C7CC')
        .margin({ top: 2 })
    }
    .width(90)
    .height(75)
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#F5F6FA')
    .borderRadius(12)
  }
}

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

代码解析

整个页面的布局是一个从上到下的Column。最上面是标题和统计卡片,中间是速度显示区和速度条,底部是滑动操作区和说明。

classifySpeed方法就是核心逻辑了——根据speed值返回’fast’、‘normal’或’slow’。200和600这两个阈值是我反复试出来的,手感上比较合理。你也可以根据自己的需要调整。

updateUI方法负责根据不同的速度等级更新所有的状态变量。这里用了@State的特性,只要状态变量一变,相关的UI组件就会自动刷新。背景色、文字颜色、emoji表情、计数器全部联动更新,代码虽然多但逻辑很清晰。

速度条那个部分用了Stack叠放两层Row,底层是灰色背景条,上层是带颜色的填充条。填充条的宽度用百分比表示,根据速度值动态计算。加了个animation让过渡更自然。

PC端的速度体验差异

这里有个坑要提醒大家:PC端用鼠标滑动和手机上用手指滑动,速度感知完全不同。

鼠标操作的时候,用户轻轻一甩速度就能到800以上,因为鼠标的DPI高,移动同样的物理距离在屏幕上对应的像素值更大。所以如果你这个页面同时要在手机和PC上跑,PC端的阈值建议调高一些,比如慢速改成300,快速改成800。

还有一种做法是判断当前设备类型,根据设备动态调整阈值。HarmonyOS6提供了设备类型判断的API,可以在页面初始化时获取设备信息,然后设置不同的阈值参数。

speed值的可靠性

说实话,speed这个值在大多数情况下是可靠的,但有两个边界情况需要注意。

第一个是滑动距离太短的情况。如果用户只是轻轻蹭了一下,位移只有几个像素,这时候算出来的speed可能不太准,因为采样点太少了。建议在业务逻辑里加一个最小位移的判断,位移太小的滑动直接忽略。

第二个是滑动过程中有明显的停顿。比如用户开始滑得很快,中间停了一下,最后又甩了一下。这种情况下speed返回的是最后一段的速度,不是整个过程的平均速度。如果你的场景对这种情况敏感,建议自己记录时间戳和位移来算平均速度。

速度检测跟方向检测的配合

前面一篇文章讲了SwipeGesture的方向检测,其实speed和angle是同时返回的,可以配合使用。比如做一个"甩一甩"功能,不仅要求方向对,还要求速度够快才算有效操作。

举个具体的例子:图片浏览器里左滑翻页,如果只是慢慢向左拖,可能只是想看图片的局部内容(放大状态下);只有快速向左甩才是真正的翻到下一张。这种场景就需要同时判断方向和速度,两个条件都满足才执行翻页。

代码层面就是把上一篇文章的getDirection和这篇文章的classifySpeed组合起来用。在onAction回调里先判断方向,再判断速度,两个条件都符合才执行业务逻辑。这种组合判断在实际项目中非常常见。

速度分级动画效果

这篇文章的案例里,不同速度等级用不同的背景色和emoji来区分。说实话这个动画效果还可以做得更好。比如快速滑动的时候可以做一个"震动"效果——元素先往滑动方向移动一小段,然后弹回来,暗示用户"这次滑动力量很大"。

实现方式也很简单,在updateUI里根据速度等级设置一个临时的translate偏移,然后用animateTo做一个弹性回弹。整个过程不超过200ms,但体验上会好很多。慢速滑动就不需要这个效果了,保持平稳即可。

还有一个思路是根据速度动态调整元素的大小。快速滑动时元素稍微缩小一点(scale设为0.95),给人一种"被风吹到"的感觉。慢速滑动时保持原样。这些微小的视觉细节加在一起,会让整个交互显得特别精致。

实际应用场景

速度检测最经典的应用就是列表的惯性滚动。快速滑动列表时,列表会以较快的速度继续滚动一段距离再停下;慢速滑动时,列表几乎立即停住。这个效果的实现就是根据speed值来计算惯性滚动的初始速度和衰减系数。

另一个场景是下拉刷新。很多App要求用户快速下拉才触发刷新,慢慢拖下来只是看看内容。这个区分就可以用speed来实现,设定一个速度阈值,超过阈值才进入刷新状态。

还有一个比较有趣的场景是"摇骰子"。用户快速甩一下手机(或者在PC端快速拖拽一下骰子元素),根据速度大小来决定骰子转动的圈数和最终结果。速度越快,骰子转得越久,结果越"随机"。这种小游戏类的交互用速度检测来做非常合适。

在PC端还有一个容易被忽视的应用场景:鼠标滚轮事件。虽然滚轮事件和SwipeGesture是不同的API,但速度检测的思路可以复用。比如一个长表单页面,用户快速滚动滚轮时自动折叠不重要的区域,慢速滚动时展开所有内容。这种"智能滚动"体验让长页面的浏览效率大幅提升。

写在最后

滑动速度检测是一个被低估的功能,很多开发者只用SwipeGesture来判断方向,完全忽略了speed这个宝藏属性。实际上,合理利用速度信息可以让交互体验提升一个档次——用户随手一甩就能快速翻页,慢慢滑动可以精细浏览,这种差异化的反馈会让App用起来特别舒服。

Logo

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

更多推荐