
在上一篇文章中,我们讲解了 Android 最简单的动画 ------ animateDpAsState。
今天,我们再讲解一个简单的 Compose 动画 ------ AnimatedVisibility。
话不多说,立马开始!
在 Jetpack Compose 中,AnimatedVisibility 是实现组件显示/隐藏动画的标准方式。
不过因为 Compose 是基于状态管理的,可能大部分开发者在控制 UI 显隐的时候,直接用了
kotlin
if(visible) {
Text(xxx)
} else {
Box(xxx)
}
这样的代码去做。
而一部分知晓 AnimatedVisibility 的开发者,可能也只是简单地传入一个单一的过渡效果便浅尝辄止。
又不是不能用···
然而,AnimatedVisibility 真正的魅力在于如何将多个过渡效果巧妙组合,并通过时机、方向和原点的精细调控,塑造出流畅自然的动态体验。
本文将通过展示一些 AnimatedVisibility 过渡效果的实例,深入探讨 + 运算符将 slideIn、fadeIn 和 scaleIn 等融合为统一的显隐效果,以及相关的 API 参数如何协调整个动画的编排节奏。
核心 API :AnimatedVisibility、slideIn、fadeIn、scaleIn。
简单示例
我们先从一个简单的示例开始:
Kotlin
private const val ANIM_DURATION_MS = 600
private const val SLIDE_FROM_RIGHT = true
//...
var visible by remember { mutableStateOf(true) }
AnimatedVisibility(
visible = visible,
enter = slideInHorizontally(
animationSpec = tween(ANIM_DURATION_MS),
initialOffsetX = { fullWidth ->
if (SLIDE_FROM_RIGHT) fullWidth else -fullWidth
},
),
exit = slideOutHorizontally(
animationSpec = tween(ANIM_DURATION_MS),
targetOffsetX = { fullWidth ->
if (SLIDE_FROM_RIGHT) fullWidth else -fullWidth
},
)
) {
AnimChip(text = "SLIDE")
}
一个简单的 AnimatedVisibility 包裹着一个写着文字的卡片。
主要有三个参数驱动这个动画:
Kotlin
private const val ANIM_DURATION_MS = 600
private const val SLIDE_FROM_RIGHT = true
var visible by remember { mutableStateOf(true) }
ANIM_DURATION_MS 表示整个动画的时长。
SLIDE_FROM_RIGHT 则是一个布尔开关,用于在滑动过渡的偏移 lambda 中进行方向分支判断。
而 visible 主要用于控制显隐效果。
看下效果:

enter 和 exit 可以随意变成你想要的显隐方式。
例如 fade:
kotlin
enter = fadeIn(animationSpec = tween(ANIM_DURATION_MS)),
exit = fadeOut(animationSpec = tween(ANIM_DURATION_MS)),

或者 scale:
kotlin
enter = scaleIn(
animationSpec = tween(ANIM_DURATION_MS),
transformOrigin = TransformOrigin(0.5f, 0.5f),
),
exit = scaleOut(
animationSpec = tween(ANIM_DURATION_MS),
transformOrigin = TransformOrigin(0.5f, 0.5f),
),

效果组合
如果你不满足于单一的显隐效果,可以使用 + 运算符叠加多个过渡效果。
例如,我们使用 slide + scale:
kotlin
// 叠加两种效果
AnimatedVisibility(
visible = visible,
enter = scaleIn(
animationSpec = tween(ANIM_DURATION_MS),
transformOrigin = TransformOrigin(0.5f, 0.5f),
) + slideInHorizontally(
animationSpec = tween(ANIM_DURATION_MS),
initialOffsetX = { fullWidth -> -fullWidth },
),
exit = scaleOut(
animationSpec = tween(ANIM_DURATION_MS),
transformOrigin = TransformOrigin(0.5f, 0.5f),
) + slideOutHorizontally(
animationSpec = tween(ANIM_DURATION_MS),
targetOffsetX = { fullWidth -> fullWidth },
),
) {
AnimChip(text = "SCALE + SLIDE")
}

需要注意的是,EnterTransition 和 ExitTransition 上的 + 运算符并非按顺序执行动画,Compose 将它们合并为一个并行执行的过渡效果。
当你使用 scaleIn(...) + slideInHorizontally(...) 时,框架会同时启动这两个动画,并在同一时刻结束它们,共享同一个可见性生命周期。
这正是当前这个卡片从右侧滑入的同时 scale 值从零开始攀升,且两者在同一帧完成动画的原因。
当然,你也可以组合 scale + fade:
Kotlin
AnimatedVisibility(
visible = visible,
enter = scaleIn(
animationSpec = tween(ANIM_DURATION_MS),
transformOrigin = TransformOrigin(1f, 1f),
) + fadeIn(
animationSpec = tween(ANIM_DURATION_MS),
),
exit = scaleOut(
animationSpec = tween(ANIM_DURATION_MS),
transformOrigin = TransformOrigin(1f, 1f),
) + fadeOut(
animationSpec = tween(ANIM_DURATION_MS),
),
) {
AnimChip(text = "SCALE + FADE")
}

