在 Jetpack Compose 中实现一个 Shake 动画 Modifier
在 Android 开发中,很多场景需要「抖动(shake)」效果,例如按钮的强调反馈等等。
在 View 体系里我们可能会直接用 ObjectAnimator 来做,但在 Jetpack Compose 中,更推荐的方式是通过 Modifier 来扩展 UI 能力。
这篇文章一步步实现一个 可配置的 Shake Modifier,支持 自定义抖动轨迹,并且保持与 UI 解耦。
1. 初版实现思路
在 Compose 中,常见的动画有 animate*AsState、Animatable、Transition 等。
为了让组件「左右抖动」,最直观的方式就是控制 offset(x = …)。
因此初版的想法是:
- 定义一个
Animatable,保存偏移量。 - 用
LaunchedEffect启动动画,按照我们设定的 offsetPattern(抖动轨迹)去更新Animatable。 - 在
Modifier中应用offset实现抖动。
代码大概是这样:
fun Modifier.shake(
offsetPattern: List<Float>, // 抖动轨迹
intensity: Dp, // 抖动幅度
durationMillis: Int = 1000 // 动画时长
): Modifier = composed {
val density = LocalDensity.current
val animateOffset = remember { Animatable(0f) }
LaunchedEffect(Unit) {
val pxIntensity = with(density) { intensity.toPx() }
val frameCount = offsetPattern.size
val frameDuration = durationMillis / (frameCount - 1).coerceAtLeast(1)
val keyframesSpec = keyframes<Float> {
this.durationMillis = durationMillis
offsetPattern.forEachIndexed { index, ratio ->
(pxIntensity * ratio) at (index * frameDuration)
}
}
animateOffset.animateTo(0f, animationSpec = keyframesSpec)
}
offset(x = with(density) { animateOffset.value.toDp() })
}
这样一来,任何组件都能一行代码加上抖动:
Text(
"错误提示",
Modifier.shake(listOf(0f, -1f, 1f, -1f, 1f, 0f), 10.dp)
)
看似完美 ✅ ,但问题随之而来。
2. 遇到的问题
❌ 1. 动画无法灵活控制
这个 Modifier 是「一进入组合就开始抖动」,缺乏 开始 / 停止 的控制。
如果要在输入框验证失败时才触发,显得很不方便。
❌ 2. 重复执行 & 状态丢失
Compose 的 Modifier 可能会被多次执行(因为 Recompose)。
如果逻辑都写在 Modifier 里,动画可能会不断重启,表现异常。
❌ 3. 与 UI 耦合
Modifier 直接写死了逻辑,导致在外部很难控制。比如想监听动画完成回调,执行其他动画。
3. 引入 Controller 统一管理
为了解决上述问题,将动画的控制逻辑抽离到一个 ShakeController 中。
这样:
Modifier只负责渲染和订阅状态Controller负责控制动画的开启、停止、重复次数、回调等
代码结构如下:
@Stable
class ShakeController(
val offsetPattern: List<Float>,
val intensity: Dp,
val durationMillis: Int = 1000,
val delayMillis: Int = 0,
val repeat: Int? = 1,
val enable: Boolean = true,
val finishedListener: ((Dp) -> Unit)? = null
) {
var shakeStarted by mutableStateOf(enable)
fun startShake() { shakeStarted = true }
fun cancelShake() { shakeStarted = false }
}
fun rememberShakeController(
offsetPattern: List<Float>,
intensity: Dp,
durationMillis: Int = 1000,
delayMillis: Int = 0,
repeat: Int? = 1,
enable: Boolean = true,
finishedListener: ((Dp) -> Unit)? = null
) = ShakeController(offsetPattern, intensity, durationMillis, delayMillis, repeat, enable, finishedListener)
4. 改造后的 Modifier
fun Modifier.shake(controller: ShakeController) = composed {
val density = LocalDensity.current
val animateOffset = remember { Animatable(0f) }
LaunchedEffect(controller) {
if (!controller.enable) {
animateOffset.snapTo(0f)
controller.cancelShake()
return@LaunchedEffect
}
val pxIntensity = with(density) { controller.intensity.toPx() }
val frameCount = controller.offsetPattern.size
val frameDuration = controller.durationMillis / (frameCount - 1).coerceAtLeast(1)
val keyframesSpec = keyframes<Float> {
this.durationMillis = controller.durationMillis
this.delayMillis = controller.delayMillis
controller.offsetPattern.forEachIndexed { index, ratio ->
(pxIntensity * ratio) at (index * frameDuration)
}
}
try {
if (controller.repeat == null) {
while (isActive) {
controller.startShake()
animateOffset.animateTo(0f, animationSpec = keyframesSpec)
}
} else {
repeat(controller.repeat) {
controller.startShake()
animateOffset.animateTo(0f, animationSpec = keyframesSpec)
}
controller.cancelShake()
controller.finishedListener?.invoke(0.dp)
}
} finally {
animateOffset.snapTo(0f)
controller.cancelShake()
}
}
val offsetDp = remember {
derivedStateOf { with(density) { animateOffset.value.toDp() } }
}
offset(x = offsetDp.value)
}
这样,shake 的逻辑完全由 controller 控制,Modifier 只关注渲染。
5. 使用示例
val shakeController: ShakeController? = if (feature.title == "Chat") {
rememberShakeController(
listOf(0f, -1f, 1f, -0.6f, 0.6f, -0.2f, 0.2f, 0f),
25.dp,
).apply {
LaunchedEffect(Unit) {
startShake()
}
}
} else null
val content = @Composable {
Column(
modifier = Modifier
.shake(shakeController)
.padding(4.dp)
.height(100.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(4.dp))
.background(Color.Red.copy(0.1f))
.noRippleClickable {
onClick(feature)
}
.padding(horizontal = 8.dp)
) {
// 设置头像,圆形,灰色边框
Image(
painter = painterResource(id = feature.icon),
modifier =
Modifier
.padding(top = 20.dp)
.size(24.dp)
.border(1.dp, Color.Gray, CircleShape)
.clip(CircleShape),
contentDescription = "",
)
Text(text = feature.title, fontSize = 16.sp)
Text(text = feature.des, fontSize = 14.sp, color = Color.Black.copy(alpha = 0.8f))
}
}
效果:按钮左右抖动 2 次 → 回到初始位置。
6. 过程中遇到的坑
- Modifier 会多次执行
需要通过remember和LaunchedEffect来保证动画状态不会被频繁重建。 - 控制粒度的问题
一开始把所有逻辑都放在 Modifier 里,无法灵活复用。引入 Controller 后,动画的启停、次数、回调就能很好地管理。 - 状态更新
LaunchedEffect(controller)可以监听 Controller 内部的变化。如果ShakeController用了@Stable,当字段变化时,Compose 也能感知到。
7. 总结
- Shake 动画的本质:控制
offset产生左右抖动。 - Controller 解耦:将动画逻辑与 UI 分离,提升灵活性。
- 注意 Recompose:Modifier 可能会执行多次,必须用
remember保持状态。
这样,我们就得到了一个 Shake Modifier,可以对任意 Composable 实现抖动效果。