compose-motion
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseCompose Motion - Sub-skill
Compose Motion - 子技能
Jetpack Compose animation core. Loaded for Android Compose and Compose Multiplatform projects. Concise rules here. Deep-dive in. Baseline: current stable Jetpack Compose (1.7+). Modern stable APIs only - noreferences/, noswipeablehacks whereanimateContentSizeis correct.AnimatedContent
Jetpack Compose 动画核心内容。适用于 Android Compose 和 Compose Multiplatform 项目。 此处为简明规则,深入内容请查看目录。 基准版本:当前稳定版 Jetpack Compose(1.7+)。仅使用现代稳定 API——不使用references/,在swipeable适用场景下不使用AnimatedContent这类临时方案。animateContentSize
API Decision Tree
API 决策树
| Need | API |
|---|---|
| Single value over time | |
| Visibility / mount-unmount | |
| Crossfade between states | |
| Multi-state coordinated | |
| Manual control / interruption | |
| Looping / infinite | |
| Shared elements | |
| Layout swap with anim | |
| Drag / swipe | |
Rule: climb the ladder only when needed. covers 70% of cases. Reach for only when you need to interrupt, chain, or read velocity.
animate*AsStateAnimatable| 需求 | API |
|---|---|
| 单个值随时间变化 | |
| 可见性 / 挂载-卸载 | |
| 状态间淡入淡出切换 | |
| 多状态协同动画 | |
| 手动控制 / 中断动画 | |
| 循环 / 无限动画 | |
| 共享元素动画 | |
| 带动画的布局切换 | |
| 拖拽 / 滑动 | |
规则: 仅在必要时使用更复杂的API。 可覆盖70%的场景。仅当需要中断、链式调用或读取速度时,才使用 。
animate*AsStateAnimatableSpring API (opinionated defaults)
Spring API(推荐默认配置)
| Use | Spec |
|---|---|
| UI snap (modal, drawer, tab) | |
| Tactile (button press release, toggle) | |
| Bouncy reveal (toast, FAB, success) | |
| Drag follow (1:1 finger tracking) | |
Stiffness constants: 200, 400, 700, 1500, 10000. Higher = faster settle. Damping constants: 0.2, 0.5, 0.75, 1.0. Below 1.0 overshoots. Springs ignore ; if you need a deterministic duration, use instead.
VeryLowLowMediumLowMediumHighHighBouncyMediumBouncyLowBouncyNoBouncydurationMillistween(...)| 使用场景 | 参数配置 |
|---|---|
| UI 快速切换(弹窗、抽屉、标签页) | |
| 触觉反馈(按钮按压释放、开关) | |
| 弹性展示(提示框、悬浮按钮、成功提示) | |
| 拖拽跟随(1:1手指追踪) | |
刚度常量: 200、 400、 700、 1500、 10000。数值越高,动画结束越快。阻尼常量: 0.2、 0.5、 0.75、 1.0。数值小于1.0时会出现过冲。Spring 动画忽略 ;如果需要确定的时长,请使用 。
VeryLowLowMediumLowMediumHighHighBouncyMediumBouncyLowBouncyNoBouncydurationMillistween(...)animate*AsState
- The Bread and Butter
animate*AsStateanimate*AsState
- 核心基础
animate*AsStatekotlin
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))The shows up in Layout Inspector / Animation Preview - always set it, future-you will thank present-you. Variants ship for , , , , , , , , and a generic for custom types via .
labelDpColorOffsetIntOffsetSizeRectFloatIntanimateValueAsStateTwoWayConverterkotlin
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))labelDpColorOffsetIntOffsetSizeRectFloatIntanimateValueAsStateTwoWayConverterAnimatedVisibility
- Mount / Unmount with Anim
AnimatedVisibilityAnimatedVisibility
- 带动画的挂载/卸载
AnimatedVisibilitykotlin
AnimatedVisibility(
visible = expanded,
enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(animationSpec = tween(150)),
) {
Panel()
}Combine multiple enter/exit transitions with . Respect motion-principles: exit shorter and simpler than enter (here 150ms tween fade vs full slide+fade enter). The content composable only runs while visible OR animating - safe to mount expensive children inside.
+kotlin
AnimatedVisibility(
visible = expanded,
enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(animationSpec = tween(150)),
) {
Panel()
}可使用 组合多个进入/退出过渡动画。遵循动效原则:退出动画比进入动画更短更简洁(此处退出为150ms的淡入淡出,进入为完整的滑动+淡入)。内容组件仅在可见或动画运行时执行——可安全地在内部挂载开销较大的子组件。
+AnimatedContent
- State-Driven Layout Swap
AnimatedContentAnimatedContent
- 状态驱动的布局切换
AnimatedContentkotlin
AnimatedContent(
targetState = currentTab,
transitionSpec = {
(slideInHorizontally { it } + fadeIn()) togetherWith
(slideOutHorizontally { -it } + fadeOut())
},
label = "tabs",
) { tab ->
TabContent(tab)
}togetherWithusing SizeTransform(clip = false)targetStatekotlin
AnimatedContent(
targetState = currentTab,
transitionSpec = {
(slideInHorizontally { it } + fadeIn()) togetherWith
(slideOutHorizontally { -it } + fadeOut())
},
label = "tabs",
) { tab ->
TabContent(tab)
}togetherWithusing SizeTransform(clip = false)targetStateCrossfade
- Simple Fade Between States
CrossfadeCrossfade
- 状态间的简单淡入淡出
Crossfadekotlin
Crossfade(targetState = isLoading, label = "loadState") { loading ->
if (loading) Spinner() else Content()
}Use when you only need a fade. For anything richer (slide, scale, layout-aware), reach for . does NOT animate size - the container takes the size of the new content immediately.
AnimatedContentCrossfadekotlin
Crossfade(targetState = isLoading, label = "loadState") { loading ->
if (loading) Spinner() else Content()
}仅当需要淡入淡出效果时使用。如果需要更丰富的动效(滑动、缩放、布局感知),请使用 。 不会动画化尺寸——容器会立即切换为新内容的尺寸。
AnimatedContentCrossfadeupdateTransition
- Multi-Property Coordinated
updateTransitionupdateTransition
- 多属性协同动画
updateTransitionkotlin
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)),
)Use when several properties animate together based on the same state. All children share the same transition timeline, so they finish in sync. Each call accepts its own lambda for per-property tuning.
animate*transitionSpeckotlin
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*transitionSpecAnimatable
- Manual Control
AnimatableAnimatable
- 手动控制动画
Animatablekotlin
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) })Reach for when you need to interrupt (), chain ( returns when finished), read live velocity, or kick off decay (). It is the imperative escape hatch for drag-then-fling, snap-back, and any flow cannot express.
Animatablestop()animateToanimateDecayanimate*AsStatekotlin
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()animateToanimateDecayAnimatableanimate*AsStateSharedTransitionLayout
(Compose 1.7+) - Hero Animations
SharedTransitionLayoutSharedTransitionLayout
(Compose 1.7+)- 英雄动画
SharedTransitionLayoutkotlin
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(
state = rememberSharedContentState(key = "hero-${item.id}"),
animatedVisibilityScope = animatedVisibilityScope,
),
)
}Two scopes plumbed down: (where extension lives) and (the visibility context that drives the transition). keys MUST match across screens or no animation fires. Deep-dive in .
SharedTransitionScopeModifier.sharedElementAnimatedVisibilityScoperememberSharedContentState(key)references/shared-transitions.mdkotlin
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,
)
}
}
}
// 在ListScreen的卡片图片中:
with(sharedTransitionScope) {
Image(
painter = painter,
contentDescription = null,
modifier = Modifier.sharedElement(
state = rememberSharedContentState(key = "hero-${item.id}"),
animatedVisibilityScope = animatedVisibilityScope,
),
)
}需要传递两个作用域:( 扩展方法所在的作用域)和 (驱动过渡动画的可见性上下文)。不同页面间的 必须匹配,否则不会触发动画。深入内容请查看 。
SharedTransitionScopeModifier.sharedElementAnimatedVisibilityScoperememberSharedContentState(key)references/shared-transitions.mdInfiniteTransition
- Looping
InfiniteTransitionInfiniteTransition
- 循环动画
InfiniteTransitionkotlin
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.RestartRepeatMode.ReverseAnimatable.animateDecaykotlin
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.RestartRepeatMode.ReverseAnimatable.animateDecayGestures (quick map)
手势(快速映射)
| Need | Modifier |
|---|---|
| Tap | |
| Tap + long-press | |
| Drag (single axis) | |
| Multi-touch / custom | |
| Scroll offset reading | |
| Swipe-to-dismiss / snap points | |
| Pinch + pan + rotate | |
Deep-dive in (NestedScrollConnection, conflict resolution, fling decay).
references/gestures-compose.md| 需求 | Modifier |
|---|---|
| 点击 | |
| 点击 + 长按 | |
| 拖拽(单轴) | |
| 多点触控 / 自定义 | |
| 滚动偏移读取 | |
| 滑动删除 / 吸附点 | |
| 捏合 + 平移 + 旋转 | |
深入内容请查看 (NestedScrollConnection、冲突解决、抛射衰减)。
references/gestures-compose.mdAnti-Patterns (BAD / GOOD)
反模式(错误/正确示例)
1. Animating layout size instead of transform
1. 动画化布局尺寸而非变换属性
kotlin
// 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))kotlin
// 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)
},
)If the actual layout size MUST change (parent depends on it), use so neighbors animate too, or wrap the swap in .
Modifier.animateContentSize()AnimatedContentkotlin
// 错误 - 对width使用animateDpAsState会导致每一帧都触发布局重排
val w by animateDpAsState(if (expanded) 300.dp else 100.dp)
Box(Modifier.width(w).height(60.dp))kotlin
// 正确 - 通过graphicsLayer动画化缩放(仅合成层,无布局重排)
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()AnimatedContent2. LaunchedEffect(true)
/ LaunchedEffect(Unit)
with hidden inputs
LaunchedEffect(true)LaunchedEffect(Unit)2. 使用 LaunchedEffect(true)
/ LaunchedEffect(Unit)
但隐藏输入依赖
LaunchedEffect(true)LaunchedEffect(Unit)kotlin
// 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)
}kotlin
// GOOD - explicit key tied to the trigger
LaunchedEffect(triggerKey) {
offsetX.animateTo(target)
}If you genuinely want "run once", use on purpose AND ensure you don't capture varying state - or hoist the captured values out. When in doubt, key on the values you read.
LaunchedEffect(Unit)kotlin
// 错误 - 仅运行一次,但当调用者替换组件实例时会意外重新执行。更糟的是:lambda中捕获的任何状态都是过时的。
LaunchedEffect(true) {
offsetX.animateTo(target)
}kotlin
// 正确 - 使用与触发条件绑定的显式key
LaunchedEffect(triggerKey) {
offsetX.animateTo(target)
}如果你确实需要“仅运行一次”,可刻意使用 ,但要确保不捕获可变状态——或把捕获的值提升到外部。如有疑问,请使用读取到的值作为key。
LaunchedEffect(Unit)3. Forgetting key
in a LazyColumn
with item animations
keyLazyColumn3. 在LazyColumn中使用项动画时忘记设置key
keykotlin
// BAD - on insert/delete/reorder, items animate to wrong slots
LazyColumn {
items(list) { item ->
AnimatedVisibility(visible = item.expanded) { ItemRow(item) }
}
}kotlin
// 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) }
}
}
}Modifier.animateItem()animateItemPlacementkeykotlin
// 错误 - 插入/删除/重排时,项会动画到错误的位置
LazyColumn {
items(list) { item ->
AnimatedVisibility(visible = item.expanded) { ItemRow(item) }
}
}kotlin
// 正确 - 使用id作为稳定key,加上Modifier.animateItem处理重排动画
LazyColumn {
items(list, key = { it.id }) { item ->
Row(modifier = Modifier.animateItem()) {
AnimatedVisibility(visible = item.expanded) { ItemRow(item) }
}
}
}Modifier.animateItem()animateItemPlacementkey4. Nesting AnimatedContent
inside scrolling list items
AnimatedContent4. 在滚动列表项中嵌套AnimatedContent
AnimatedContentkotlin
// BAD - every item runs its own transition graph; scroll = jank
LazyColumn {
items(list, key = { it.id }) { item ->
AnimatedContent(targetState = item.state) { state -> Row(state) }
}
}kotlin
// 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) }
}
}Rule: heavy animation containers (, ) belong at screen scope, not per row.
AnimatedContentSharedTransitionLayoutkotlin
// 错误 - 每个项都运行自己的过渡动画图;滚动时会出现卡顿
LazyColumn {
items(list, key = { it.id }) { item ->
AnimatedContent(targetState = item.state) { state -> Row(state) }
}
}kotlin
// 正确 - 提取状态,仅对行中变化的属性做动画
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) }
}
}规则:重量级动画容器(、)应放在屏幕级作用域,而非每行中。
AnimatedContentSharedTransitionLayoutReduced Motion - Respect It
减少动画 - 尊重用户设置
See for the cross-platform doctrine.
../motion-principles/SKILL.mdkotlin
@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_SCALEValueAnimator.areAnimatorsEnabled()false../mobile-principles/references/accessibility-mobile.md跨平台原则请查看 。
../motion-principles/SKILL.mdkotlin
@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_SCALEValueAnimator.areAnimatorsEnabled()false../mobile-principles/references/accessibility-mobile.mdQuick Reference
快速参考
| Need | Load |
|---|---|
| Shared transitions deep-dive | |
| Gestures + nestedScroll patterns | |
| Recomposition / jank / Layout Inspector | |
| Advanced (M3 Expressive, AGSL, Canvas) | |
| CMP / KMP patterns | |
| Mobile UX context | |
| Foundation (timing, easing, a11y) | |
| 需求 | 查看路径 |
|---|---|
| 共享过渡动画深入内容 | |
| 手势 + nestedScroll 模式 | |
| 重组 / 卡顿 / 布局检查器 | |
| 进阶内容(M3 动态效果、AGSL、Canvas) | |
| CMP / KMP 模式 | |
| 移动UX上下文 | |
| 基础内容(计时、缓动、无障碍) | |
Sources
资料来源
- aldefy/compose-skill - animation reference
- skydoves/Orbital - shared element transitions
- mutualmobile/compose-animation-examples
- fornewid/material-motion-compose
- Android Compose animations docs
- Android SharedTransitionLayout