这里,我们做了一个从右下角生长动画的效果,就像卡片从右下角长出来一样,同时叠加了一个 fade 效果,让显隐的过渡更加平滑。
scale 动画的 transformOrigin 参数,可以设置缩放的原点,(0.5f, 0.5f) 为中心位置,(1f, 1f) 为右下角。
实际上,上面的内容几乎已经涵盖了所有的 AnimatedVisibility 的技术点,只要开发者在它的 API 基础上稍加调试,一定能够做出满意的显隐动画。
但是作为一篇文章,这么草草结束确实显得有点仓促,所以我再稍微详细讲解一下部分 API。
slide
如果我们只看 slideIn(slideOut 是对应的,讲解一个即可),这个 API 一般有三种支持的滑动方式:
- slideInHorizontally:横向的滑动。
- slideInVertically:竖向的滑动。
- slideIn:滑动进入。
哈哈!你没想到吧!还真有一个 slideIn。
slideInHorizontally 和 slideInVertically 比较简单,它们是特化的 slideIn。
例如:
kotlin
slideInHorizontally(
animationSpec = tween(ANIM_DURATION_MS),
initialOffsetX = { fullWidth -> -fullWidth },
)
initialOffsetX 是为了确定从哪里进来,这个 lambda 表达式会给你一个参数 fullWidth,表示当前控件的宽度。这样我们就可以完成从屏幕外完全移入的效果。
slideIn 稍稍有点特殊:
Kotlin
AnimatedVisibility(
visible = visible,
enter = slideIn(
animationSpec = tween(ANIM_DURATION_MS),
initialOffset = { size ->
IntOffset(size.width, size.height)
},
),
exit = slideOut(
animationSpec = tween(ANIM_DURATION_MS),
targetOffset = { size ->
IntOffset(-size.width, -size.height)
},
),
) {
AnimChip(text = "SLIDEIN")
}
它需要一个 initialOffset 作为起始位置,同样,这个 lambda 表达式会给你一个表示当前控件尺寸的参数,你可以使用这个来定义任意位置的滑动进出效果。
上面代码效果是这样的:

veil
代码很简单:
kotlin
AnimatedVisibility(
visible = visible,
enter = unveilIn(animationSpec = tween(ANIM_DURATION_MS)),
exit = veilOut(animationSpec = tween(ANIM_DURATION_MS)),
) {
AnimChip(text = "VEIL")
}
animationSpec 你可以不写,这里只是为了使用我们默认的动画时长。
我们先看下效果:

veil 的核心概念是遮罩层(scrim)。与 slide、fade、scale 这些直接改变内容自身属性的过渡不同,unveilIn 和 veilOut 是在内容上方覆盖一层颜色遮罩,通过改变这层遮罩的透明度来实现"揭开"和"蒙上"的视觉效果。
unveilIn(进入):组件显示时,遮罩层从完全不透明逐渐变为完全透明,就像掀开盖在内容上的幕布,让下面的内容慢慢显露出来。veilOut(退出):组件隐藏时,遮罩层从完全透明逐渐变为半透明(默认黑色 50% 透明度),像给内容盖上一层薄纱,使其逐渐隐去。
除了 animationSpec,veil 还提供了两个关键参数:
- initialColor / targetColor :分别控制进入和退出时遮罩层的颜色。默认值为
Color.Black.copy(alpha = 0.5f),即半透明黑色。你可以根据场景调整,比如使用白色遮罩实现"淡入白纸"的效果,或者使用品牌色增强视觉一致性。 - matchParentSize :控制遮罩层是否匹配父布局尺寸。当设为
true时,遮罩层独立于内容本身的变换,始终覆盖整个父区域;当设为false时,遮罩层会先应用,因此会受到其他变换(如 scale、slide)的影响。如果布局上使用了clip修饰符,即使matchParentSize为true,遮罩层也可能被裁剪。
veil 动画的独特之处在于它不直接操控内容,而是通过一层"幕布"来间接控制可见性。这种间接性让它非常适合用于页面转场、对话框弹出等场景------你可以让旧内容被一层有色遮罩覆盖,同时新内容从下方滑入,营造出层次分明的空间感。
shrink
kotlin
AnimatedVisibility(
visible = visible,
enter = expandIn(animationSpec = tween(ANIM_DURATION_MS)),
exit = shrinkOut(animationSpec = tween(ANIM_DURATION_MS)),
) {
AnimChip(text = "Shrink/Expand")
}
效果先行:

