Compose 动画 — 显隐的艺术

0 阅读9分钟

0.png

上一篇文章中,我们讲解了 Android 最简单的动画 —— animateDpAsState

今天,我们再讲解一个简单的 Compose 动画 —— AnimatedVisibility

话不多说,立马开始!

在 Jetpack Compose 中,AnimatedVisibility 是实现组件显示/隐藏动画的标准方式。

不过因为 Compose 是基于状态管理的,可能大部分开发者在控制 UI 显隐的时候,直接用了


if(visible) {
    Text(xxx)
} else {
    Box(xxx)
}

这样的代码去做。

而一部分知晓 AnimatedVisibility 的开发者,可能也只是简单地传入一个单一的过渡效果便浅尝辄止。

又不是不能用···

然而,AnimatedVisibility 真正的魅力在于如何将多个过渡效果巧妙组合,并通过时机、方向和原点的精细调控,塑造出流畅自然的动态体验。

本文将通过展示一些 AnimatedVisibility 过渡效果的实例,深入探讨 + 运算符将 slideInfadeIn 和 scaleIn 等融合为统一的显隐效果,以及相关的 API 参数如何协调整个动画的编排节奏。

核心 APIAnimatedVisibilityslideInfadeInscaleIn

简单示例

我们先从一个简单的示例开始:


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 包裹着一个写着文字的卡片。

主要有三个参数驱动这个动画:

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 主要用于控制显隐效果。

看下效果:

2_1.gif

enterexit 可以随意变成你想要的显隐方式。

例如 fade

enter = fadeIn(animationSpec = tween(ANIM_DURATION_MS)),
exit = fadeOut(animationSpec = tween(ANIM_DURATION_MS)),

2_2.gif

或者 scale

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),
),

2_3.gif

效果组合

如果你不满足于单一的显隐效果,可以使用 + 运算符叠加多个过渡效果。

例如,我们使用 slide + scale

// 叠加两种效果
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")
}

2_4.gif

需要注意的是,EnterTransitionExitTransition 上的 + 运算符并非按顺序执行动画,Compose 将它们合并为一个并行执行的过渡效果。

当你使用 scaleIn(...) + slideInHorizontally(...) 时,框架会同时启动这两个动画,并在同一时刻结束它们,共享同一个可见性生命周期。

这正是当前这个卡片从右侧滑入的同时 scale 值从零开始攀升,且两者在同一帧完成动画的原因。

当然,你也可以组合 scale + fade

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")
}

2_5.gif

这里,我们做了一个从右下角生长动画的效果,就像卡片从右下角长出来一样,同时叠加了一个 fade 效果,让显隐的过渡更加平滑。

scale 动画的 transformOrigin 参数,可以设置缩放的原点,(0.5f, 0.5f) 为中心位置,(1f, 1f) 为右下角。

实际上,上面的内容几乎已经涵盖了所有的 AnimatedVisibility 的技术点,只要开发者在它的 API 基础上稍加调试,一定能够做出满意的显隐动画。

但是作为一篇文章,这么草草结束确实显得有点仓促,所以我再稍微详细讲解一下部分 API。

slide

如果我们只看 slideInslideOut 是对应的,讲解一个即可),这个 API 一般有三种支持的滑动方式:

  • slideInHorizontally:横向的滑动。
  • slideInVertically:竖向的滑动。
  • slideIn:滑动进入。

哈哈!你没想到吧!还真有一个 slideIn

slideInHorizontallyslideInVertically 比较简单,它们是特化的 slideIn

例如:

slideInHorizontally(
    animationSpec = tween(ANIM_DURATION_MS),
    initialOffsetX = { fullWidth -> -fullWidth },
)

initialOffsetX 是为了确定从哪里进来,这个 lambda 表达式会给你一个参数 fullWidth,表示当前控件的宽度。这样我们就可以完成从屏幕外完全移入的效果。

slideIn 稍稍有点特殊:

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 表达式会给你一个表示当前控件尺寸的参数,你可以使用这个来定义任意位置的滑动进出效果。

上面代码效果是这样的:

2_6.gif

veil

代码很简单:

AnimatedVisibility(
    visible = visible,
    enter = unveilIn(animationSpec = tween(ANIM_DURATION_MS)),
    exit = veilOut(animationSpec = tween(ANIM_DURATION_MS)),
) {
    AnimChip(text = "VEIL")
}

animationSpec 你可以不写,这里只是为了使用我们默认的动画时长。

我们先看下效果:

2_7.gif

veil 的核心概念是遮罩层(scrim)。与 slidefadescale 这些直接改变内容自身属性的过渡不同,unveilInveilOut 是在内容上方覆盖一层颜色遮罩,通过改变这层遮罩的透明度来实现"揭开"和"蒙上"的视觉效果。

  • unveilIn(进入):组件显示时,遮罩层从完全不透明逐渐变为完全透明,就像掀开盖在内容上的幕布,让下面的内容慢慢显露出来。
  • veilOut(退出):组件隐藏时,遮罩层从完全透明逐渐变为半透明(默认黑色 50% 透明度),像给内容盖上一层薄纱,使其逐渐隐去。

