关于 compose-motion
面向 Claude Code、claude.ai 和 Cowork 的 Jetpack Compose 与 Compose Multiplatform 动画技能,将常见界面需求对应到 Compose API,并介绍协调转场、手动控制、手势及减少动态效果。
主要功能
- 01Compose 动画 API 选择
- 02可见性与内容转场
- 03多属性协调动画
- 04使用 Animatable 手动控制
- 05手势与减少动态效果指导
适用场景
- 01为界面元素添加显隐动画
- 02协调多个界面属性的变化
- 03实现拖动、滑动或吸附行为
- 04检查动画的无障碍行为
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ athevon/ genjutsu --skill compose-motion安装后在 Claude Code 中输入/compose-motion确认已加载。
详细介绍
对版本敏感。 以下所有 API 名称、SDK 要求和浏览器支持声明均已于 2026-09-08 根据一手来源核实。核实依据和时间见
_jutsu/VERSIONS.md。如果该日期已久,请在依据版本号采取行动前重新核实。
Compose Motion - 子技能
Jetpack Compose 动画核心。适用于 Android Compose 和 Compose Multiplatform 项目。 此处列出简明规则。深入内容见
references/。 基准:Jetpack Compose 1.12.0(当前稳定版,2026 年 8 月)——即 Compose BOM 2026.08.x。仅使用现代稳定 API——不使用swipeable,也不在应使用AnimatedContent的场景中采用animateContentSize变通方案。标记为“Compose 1.x+”的内容表示引入版本,并非目标版本。
API 决策树
| 需求 | API |
|---|---|
| 随时间变化的单个值 | animateFloatAsState、animateDpAsState、animateColorAsState 等 |
| 显示 / 隐藏 | AnimatedVisibility(visible) { ... } |
| 在不同状态间交叉淡入淡出 | Crossfade(target) { state -> ... } |
| 协调多个状态 | updateTransition(target).animateFloat { ... } |
| 手动控制 / 中断 | Animatable(initialValue) + animateTo(...) |
| 循环 / 无限动画 | rememberInfiniteTransition().animateFloat(...) |
| 共享元素 | SharedTransitionLayout + Modifier.sharedElement(...)(Compose 1.7+) |
| 动画切换布局 | AnimatedContent(target) { ... } |
| 拖动 / 滑动 | Modifier.draggable + Animatable.snapTo/animateTo,或使用 Modifier.anchoredDraggable 实现吸附点 |
规则: 仅在需要时逐级升级。animate*AsState 可满足 70% 的场景。只有需要中断、串联动画或读取速度时,才使用 Animatable。
Spring API(建议默认值)
| 用途 | 规格 |
|---|---|
| UI 吸附(模态框、抽屉、标签页) | spring(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioNoBouncy) |
| 触感反馈(按钮按下后释放、切换开关) | spring(stiffness = Spring.StiffnessMedium, dampingRatio = 0.85f) |
| 弹性显现(Toast、FAB、成功状态) | spring(stiffness = Spring.StiffnessLow, dampingRatio = Spring.DampingRatioMediumBouncy) |
| 跟随拖动(与手指 1:1 同步) | spring(stiffness = Spring.StiffnessHigh, dampingRatio = 1f) |
刚度常量(androidx.compose.animation.core.Spring):StiffnessVeryLow 为 50f,StiffnessLow 为 200f,StiffnessMediumLow 为 400f,StiffnessMedium 为 1500f(spring() 的默认值),StiffnessHigh 为 10000f。值越高,稳定得越快。阻尼常量:DampingRatioHighBouncy 为 0.2f,DampingRatioMediumBouncy 为 0.5f,DampingRatioLowBouncy 为 0.75f,DampingRatioNoBouncy 为 1.0f(默认值)。低于 1.0 时会产生过冲。弹簧动画会忽略 durationMillis;如果需要确定的时长,请改用 tween(...)。
animate*AsState - 基本功
val targetAlpha = if (visible) 1f else 0f
val alpha by animateFloatAsState(
targetValue = targetAlpha,
animationSpec = spring(stiffness = Spring.StiffnessMedium),
label = "alpha",
)
Box(modifier = Modifier.alpha(alpha))
label 会显示在 Layout Inspector / Animation Preview 中——始终设置它,未来的你会感谢现在的自己。该 API 提供 Dp、Color、Offset、IntOffset、Size、Rect、Float 和 Int 类型的变体;自定义类型则可通过 TwoWayConverter 使用通用的 animateValueAsState。
AnimatedVisibility - 动画显示 / 隐藏
AnimatedVisibility(
visible = expanded,
enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(animationSpec = tween(150)),
) {
Panel()
}
使用 + 组合多个进入 / 退出过渡。遵循动效原则:退出动画应比进入动画更短、更简单(此处为 150ms 的淡出 tween,对比完整的滑入加淡入)。内容可组合项只会在可见或动画进行期间运行——因此可以安全地在其中挂载开销较大的子项。
AnimatedContent - 状态驱动的布局切换
AnimatedContent(
targetState = currentTab,
transitionSpec = {
(slideInHorizontally { it } + fadeIn()) togetherWith
(slideOutHorizontally { -it } + fadeOut())
},
label = "tabs",
) { tab ->
TabContent(tab)
}
togetherWith 会并行运行进入和退出动画;using SizeTransform(clip = false) 控制容器在不同内容间切换时如何调整大小。键很重要:如果 targetState 的身份未改变,就不会触发过渡。
Crossfade - 在状态间简单淡入淡出
Crossfade(targetState = isLoading, label = "loadState") { loading ->
if (loading) Spinner() else Content()
}
只需要淡入淡出时使用。需要更丰富的效果(滑动、缩放、感知布局的动画)时,请使用 AnimatedContent。Crossfade 不会对尺寸进行动画处理——容器会立即调整为新内容的尺寸。
updateTransition - 多属性协调
val transition = updateTransition(targetState = expanded, label = "expand")
val width by transition.animateDp(label = "width") { if (it) 300.dp else 100.dp }
val color by transition.animateColor(label = "color") { if (it) Color.Blue else Color.Gray }
val corner by transition.animateDp(label = "corner") { if (it) 24.dp else 8.dp }
Box(
Modifier
.width(width)
.background(color, RoundedCornerShape(corner)),
)
当多个属性需要根据同一状态一起进行动画时使用。所有子项共享同一条过渡时间线,因此会同步结束。每次 animate* 调用都可以接受自己的 transitionSpec lambda,以单独调整属性。
Animatable - 手动控制
val offsetX = remember { Animatable(0f) }
LaunchedEffect(triggerEvent) {
offsetX.animateTo(100f, spring())
offsetX.animateTo(0f, spring(dampingRatio = Spring.DampingRatioMediumBouncy))
}
Box(Modifier.offset { IntOffset(offsetX.value.roundToInt(), 0) })
当你需要中断动画(stop())、串联动画(animateTo 在完成后返回)、读取实时速度或启动衰减动画(animateDecay)时,可使用 Animatable。它是命令式控制的灵活选择,适用于先拖动再甩动、回弹,以及任何 animate*AsState 无法表达的流程。
SharedTransitionLayout(Compose 1.7+)- Hero 动画
SharedTransitionLayout {
AnimatedContent(targetState = currentScreen, label = "nav") { screen ->
when (screen) {
Screen.List -> ListScreen(
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@AnimatedContent,
)
is Screen.Detail -> DetailScreen(
item = screen.item,
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@AnimatedContent,
)
}
}
}
// Inside ListScreen, on the card image:
with(sharedTransitionScope) {
Image(
painter = painter,
contentDescription = null,
modifier = Modifier.sharedElement(
sharedContentState = rememberSharedContentState(key = "hero-${item.id}"),
animatedVisibilityScope = animatedVisibilityScope,
),
)
}
这里会向下传递两个作用域:SharedTransitionScope(Modifier.sharedElement 扩展所在的作用域)和 AnimatedVisibilityScope(驱动过渡的可见性上下文)。不同屏幕上的 rememberSharedContentState(key) 键必须一致,否则不会触发动画。详见 references/shared-transitions.md。
InfiniteTransition - 循环动画
val infinite = rememberInfiniteTransition(label = "loader")
val rotation by infinite.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "rotation",
)
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.rotate(rotation))
RepeatMode.Restart 会在每个周期结束后跳回起点;RepeatMode.Reverse 则会来回往复。请始终提供暂停或关闭方式,以满足无障碍需求(参见下文的“减少动态效果”部分)。对于一次性衰减(甩动),请改用 Animatable.animateDecay。
手势(速查)
| 需求 | Modifier |
|---|---|
| 点击 | Modifier.clickable(onClick = ...) |
| 点击 + 长按 | Modifier.combinedClickable(onClick = ..., onLongClick = ...) |
| 拖动(单轴) | Modifier.draggable(state, orientation) |
| 多点触控 / 自定义 | Modifier.pointerInput { detectDragGestures { ... } } |
| 读取滚动偏移量 | LazyListState 或 ScrollState.value |
| 滑动关闭 / 吸附点 | Modifier.anchoredDraggable(state)(Compose 1.6+,取代 swipeable) |
| 捏合 + 平移 + 旋转 | Modifier.transformable(state) |
详见 references/gestures-compose.md(NestedScrollConnection、冲突解决、甩动衰减)。
反模式(不佳 / 良好)
1. 使用变换而非布局尺寸动画
// BAD - animateDpAsState on width forces a layout pass every frame
val w by animateDpAsState(if (expanded) 300.dp else 100.dp)
Box(Modifier.width(w).height(60.dp))
// GOOD - animate scale via graphicsLayer (composite-only, no layout)
val scale by animateFloatAsState(if (expanded) 3f else 1f, label = "scale")
Box(
Modifier
.width(100.dp)
.height(60.dp)
.graphicsLayer {
scaleX = scale
transformOrigin = TransformOrigin(0f, 0.5f)
},
)
如果实际布局尺寸必须改变(父级依赖该尺寸),请使用 Modifier.animateContentSize(),让相邻元素也能一起产生动画;或者将内容切换封装在 AnimatedContent 中。
2. 使用带有隐藏输入的 LaunchedEffect(true) / LaunchedEffect(Unit)
// BAD - re-runs only once, BUT also fires on every recomposition surprise
// when callers swap the composable instance. Worse: anything captured
// in the lambda is stale.
LaunchedEffect(true) {
offsetX.animateTo(target)
}
// GOOD - explicit key tied to the trigger
LaunchedEffect(triggerKey) {
offsetX.animateTo(target)
}
如果确实希望“只运行一次”,请有意使用 LaunchedEffect(Unit),并确保不会捕获变化的状态;或者将捕获的值提升到外部。不确定时,请将读取的值设为键。
3. 在带有条目动画的 LazyColumn 中忘记添加 key
// BAD - on insert/delete/reorder, items animate to wrong slots
LazyColumn {
items(list) { item ->
AnimatedVisibility(visible = item.expanded) { ItemRow(item) }
}
}
// GOOD - stable key by id, plus Modifier.animateItem for reorder anim
LazyColumn {
items(list, key = { it.id }) { item ->
Row(modifier = Modifier.animateItem()) {
AnimatedVisibility(visible = item.expanded) { ItemRow(item) }
}
}
}
只要 key 保持稳定,Modifier.animateItem()(Compose 1.7+,取代 animateItemPlacement)就会自动处理插入、移除和移动。
4. 在可滚动列表项中嵌套 AnimatedContent
// BAD - every item runs its own transition graph; scroll = jank
LazyColumn {
items(list, key = { it.id }) { item ->
AnimatedContent(targetState = item.state) { state -> Row(state) }
}
}
// GOOD - lift state, animate only the changing prop on the row
LazyColumn {
items(list, key = { it.id }) { item ->
val color by animateColorAsState(if (item.selected) selBg else bg, label = "rowBg")
Row(Modifier.background(color)) { Content(item) }
}
}
原则:重量级动画容器(AnimatedContent、SharedTransitionLayout)应放在屏幕级作用域中,而不是每个列表行中。
减少动态效果 - 尊重用户设置
跨平台规范请参阅 ../motion-principles/SKILL.md。
@Composable
fun rememberReduceMotion(): Boolean {
val context = LocalContext.current
return remember {
Settings.Global.getFloat(
context.contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE,
1f,
) == 0f
}
}
val reduce = rememberReduceMotion()
val alpha by animateFloatAsState(
targetValue = if (visible) 1f else 0f,
animationSpec = if (reduce) snap() else spring(),
label = "alpha",
)
ANIMATOR_DURATION_SCALE 会直接读取动画器持续时间缩放比例。更简洁的调用方式是 ValueAnimator.areAnimatorsEnabled()(API 26+);当缩放比例为 0 时,该方法会返回 false。此值可由开发者选项中的“关闭动画”开关、省电模式,以及面向用户的“移除动画”(设置 -> 无障碍)开关设置。详见 ../mobile-principles/references/accessibility-mobile.md。
快速参考
| 需求 | 加载 |
|---|---|
| 深入了解共享转场 | references/shared-transitions.md |
| 手势与 nestedScroll 模式 | references/gestures-compose.md |
| 重组/卡顿/Layout Inspector | references/recomposition-and-anim.md |
| 高级(M3 Expressive、AGSL、Canvas) | ../compose-graphics/SKILL.md |
| CMP/KMP 模式 | ../compose-multiplatform/SKILL.md |
| 移动端 UX 背景 | ../mobile-principles/SKILL.md |
| 基础(时长、缓动、无障碍) | ../motion-principles/SKILL.md |