登录表单输错密码,输入框左右抖几下——这个效果你应该见过无数次了。但你可能没想过,这个"抖几下"背后其实挺有讲究的。

我最早实现摇晃动画的时候,想当然地写了一个"左-右-左-右"的等幅振荡。效果出来后怎么看都不对劲,像一个机器在做机械运动,完全没有"抖"的感觉。后来去翻了一些物理动画的资料才明白——真实的摇晃是一个振幅逐渐衰减的过程。就像你推了一下桌上的水杯,它晃几下就停下来了,每一下比上一下幅度小。

今天我们就来在HarmonyOS6 PC端实现一个物理感十足的摇晃动画,并且支持三种不同强度。

效果预览

页面上有一个红色的圆角矩形,上面放着一个感叹号"!"。下面有三个按钮:

  • 轻微摇晃:小幅度抖动,像被轻轻碰了一下
  • 标准摇晃:中等幅度,表单验证失败的经典效果
  • 剧烈摇晃:大幅度抖动,像出了严重错误

三种强度的摇晃使用相同的衰减序列,但基础振幅不同。摇晃结束后,元素自动回到原位。

核心设计:振幅递减序列

摇晃动画的灵魂就是这个数组:

const pattern = [-1, 1, -1, 1, -0.8, 0.8, -0.5, 0.5, -0.2, 0.2, 0]

这个序列模拟了一个真实的物理衰减过程。我们来分析一下:

序号 含义
0 -1 向左最大偏移
1 1 向右最大偏移
2 -1 向左最大偏移
3 1 向右最大偏移
4 -0.8 向左偏移,振幅开始衰减
5 0.8 向右偏移
6 -0.5 向左偏移,继续衰减
7 0.5 向右偏移
8 -0.2 向左偏移,接近静止
9 0.2 向右偏移
10 0 回到中心

你看,前四次是满幅度振荡(-1和1),然后逐步衰减到0.8、0.5、0.2,最后归零。这个过程模拟的就是阻尼振动——初始能量大,但随着摩擦/阻尼消耗能量,振幅越来越小。

如果你改成等幅振荡比如 [-1, 1, -1, 1, -1, 1, 0],效果就会像电动牙刷——机械、生硬、不自然。衰减才是摇晃动画的关键

状态变量

@Entry
@Component
struct ShakeDemo {
  @State shakeX: number = 0
  @State shakeIntensity: number = 1
  // ...
}

两个状态变量:

  • shakeX:当前帧的偏移基准值,取 pattern 数组中的值(-1到1之间)
  • shakeIntensity:摇晃强度系数,0.5为轻微,1为标准,1.5为剧烈

实际的像素偏移量通过公式计算:

实际偏移 = shakeX × shakeIntensity × 10

所以标准摇晃(intensity=1)的最大偏移是 ±10px,轻微摇晃(intensity=0.5)是 ±5px,剧烈摇晃(intensity=1.5)是 ±15px。

这个设计很巧妙——用同一个衰减序列,通过乘以不同的强度系数,就实现了三种摇晃效果。不需要写三组不同的序列。

摇晃执行逻辑

核心的摇晃函数 _doShake 是这样的:

_doShake() {
  const pattern = [-1, 1, -1, 1, -0.8, 0.8, -0.5, 0.5, -0.2, 0.2, 0]
  let i = 0
  const step = () => {
    if (i < pattern.length) {
      this.shakeX = pattern[i]
      i++
      setTimeout(step, 50)
    } else {
      this.shakeX = 0
    }
  }
  step()
}

逻辑很直白:

  1. 定义衰减序列 pattern
  2. 用递归 setTimeout 每50ms执行一步
  3. 每一步把 shakeX 设为序列中对应的值
  4. 序列跑完后,确保 shakeX 回到0

为什么是50ms?

50ms的步进时间意味着每帧之间的间隔是50ms,对应20fps的帧率。对于摇晃这种"快速抖动"效果来说,这个帧率是合适的。

为什么不用更高帧率?因为摇晃动画的本质是在离散位置之间跳变,它不是平滑过渡——每一帧元素突然出现在一个新位置,模拟的就是物理振动中那种"快速来回"的感觉。如果帧率太高(比如60fps),反而会变成平滑的来回移动,失去"抖"的质感。

如果你想要更柔和的摇晃效果,可以把步进时间改成60ms或70ms。如果想要更急促的效果,改成30ms或40ms。

整个摇晃过程的总时长

11帧 × 50ms = 550ms。大概半秒的摇晃时间,这跟你在各种应用中见到的错误提示抖动时长基本一致。太短了来不及抖出衰减感,太长了又显得拖沓。500-600ms是个非常舒适的区间。

按钮触发:强度控制

三个按钮的点击逻辑非常简单:

