K 动画(扩展)
不挡主线。Compose 动画仍是 UI = f(state) :状态变了,值或进出平滑过渡。按业务 App 真实使用频率 覆盖官方 androidx.compose.animation* 全家桶;Lottie / Rive / MotionLayout 不是这套 API,本页不讲。
🔗 引用:动画总览 · 怎么选 API · 组件与 Modifier · 基于值 · 自定义规格 · 共享元素
先选对 API
常用 业务页几乎天天写
偶尔 对上场景再用
一个值在变
animate*AsState
显隐 / 换内容
Visibility / Content
布局自己在变
contentSize / item
你想做的事 用这个 频率
颜色、尺寸、透明度、偏移跟状态走 animate*AsState常用
一块 UI 出现 / 消失(横幅、面板、FAB) AnimatedVisibility常用
同一坑位换内容(加载 ↔ 成功、Tab、步骤) AnimatedContent常用
卡片展开、文案变长,容器跟着长 Modifier.animateContentSize常用
Lazy 列表增删改排序,条目自己滑 Modifier.animateItem()常用
转圈、呼吸灯、骨架屏闪动 rememberInfiniteTransition常用
时长 / 弹簧 / 延迟(几乎每个动画都带) spring / tween常用
只要交叉淡入,不要滑入 Crossfade偶尔
一个状态同时驱动多个属性 updateTransition偶尔
拖拽跟手、先弹再落、惯性甩 Animatable偶尔
列表缩略图飞到详情大图 SharedTransitionLayout偶尔
关键帧、有限次重复、自定义类型 keyframes / repeatable / animateValueAsState偶尔
选型口诀:一个数 → AsState ;整块进出 → Visibility ;换内容 → Content ;盒子自己长 → contentSize ;列表项挪位置 → animateItem ;永远转 → Infinite ;手势/顺序 → Animatable 。
所有高层 API 都要传 label(排查用)。不要在组合体里用 value += 1 当动画,也不要用 Handler.postDelayed 改 State 冒充过渡。
animate*AsState 常用
概念
目标值变了,当前值平滑追过去,返回 State 。适合「同一个组件还在,只是某个属性变了」:选中高亮、开关拉伸、进度条、图标旋转。
官方这一族
API 类型 业务里最常见
animateDpAsStateDp 宽高、圆角、elevation、间距
animateFloatAsStateFloat alpha、scale、progress、rotation
animateColorAsStateColor 选中色、主题切换过渡
animateIntAsStateInt 计数滚动、页码
animateOffsetAsStateOffset 画布/图层偏移(px)
animateIntOffsetAsStateIntOffset Modifier.offset { }
animateSizeAsStateSize 绘制尺寸(px)
animateIntSizeAsStateIntSize 布局像素尺寸
animateRectAsStateRect 裁剪框,少见
animateValueAsState任意 T 要自己给 TwoWayConverter,见偶尔
和 XML 对照
≈ ObjectAnimator / ViewPropertyAnimator 的常用子集。差别:你改的是状态 ,不是「找到 View 再开动画」。
怎么用
@Composable
fun ExpandChip(expanded: Boolean, onToggle: () -> Unit) {
val width by animateDpAsState(if (expanded) 200.dp else 88.dp, label = "chipW")
val color by animateColorAsState(
if (expanded) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surfaceVariant,
label = "chipC"
)
Box(
Modifier
.size(width, 40.dp)
.background(color, CircleShape)
.clickable(onClick = onToggle)
)
}
透明度、缩放挂在 graphicsLayer / Modifier.alpha / scale 上,不要为了动画去改业务数据:
val alpha by animateFloatAsState(if (enabled) 1f else 0.38f, label = "a")
Text("提交", modifier = Modifier.graphicsLayer { this.alpha = alpha })
易错点
目标值每次重组都是新对象(新的 Dp 计算不稳定、新 Color 实例来自随机)→ 动画不停重启。
用它做「组件卸掉」的进出 → 用 AnimatedVisibility,否则子树还在占位。
多个属性其实都跟同一个枚举走 → 考虑 updateTransition,避免多路 AsState 时序不一致。
▶ 选中卡片升高 + 变色
@Composable
fun SelectCard(selected: Boolean, onClick: () -> Unit) {
val elev by animateDpAsState(if (selected) 8.dp else 1.dp, label = "e")
Card(elevation = CardDefaults.cardElevation(elev), onClick = onClick) {
Text(if (selected) "已选" else "未选")
}
}
AnimatedVisibility 常用
概念
布尔(或 MutableTransitionState)控制一块内容进出组合。退出动画播完才真正移除,所以空态、错误条、可折叠面板、滚动时藏 FAB 都用它。
和 XML 对照
≈ animateLayoutChanges + 自己写进出 Animator。Row/Column 作用域里有特化重载,默认会沿主轴展开/收起。
怎么用
@Composable
fun ErrorBanner(message: String?) {
AnimatedVisibility(
visible = message != null,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Text(message.orEmpty(), modifier = Modifier.padding(12.dp))
}
}
MutableTransitionState:一进组合就播,或在外面读 isIdle(播完再跳转):
val visible = remember { MutableTransitionState(false) }
LaunchedEffect(Unit) { visible.targetState = true }
AnimatedVisibility(visibleState = visible) { SplashLogo() }
if (visible.isIdle && visible.currentState) { /* 入场结束 */ }
易错点
用 if (visible) { ... } 代替它 → 没有进出,直接卸掉。
退出时另外用 animateFloatAsState 做透明度:Visibility 等不到这条动画,可能提前移除。自定义效果用内容 lambda 里的 transition 或 animateEnterExit。
在重组里把 visible 来回拨 → 抖动。状态要稳定(来自 VM / remember)。
▶ 滚动列表时收起 FAB
@Composable
fun Feed(listState: LazyListState) {
val showFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
Scaffold(
floatingActionButton = {
AnimatedVisibility(showFab, enter = scaleIn() + fadeIn(), exit = scaleOut() + fadeOut()) {
FloatingActionButton(onClick = { }) { Icon(Icons.Default.Add, "发帖") }
}
}
) { padding ->
LazyColumn(state = listState, modifier = Modifier.padding(padding)) { /* … */ }
}
}
Enter / Exit 过渡 常用
概念
官方内置进出效果,给 AnimatedVisibility / AnimatedContent 用。用 + 叠加(淡入 + 下滑很常见)。完全自定义可传 EnterTransition.None / ExitTransition.None,再在子节点用 animateEnterExit。
API 效果 频率
fadeIn / fadeOut透明度 常用
slideInVertically / slideOutVertically上下滑(Snackbar、底栏) 常用
slideInHorizontally / slideOutHorizontally左右滑(步骤、详情) 常用
slideIn / slideOut任意 Offset 滑入滑出 偶尔
expandVertically / shrinkVertically高度展开收起 常用
expandHorizontally / shrinkHorizontally宽度展开收起 偶尔
expandIn / shrinkOut向某对齐点展开/收缩 偶尔
scaleIn / scaleOut缩放(对话框、FAB) 常用
EnterTransition.None / ExitTransition.None关闭默认,自己管子节点 偶尔
怎么用
enter = fadeIn(tween(180)) + slideInVertically { it / 2 }
exit = fadeOut(tween(120)) + slideOutVertically { it / 2 }
// 子节点单独进出(父级可 None)
Box(Modifier.animateEnterExit(enter = fadeIn(), exit = fadeOut()))
易错点
slideInVertically { fullHeight -> fullHeight } 的 lambda 返回的是起始偏移 (px),不是「滑多远」的百分比对象。写反方向会从另一侧飞入。
AnimatedContent 常用
概念
targetState 变了,旧内容退出、新内容进入。适合同一个槽位、不同 UI :加载圈 ↔ 正文、Tab 页、向导弹窗步骤。比 if/else 多了过渡,比开两个 AnimatedVisibility 好管。
和 XML 对照
≈ ViewSwitcher / ViewFlipper,但按任意状态切换,不限两页。
怎么用
@Composable
fun FeedBody(ui: FeedUi) {
AnimatedContent(targetState = ui, label = "feed") { state ->
when (state) {
FeedUi.Loading -> CircularProgressIndicator()
is FeedUi.Ok -> LazyColumn { items(state.items, key = { it.id }) { RowItem(it) } }
is FeedUi.Err -> TextButton(onClick = state.retry) { Text("重试") }
}
}
}
指定进出方向(1.7+ 用 togetherWith;老代码里的 with 已弃用):
AnimatedContent(
targetState = page,
transitionSpec = {
if (targetState > initialState) {
slideInHorizontally { it } togetherWith slideOutHorizontally { -it }
} else {
slideInHorizontally { -it } togetherWith slideOutHorizontally { it }
}.using(SizeTransform(clip = false))
},
label = "pager"
) { p -> Page(p) }
SizeTransform
内容尺寸不同时,容器大小怎么过渡。clip = false 可避免切换时裁切阴影。默认会动画尺寸,列表 ↔ 转圈这种高度差很大的场景建议显式写。
易错点
targetState 用不稳定对象(每次 new 的 data class 没 equals)→ 每帧都切。
在 lambda 里读外面的最新 page 而不是参数 p → 进出两套内容串台。
只是显隐同一块内容 → 用 Visibility,别上 Content。
▶ 登录步骤 账号 → 验证码
enum class LoginStep { Phone, Code }
@Composable
fun LoginFlow() {
var step by remember { mutableStateOf(LoginStep.Phone) }
AnimatedContent(step, label = "login") { s ->
when (s) {
LoginStep.Phone -> PhoneForm(onNext = { step = LoginStep.Code })
LoginStep.Code -> CodeForm(onBack = { step = LoginStep.Phone })
}
}
}
animateContentSize 常用
概念
子内容变高变宽时,父布局尺寸平滑过渡。展开「查看更多」、Chip 变长、表单出错多出一行红字,都比手算 animateDpAsState 省事。
和 XML 对照
≈ android:animateLayoutChanges=true,但只动画自己的 测量尺寸。
怎么用
@Composable
fun ExpandText(text: String) {
var open by remember { mutableStateOf(false) }
Column(Modifier.animateContentSize().clickable { open = !open }) {
Text(text, maxLines = if (open) Int.MAX_VALUE else 3)
Text(if (open) "收起" else "展开")
}
}
易错点
和 AnimatedVisibility 叠在同一节点上容易抢尺寸。子项进出用 Visibility;容器跟着长用 contentSize,一般只留一个。
Lazy · animateItem 常用
概念
列表插入、删除、重排时,条目自己做位移动画。Compose 1.7+ 用 Modifier.animateItem()(覆盖出现 / 消失 / 位移)。旧名 animateItemPlacement() 只做位移,已过时。
和 XML 对照
≈ RecyclerView.ItemAnimator / DefaultItemAnimator。
怎么用
LazyColumn {
items(todos, key = { it.id }) { todo ->
TodoRow(
todo,
modifier = Modifier.animateItem(
fadeInSpec = tween(120),
fadeOutSpec = tween(90),
placementSpec = spring()
)
)
}
}
易错点
没有稳定 key → 动画会绑错行,详见列表 · key 。
整表 items = emptyList() 再一次性塞入,看起来像闪一下,不是逐条插入。
▶ 待办划掉后从列表移除
在 VM 里删掉该项,Compose 根据 key 认出「这行走了」,animateItem 播消失 + 下面行上移。不要先把 alpha 做成 0 再删,会播两遍。
rememberInfiniteTransition 常用
概念
只要还在组合里就循环。加载旋转、骨架屏闪光、录音波形、未读红点呼吸。停动画 = 把这段组合拿掉,或改 visible 包一层 Visibility。
官方这一族
infiniteTransition.animateFloat / animateColor / animateValue。规格几乎总是 infiniteRepeatable(tween/keyframes, RepeatMode.Restart|Reverse)。
怎么用
@Composable
fun PulseDot() {
val inf = rememberInfiniteTransition(label = "pulse")
val a by inf.animateFloat(
0.35f, 1f,
infiniteRepeatable(tween(800), RepeatMode.Reverse),
label = "a"
)
Box(Modifier.size(10.dp).graphicsLayer { alpha = a }.background(Color.Red, CircleShape))
}
@Composable
fun Spinner() {
val inf = rememberInfiniteTransition(label = "spin")
val deg by inf.animateFloat(
0f, 360f,
infiniteRepeatable(tween(1000, easing = LinearEasing)),
label = "r"
)
Icon(Icons.Default.Refresh, null, Modifier.rotate(deg))
}
易错点
离开页面后组合还在(比如 ViewPager 预加载、BottomBar 多页全挂着)会一直转,耗电。不可见时不要挂 Infinite;或让 targetValue 停在常数并配合 Visibility 卸掉。
AnimationSpec 与 Easing 常用
概念
所有「播多快、像不像弹簧」都走 AnimationSpec。官方默认很多是 spring()(跟手、可打断)。要固定时长用 tween。
规格 干什么 频率
spring(dampingRatio, stiffness)物理弹簧;可中途改目标 常用
tween(durationMillis, delayMillis, easing)固定时长 常用
snap(delayMillis)几乎立刻到位(可延迟) 偶尔
keyframes { 0f at 0; 1f at 200 ... }途经点;弹性/颠一下 偶尔
repeatable(iterations, animation, RepeatMode)有限次重复 偶尔
infiniteRepeatable无限(InfiniteTransition 内部也用它) 常用
Easing(tween 用)
官方常用:FastOutSlowInEasing(Material 标准)、LinearOutSlowInEasing(进场)、FastOutLinearInEasing(离场)、LinearEasing(旋转)。也可用 CubicBezierEasing。1.7+ 另有 EaseIn / EaseOut / EaseInOut 一组别名。
怎么用
animateDpAsState(
targetValue = if (open) 200.dp else 0.dp,
animationSpec = spring(dampingRatio = 0.8f, stiffness = Spring.StiffnessMedium),
label = "h"
)
fadeIn(tween(220, easing = FastOutSlowInEasing))
val spec = keyframes<Float> {
durationMillis = 400
0f at 0 using LinearEasing
1.1f at 240 using FastOutSlowInEasing
1f at 400
}
易错点
spring 没有 duration;不要问「弹簧播 300ms」。跟手拖拽必须用弹簧或 Animatable,用 tween 会发硬。
Crossfade 偶尔
概念
AnimatedContent 的淡入淡出特化版。图标切换、日夜间小图标、两个状态交叉溶解时一行够用。要滑动、展开、按方向进出,直接上 Content。
怎么用
Crossfade(targetState = tab, label = "tab") { t ->
when (t) {
Tab.Home -> HomePane()
Tab.Me -> MePane()
}
}
updateTransition 偶尔
概念
一个 targetState(枚举最好)同时驱动多个 animateDp / animateColor / animateFloat / animateValue,保证同一时钟。比并列多个 AsState 更不容易「颜色已到位、高度还在跑」却对不齐。
它还提供扩展:Transition.AnimatedVisibility / Transition.AnimatedContent,把进出也挂到同一 Transition 上,方便外面读 currentState / isRunning。
怎么用
enum class BoxState { Collapsed, Expanded }
@Composable
fun Header(state: BoxState) {
val t = updateTransition(state, label = "header")
val h by t.animateDp(label = "h") { if (it == BoxState.Expanded) 180.dp else 56.dp }
val c by t.animateColor(label = "c") {
if (it == BoxState.Expanded) Color(0xFF152033) else Color(0xFF171A21)
}
Box(Modifier.fillMaxWidth().height(h).background(c))
}
易错点
单个属性仍优先 AsState。只有「同一状态、好几条属性必须齐步」才上 Transition。
Animatable 偶尔
概念
协程里的单值动画:animateTo、snapTo、animateDecay、stop。底层其实就是 AsState 的实现。只有这些才需要它:
顺序动画:先弹起来再落下(两个 animateTo 挨着 await)。
手势跟手:拖时 snapTo,松手 animateTo / animateDecay(fling)。
动画中途改目标,且要接着当前速度(弹簧连续)。
和 XML 对照
≈ 自己持有 ValueAnimator,在松手回调里 start。Compose 里在 pointerInput / rememberCoroutineScope 里 launch。
怎么用
@Composable
fun TossDot() {
val x = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
Box(
Modifier
.offset { IntOffset(x.value.roundToInt(), 0) }
.pointerInput(Unit) {
detectDragGestures(
onDrag = { _, drag -> scope.launch { x.snapTo(x.value + drag.x) } },
onDragEnd = {
scope.launch {
x.animateDecay(
initialVelocity = 0f,
animationSpec = exponentialDecay()
)
x.animateTo(0f, spring())
}
}
)
}
.size(40.dp)
.background(Color.Blue, CircleShape)
)
}
官方还有泛型 Animatable<T, V : AnimationVector>,以及 animateDecay 用的 exponentialDecay()(DecayAnimationSpec)。
易错点
在组合体(不是协程)里调 animateTo → 编译都过不了;挂起函数只能在 Effect / scope.launch。多个协程同时 animateTo 同一实例会互斥取消,这是特性。
共享元素 SharedTransitionLayout 偶尔
概念
1.7+ 稳定方向:列表缩略图飞到详情大图。外壳 SharedTransitionLayout 提供 SharedTransitionScope;成对控件用同一个 rememberSharedContentState(key)。
Modifier.sharedElement():同一块内容在两边变形(图)。
Modifier.sharedBounds():两边内容不同,只共享边框过渡(标题条)。
通常配合 AnimatedContent / AnimatedVisibility / NavHost 的 AnimatedVisibilityScope。
怎么用
@Composable
fun Catalog(showDetail: Boolean, item: Item) {
SharedTransitionLayout {
AnimatedContent(showDetail, label = "detail") { detail ->
if (!detail) {
Image(
item.thumb, null,
Modifier.sharedElement(
rememberSharedContentState(key = "img-${item.id}"),
animatedVisibilityScope = this
)
)
} else {
Image(
item.photo, null,
Modifier.sharedElement(
rememberSharedContentState(key = "img-${item.id}"),
animatedVisibilityScope = this
)
)
}
}
}
}
易错点
key 两边必须一致;列表必须用 item.id,不能用 index。
两边都要在同一个 SharedTransitionLayout 子树里。
导航场景要把 Scope 往下传(参数 / CompositionLocal),子页才能挂 sharedElement。
🔗 共享元素过渡
自定义类型 · animateValueAsState 偶尔
概念
官方没提供的类型(自定义 data class、奇数枚举映射)用 animateValueAsState / Transition.animateValue / Animatable,并给 TwoWayConverter<T, AnimationVector*>:把 T 拆成 1~4 个 Float(AnimationVector1D/2D/3D/4D)。
怎么用
data class Corner(val tl: Float, val br: Float)
val CornerConverter = TwoWayConverter<Corner, AnimationVector2D>(
convertToVector = { AnimationVector2D(it.tl, it.br) },
convertFromVector = { Corner(it.v1, it.v2) }
)
val corner by animateValueAsState(
targetValue = Corner(8f, 24f),
typeConverter = CornerConverter,
label = "corner"
)
更底层(知道即可)
业务页几乎用不到,调试或写库时会碰到:
TargetBasedAnimation / DecayAnimation:不自动驱动 UI,自己按时间戳取 valueFromNanos。
AnimationVector*:规格内部的向量空间。
VectorConverter:Dp / Offset / Color 等内置转换,一般不用自己写。
LookaheadScope + Modifier.approachLayout / animateBounds(较新):先看最终布局再过渡。共享元素底层会用到;业务优先用 SharedTransition / contentSize。
不在本页展开:ConstraintLayout 的 MotionLayout 、第三方 Lottie / Rive。那是另一套时间轴,不是 compose.animation。
易错合集
动画目标写在组合体里每帧都变 → 永远播不完。目标必须来自稳定 State。
if (show) 和 Visibility 搞混:要进出动画就不要用裸 if 卸子树。
忘记 label:Layout Inspector / 动画检查器里一堆匿名动画。
在列表里做动画却用 index 当 key → 见列表 。
Infinite 动画在不可见页还挂着 → 耗电。
用延时 delay + 改 State 模拟 tween → 丢帧且不可打断;用规格。
无障碍:纯装饰动画不要抢焦点;减少动态可跟系统「关闭动画」走(用户开了开发者选项 / 无障碍减弱动画时,官方 API 会缩短或跳过不少过渡)。
Android Compose 现代开发知识体系 · 主线必读 / 扩展选读。
术语表常驻;组件与属性先看分讲,再查两张总表。案例默认收起。