Compose 实现抖动 Shake 动画 Modifier

574 阅读3分钟

在 Jetpack Compose 中实现一个 Shake 动画 Modifier

在 Android 开发中,很多场景需要「抖动(shake)」效果,例如按钮的强调反馈等等。
在 View 体系里我们可能会直接用 ObjectAnimator 来做,但在 Jetpack Compose 中,更推荐的方式是通过 Modifier 来扩展 UI 能力。

这篇文章一步步实现一个 可配置的 Shake Modifier,支持 自定义抖动轨迹,并且保持与 UI 解耦。


1. 初版实现思路

在 Compose 中,常见的动画有 animate*AsStateAnimatableTransition 等。
为了让组件「左右抖动」,最直观的方式就是控制 offset(x = …)

因此初版的想法是:

  1. 定义一个 Animatable,保存偏移量。
  2. LaunchedEffect 启动动画,按照我们设定的 offsetPattern(抖动轨迹)去更新 Animatable
  3. 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))
  }
}

Sep-05-2025 12-00-51.gif

效果:按钮左右抖动 2 次 → 回到初始位置。


6. 过程中遇到的坑

  1. Modifier 会多次执行
    需要通过 rememberLaunchedEffect 来保证动画状态不会被频繁重建。
  2. 控制粒度的问题
    一开始把所有逻辑都放在 Modifier 里,无法灵活复用。引入 Controller 后,动画的启停、次数、回调就能很好地管理。
  3. 状态更新
    LaunchedEffect(controller) 可以监听 Controller 内部的变化。如果 ShakeController 用了 @Stable,当字段变化时,Compose 也能感知到。

7. 总结

  • Shake 动画的本质:控制 offset 产生左右抖动。
  • Controller 解耦:将动画逻辑与 UI 分离,提升灵活性。
  • 注意 Recompose:Modifier 可能会执行多次,必须用 remember 保持状态。

这样,我们就得到了一个 Shake Modifier,可以对任意 Composable 实现抖动效果。