HarmonyOS6 PC 开发实战:摇晃动画——用代码模拟物理抖动效果
登录表单输错密码,输入框左右抖几下——这个效果你应该见过无数次了。但你可能没想过,这个"抖几下"背后其实挺有讲究的。
我最早实现摇晃动画的时候,想当然地写了一个"左-右-左-右"的等幅振荡。效果出来后怎么看都不对劲,像一个机器在做机械运动,完全没有"抖"的感觉。后来去翻了一些物理动画的资料才明白——真实的摇晃是一个振幅逐渐衰减的过程。就像你推了一下桌上的水杯,它晃几下就停下来了,每一下比上一下幅度小。
今天我们就来在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()
}
逻辑很直白:
- 定义衰减序列
pattern - 用递归
setTimeout每50ms执行一步 - 每一步把
shakeX设为序列中对应的值 - 序列跑完后,确保
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:摇晃结束后元素没有回到原位
如果你忘了在 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端的表单验证、错误提示、操作反馈这些场景中,摇晃动画都是非常好的选择。它比弹窗更轻量,比纯文字提示更有存在感,是一个"恰到好处"的交互反馈方式。
更多推荐



所有评论(0)