shrink/expand 的核心机制是裁剪边界(clip bounds)动画。
与 slide 直接移动内容本身不同,expand/shrink 是通过改变内容的可见边界来实现显示和隐藏的。你可以把它理解为"拉开幕布"和"拉上幕布"------内容本身没有变形,只是被看到的区域在逐渐扩大或缩小。
- expandIn(进入):组件显示时,裁剪边界从一个极小的尺寸逐步扩展到内容的完整尺寸,内容随之从局部到整体逐渐显露。
- shrinkOut(退出):组件隐藏时,裁剪边界从完整尺寸逐步缩小,内容被逐渐遮挡,直到完全不可见。
除了 animationSpec,expand/shrink 还提供了几个关键参数:
expandFrom/shrinkTowards:控制展开或收缩的起始锚点。例如expandFrom = Alignment.Top表示从顶部开始向下展开,shrinkTowards = Alignment.Bottom表示向底部收缩。默认值通常是Alignment.Bottom或Alignment.End,你可以根据布局需求调整,让动画的展开/收缩方向与用户的阅读流或操作意图保持一致。clip:控制是否裁剪超出动画边界的部分,默认为true。当设为true时,只有当前边界内的内容可见;设为false则允许内容溢出边界显示。大多数情况下保持默认即可,但在某些需要内容"提前露头"的场景下可以关闭裁剪。initialSize/targetSize:一个 lambda 表达式,接收内容的完整尺寸并返回动画起始(或结束)时的边界尺寸。默认返回IntSize.Zero,即从完全不可见开始展开。你也可以返回一个比例值,比如{ fullSize -> IntSize(fullSize.width / 2, fullSize.height / 2) },让内容从一半大小开始展开,创造出更有层次感的入场效果。
shrink/expand 还有一个重要特性:它会驱动依赖该尺寸的其他布局同步动画。
因为 expand/shrink 实际上改变的是组件在布局中的占用空间,所以周围的元素会自动跟随调整位置,就像 Modifier.animateContentSize 的效果一样。这使得它非常适合用于折叠面板、列表项展开/收起等需要整体布局响应的场景。
上面的示例比较简单,下面来个稍微复杂一点的,模拟幕布上下拉的效果:
kotlin
Column(
modifier = Modifier.fillMaxWidth(),
) {
AnimatedVisibility(
visible = visible,
enter = expandIn(animationSpec = tween(ANIM_DURATION_MS), expandFrom = Alignment.TopCenter, initialSize = { size -> IntSize(size.width, 0) }),
exit = shrinkOut(animationSpec = tween(ANIM_DURATION_MS), shrinkTowards = Alignment.TopCenter, targetSize = { size -> IntSize(size.width, 0) }),
) {
AnimChip(text = "Shrink/Expand")
}
Text("关注 RockByte 公众号", fontSize = 16.sp)
}

你会发现,expand/shrink 改变了组件在布局中的占用空间,所以下面的文字内容也跟着向上顶了!
小提示
enter 和 exit 的动画并不是强制对应的------不是说用了 slideIn 就必须搭配 slideOut,也不是说用了 expandIn 就必须搭配 shrinkOut。
你可以根据场景自由组合,比如进入时用 slideIn 从右侧滑入,退出时用 fadeOut 直接淡出。
不过,大部分显隐动画的本质是表达"从哪儿来、回哪儿去"的空间叙事,所以通常情况下,成对使用(如 slideIn 配 slideOut、scaleIn 配 scaleOut)会让用户的认知负担更小,视觉语义也更连贯。
当你打破这种对应关系时,最好确保这个"不对称"本身是有意为之的设计选择,而非随意混搭。
总结
本文系统梳理了 AnimatedVisibility 的多种过渡效果及其组合方式,核心要点如下:
单一过渡 :slideIn/slideOut 控制位置平移,fadeIn/fadeOut 控制透明度渐变,scaleIn/scaleOut 控制缩放比例,expandIn/shrinkOut 控制裁剪边界,unveilIn/veilOut 通过遮罩层间接控制可见性。每种过渡都有明确的语义和适用场景。
组合过渡 :+ 运算符将多个过渡合并为并行执行的单一动画规范,而非顺序链式执行。这意味着 slide、fade、scale 可以同时发生、同时结束,共享同一个可见性生命周期。统一 ANIM_DURATION_MS 是确保组合动画协调一致的关键。
参数调控 :initialOffsetX/targetOffsetX 的 lambda 在运行时读取布局数据,确保方向变化在不同设备尺寸下都能正常工作;transformOrigin 连接动画与其空间上下文;expandFrom/shrinkTowards 决定展开收缩的锚点方向;initialColor/targetColor 让 veil 动画能够融入品牌视觉。
合理的运用这些动画,能产出更平滑的视觉效果。