Row({ space: 10 }) {
  Button('轻微摇晃')
    .onClick(() => {
      this.shakeIntensity = 0.5
      this._doShake()
    })
  Button('标准摇晃')
    .onClick(() => {
      this.shakeIntensity = 1
      this._doShake()
    })
  Button('剧烈摇晃')
    .onClick(() => {
      this.shakeIntensity = 1.5
      this._doShake()
    })
}
.width('100%')
.justifyContent(FlexAlign.SpaceEvenly)
.margin({ top: 12 })

先设置强度系数,再调用 _doShake()。因为 _doShake 内部读取的是 this.shakeIntensity 的当前值,所以设置和调用的顺序很重要——必须先设强度再触发摇晃。

视觉元素的动画绑定

被摇晃的元素通过 .translate().animation() 来响应状态变化:

Column()
  .width(100)
  .height(60)
  .backgroundColor('#FF6B6B')
  .borderRadius(12)
  .translate({ x: this.shakeX * this.shakeIntensity * 10 })
  .animation({ duration: 50, curve: Curve.Linear })

几个值得注意的点:

.translate({ x: ... }) 把水平偏移量绑定到一个计算表达式上。每当 shakeX 变化,偏移量跟着变。

.animation({ duration: 50, curve: Curve.Linear }) 这个50ms的动画时长正好等于步进间隔。也就是说,每一帧的偏移变化会在50ms内用线性插值完成——刚好在下一帧开始时完成过渡。这样视觉上就是连续的平滑移动,而不是突然跳变。

如果你把 .animation()duration 设成0,就会变成纯跳变——每一帧元素瞬间出现在新位置。对于摇晃动画来说,带50ms的线性过渡效果更好,视觉上更"顺滑"。

完整代码

@Entry
@Component
struct ShakeDemo {
  @State shakeX: number = 0
  @State shakeIntensity: number = 1

  build() {
    Column() {
      Scroll() {
        Column() {
          Text('摇晃动画')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .margin({ bottom: 8 })

          Column() {
            // 摇晃目标
            Row() {
              Column()
                .width(100)
                .height(60)
                .backgroundColor('#FF6B6B')
                .borderRadius(12)
                .translate({ x: this.shakeX * this.shakeIntensity * 10 })
                .animation({ duration: 50, curve: Curve.Linear })
              Column() {
                Text('!')
                  .fontSize(30)
                  .fontColor('#FFFFFF')
                  .fontWeight(FontWeight.Bold)
              }
              .width('100%')
              .height('100%')
              .position({ x: 0, y: 0 })
              .justifyContent(FlexAlign.Center)
            }
            .position({ x: 0, y: 0 })
            .width(100)
            .height(60)

            Row().height(80)

            // 控制按钮
            Row({ space: 10 }) {
              Button('轻微摇晃')
                .onClick(() => {
                  this.shakeIntensity = 0.5
                  this._doShake()
                })
              Button('标准摇晃')
                .onClick(() => {
                  this.shakeIntensity = 1
                  this._doShake()
                })
              Button('剧烈摇晃')
                .onClick(() => {
                  this.shakeIntensity = 1.5
                  this._doShake()
                })
            }
            .width('100%')
            .justifyContent(FlexAlign.SpaceEvenly)
            .margin({ top: 12 })
          }
          .width('100%')
          .backgroundColor('#FFFFFF')
          .borderRadius(12)
          .padding(16)
          .alignItems(HorizontalAlign.Center)
        }
        .width('100%')
      }
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F6FA')
    .padding(16)
  }

  _doShake() {
    const pattern = [-1, 1, -1, 1, -0.8, 0.8, -0.5, 0.5, -0.2, 0.2, 0]
    let i = 0
    const step = () => {
      if (i < pattern.length) {
        this.shakeX = pattern[i]
        i++
        setTimeout(step, 50)
      } else {
        this.shakeX = 0
      }
    }
    step()
  }
}

摇晃动画在PC端的实际应用场景

摇晃动画在PC端的使用场景比大家想象的要多。

表单验证失败

这是最经典的场景。用户输错了密码、填了不合法的邮箱、漏了必填项——输入框左右抖几下,比任何红色文字都更能引起注意。

在PC端的表单场景里,摇晃动画有一个隐藏的好处:PC用户通常会同时开多个窗口,注意力不一定在当前应用上。一个摇晃动画能有效地把用户的视线"拉"回来。

// 模拟表单验证场景
Button('登录')
  .onClick(() => {
    if (password.length < 6) {
      this.shakeIntensity = 1
      this._doShake()
      this.errorText = '密码不能少于6位'
    }
  })

错误提示/警告框

不只是输入框,整个错误提示卡片也可以做摇晃效果。比如一个弹出的错误通知,出现的时候先左右抖一下,然后再显示错误详情。这比直接弹出来更有"警告"的意味。

操作失败反馈

拖拽文件到不支持的区域、尝试删除不可删除的项目、点击灰色的禁用按钮——这些"操作被拒绝"的场景都可以用摇晃来反馈。比起冷冰冰的弹窗提示,摇晃动画更温和也更直觉。

删除确认

用户点击删除按钮时,被删除的项目先摇晃一下,给用户一个"你确定吗?"的暗示。如果用户再次点击确认,才真正删除。这种交互比传统的确认对话框更轻量。

衰减动画序列的设计方法

摇晃动画的核心——振幅递减序列——其实是一种通用的动画设计模式。掌握了这个模式,你可以用它来做很多不同的效果。

弹性落地效果

一个元素从高处落下,着地后弹跳几次,每次弹起的高度递减:

const bouncePattern = [0, -50, 0, -30, 0, -15, 0, -5, 0]
// 负值表示向上弹起,0表示地面

果冻晃动效果

一个元素被点击后像果冻一样晃动,幅度逐渐减小:

const jellyPattern = [1.2, 0.8, 1.1, 0.9, 1.05, 0.95, 1]
// 这些是缩放因子,1是正常大小

弹簧回弹效果

一个滑块被拖拽后松手,回到原位的过程中有过冲和回弹:

const springPattern = [1, -0.3, 0.15, -0.05, 0]
// 1是起点(被拖到的位置),0是终点(原位),中间是过冲和回弹

这些效果的共同点是:不是简单的从A到B的线性运动,而是有一个振荡衰减的过程。这种运动模式更接近物理世界,所以看起来更自然、更舒服。

设计衰减序列的两个原则:

  1. 交替符号。正负交替才能产生"来回"的感觉。如果符号不交替,就只是单方向的缓动。

  2. 递减幅度。每个周期的峰值应该比前一个小。衰减比率不必是等比的——前几次可以衰减得慢一些(保持动能),后面衰减得快一些(快速收敛)。这样更接近真实的阻尼振动。

踩坑记录

坑1:摇晃结束后元素没有回到原位

如果你忘了在 pattern 数组末尾加 0,或者在循环结束后没有手动设置 this.shakeX = 0,元素就会停在一个偏移位置上。一定要确保最终状态是 shakeX = 0

坑2:快速连续点击导致动画叠加

如果用户疯狂点击"标准摇晃"按钮,每次点击都会启动一个新的 _doShake 调用。多个摇晃序列同时修改 shakeX,视觉上会非常混乱。

解决方案:加一个"正在摇晃"的锁。

@State isShaking: boolean = false

_doShake() {
  if (this.isShaking) return
  this.isShaking = true
  
  const pattern = [-1, 1, -1, 1, -0.8, 0.8, -0.5, 0.5, -0.2, 0.2, 0]
  let i = 0
  const step = () => {
    if (i < pattern.length) {
      this.shakeX = pattern[i]
      i++
      setTimeout(step, 50)
    } else {
      this.shakeX = 0
      this.isShaking = false
    }
  }
  step()
}

坑3:.animation() 的 duration 跟步进时间不匹配

如果 .animation()duration 远大于步进时间(比如设了200ms),每一帧的过渡还没完成就被下一帧覆盖了,视觉上会出现"拖影"的效果。建议 duration 等于或略小于步进时间。

扩展:用 animateTo 替代逐帧控制

如果你觉得逐帧控制太"底层",也可以用 animateTo 链来实现类似的效果。思路是把每一帧的偏移作为一次 animateTo 调用:

_doShakeAnimated() {
  const pattern = [-1, 1, -1, 1, -0.8, 0.8, -0.5, 0.5, -0.2, 0.2, 0]
  let i = 0
  const doStep = () => {
    if (i >= pattern.length) return
    animateTo({ duration: 50, curve: Curve.Linear }, () => {
      this.shakeX = pattern[i]
    })
    i++
    setTimeout(doStep, 50)
  }
  doStep()
}

两种方式的最终效果差不多,但 animateTo 方式更容易跟其他动画效果组合——比如你想在摇晃的同时做一个短暂的缩放或颜色变化,在 animateTo 闭包里加一行就行了。

小结

摇晃动画看起来简单,但它背后的"振幅递减序列"是个非常有价值的动画设计模式。核心要点:

  • pattern 数组定义了摇晃的运动轨迹:正负交替+幅度递减
  • 递归 setTimeout 每50ms走一步,550ms完成整个摇晃
  • shakeIntensity 系数控制摇晃强度,同一个序列实现多种效果
  • .translate() + .animation() 负责视觉呈现

在HarmonyOS6 PC端的表单验证、错误提示、操作反馈这些场景中,摇晃动画都是非常好的选择。它比弹窗更轻量,比纯文字提示更有存在感,是一个"恰到好处"的交互反馈方式。

Logo

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

更多推荐