K动画(扩展)

不挡主线。Compose 动画仍是 UI = f(state):状态变了,值或进出平滑过渡。按业务 App 真实使用频率覆盖官方 androidx.compose.animation* 全家桶;Lottie / Rive / MotionLayout 不是这套 API,本页不讲。

先选对 API

常用业务页几乎天天写 偶尔对上场景再用
你想做的事用这个频率
颜色、尺寸、透明度、偏移跟状态走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、间距
animateFloatAsStateFloatalpha、scale、progress、rotation
animateColorAsStateColor选中色、主题切换过渡
animateIntAsStateInt计数滚动、页码
animateOffsetAsStateOffset画布/图层偏移(px)
animateIntOffsetAsStateIntOffsetModifier.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 里的 transitionanimateEnterExit
  • 在重组里把 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 偶尔

概念

协程里的单值动画:animateTosnapToanimateDecaystop。底层其实就是 AsState 的实现。只有这些才需要它:

  • 顺序动画:先弹起来再落下(两个 animateTo 挨着 await)。
  • 手势跟手:拖时 snapTo,松手 animateTo / animateDecay(fling)。
  • 动画中途改目标,且要接着当前速度(弹簧连续)。

和 XML 对照

≈ 自己持有 ValueAnimator,在松手回调里 start。Compose 里在 pointerInput / rememberCoroutineScopelaunch

怎么用

@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 / NavHostAnimatedVisibilityScope

怎么用

@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 现代开发知识体系 · 主线必读 / 扩展选读。
术语表常驻;组件与属性先看分讲,再查两张总表。案例默认收起。