除了 animationSpec,veil 还提供了两个关键参数:

  • initialColor / targetColor:分别控制进入和退出时遮罩层的颜色。默认值为 Color.Black.copy(alpha = 0.5f),即半透明黑色。你可以根据场景调整,比如使用白色遮罩实现"淡入白纸"的效果,或者使用品牌色增强视觉一致性。
  • matchParentSize:控制遮罩层是否匹配父布局尺寸。当设为 true 时,遮罩层独立于内容本身的变换,始终覆盖整个父区域;当设为 false 时,遮罩层会先应用,因此会受到其他变换(如 scale、slide)的影响。如果布局上使用了 clip 修饰符,即使 matchParentSizetrue,遮罩层也可能被裁剪。

veil 动画的独特之处在于它不直接操控内容,而是通过一层"幕布"来间接控制可见性。这种间接性让它非常适合用于页面转场、对话框弹出等场景——你可以让旧内容被一层有色遮罩覆盖,同时新内容从下方滑入,营造出层次分明的空间感。

shrink

AnimatedVisibility(
    visible = visible,
    enter = expandIn(animationSpec = tween(ANIM_DURATION_MS)),
    exit = shrinkOut(animationSpec = tween(ANIM_DURATION_MS)),
) {
    AnimChip(text = "Shrink/Expand")
}

效果先行:

2_8.gif

shrink/expand 的核心机制是裁剪边界(clip bounds)动画。

与 slide 直接移动内容本身不同,expand/shrink 是通过改变内容的可见边界来实现显示和隐藏的。你可以把它理解为"拉开幕布"和"拉上幕布"——内容本身没有变形,只是被看到的区域在逐渐扩大或缩小。

  • expandIn(进入):组件显示时,裁剪边界从一个极小的尺寸逐步扩展到内容的完整尺寸,内容随之从局部到整体逐渐显露。
  • shrinkOut(退出):组件隐藏时,裁剪边界从完整尺寸逐步缩小,内容被逐渐遮挡,直到完全不可见。

除了 animationSpecexpand/shrink 还提供了几个关键参数:

  • expandFrom / shrinkTowards:控制展开或收缩的起始锚点。例如 expandFrom = Alignment.Top 表示从顶部开始向下展开,shrinkTowards = Alignment.Bottom 表示向底部收缩。默认值通常是 Alignment.BottomAlignment.End,你可以根据布局需求调整,让动画的展开/收缩方向与用户的阅读流或操作意图保持一致。
  • clip:控制是否裁剪超出动画边界的部分,默认为 true。当设为 true 时,只有当前边界内的内容可见;设为 false 则允许内容溢出边界显示。大多数情况下保持默认即可,但在某些需要内容"提前露头"的场景下可以关闭裁剪。
  • initialSize / targetSize:一个 lambda 表达式,接收内容的完整尺寸并返回动画起始(或结束)时的边界尺寸。默认返回 IntSize.Zero,即从完全不可见开始展开。你也可以返回一个比例值,比如 { fullSize -> IntSize(fullSize.width / 2, fullSize.height / 2) },让内容从一半大小开始展开,创造出更有层次感的入场效果。

shrink/expand 还有一个重要特性:它会驱动依赖该尺寸的其他布局同步动画。

因为 expand/shrink 实际上改变的是组件在布局中的占用空间,所以周围的元素会自动跟随调整位置,就像 Modifier.animateContentSize 的效果一样。这使得它非常适合用于折叠面板、列表项展开/收起等需要整体布局响应的场景。

上面的示例比较简单,下面来个稍微复杂一点的,模拟幕布上下拉的效果:

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)
}

2_9.gif

你会发现,expand/shrink 改变了组件在布局中的占用空间,所以下面的文字内容也跟着向上顶了!

小提示

enterexit 的动画并不是强制对应的——不是说用了 slideIn 就必须搭配 slideOut,也不是说用了 expandIn 就必须搭配 shrinkOut

你可以根据场景自由组合,比如进入时用 slideIn 从右侧滑入,退出时用 fadeOut 直接淡出。

不过,大部分显隐动画的本质是表达"从哪儿来、回哪儿去"的空间叙事,所以通常情况下,成对使用(如 slideInslideOutscaleInscaleOut)会让用户的认知负担更小,视觉语义也更连贯。

当你打破这种对应关系时,最好确保这个"不对称"本身是有意为之的设计选择,而非随意混搭。

总结

本文系统梳理了 AnimatedVisibility 的多种过渡效果及其组合方式,核心要点如下:

单一过渡slideIn/slideOut 控制位置平移,fadeIn/fadeOut 控制透明度渐变,scaleIn/scaleOut 控制缩放比例,expandIn/shrinkOut 控制裁剪边界,unveilIn/veilOut 通过遮罩层间接控制可见性。每种过渡都有明确的语义和适用场景。

组合过渡+ 运算符将多个过渡合并为并行执行的单一动画规范,而非顺序链式执行。这意味着 slidefadescale 可以同时发生、同时结束,共享同一个可见性生命周期。统一 ANIM_DURATION_MS 是确保组合动画协调一致的关键。

参数调控initialOffsetX/targetOffsetX 的 lambda 在运行时读取布局数据,确保方向变化在不同设备尺寸下都能正常工作;transformOrigin 连接动画与其空间上下文;expandFrom/shrinkTowards 决定展开收缩的锚点方向;initialColor/targetColorveil 动画能够融入品牌视觉。

合理的运用这些动画,能产出更平滑的视觉效果。