让界面"活"起来 —— 掌握动画系统与副作用管理,打造流畅且可靠的用户体验
核心方法论:赋予画面生命与呼吸
「一幅伟大的画作,关键不在于静止的完美,而在于它能让观者感受到运动与生命。Compose 的动画系统正是如此——你赋予界面呼吸的节奏,用户便能感受到应用的生命力。让我们像文艺复兴的大师一样,用工程的精确与艺术的感性,为每一个像素注入灵魂。」
在我的画室里,每一幅杰作都始于对运动的深刻理解。鸟的飞翔、水的流淌、光影的变幻——自然界中没有真正静止的事物。同样,现代移动应用中,动画是不可或缺的灵魂。恰到好处的动画就像画作中精心设计的视线引导——它牵引用户的注意力,为操作提供反馈,让界面过渡如丝般自然。Compose 的声明式特性让动画创作变得前所未有的简单,就像我发明的晕涂法(sfumato)一样,你只需要告诉画布目标状态,Compose 就会自动帮你完成那优雅的过渡。
Compose 为你准备了一套层次分明的动画工具,如同画家工作台上从粗到细的各种画笔:
| API 层级 | 代表 API | 适用场景 |
|---|---|---|
| 高级 API | AnimatedVisibility, AnimatedContent, Crossfade | 组件显示/隐藏、内容切换 |
| 值动画 API | animate*AsState, animateContentSize | 单个属性值的动画过渡 |
| 过渡 API | updateTransition, rememberInfiniteTransition | 多属性协调动画、无限循环 |
| 低级 API | Animatable, AnimationState | 精确控制时机、手势驱动 |
正如绘画中应先用宽笔铺大色块、再用细笔勾勒细节,动画 API 的选择也遵循同样的原则:优先使用高级 API。只有当高级 API 无法满足需求时,才降级到低级 API。大多数业务场景下,animate*AsState 和 AnimatedVisibility 就已经足够了——好的大师知道何时克制,何时精雕。
如果说文艺复兴绘画的精髓在于"渐变"——从光到影的柔和过渡、从暖色到冷色的微妙转换——那么 animate*AsState 就是 Compose 中的渐变大师。这个系列是 Compose 中最常用的动画 API。给它一个目标值,当目标值变化时,它会如同我用画笔混合颜料一样,自动从当前值平滑过渡到新值。常用的有 animateDpAsState(尺寸)、animateColorAsState(颜色)、animateFloatAsState(透明度、角度)等。
下面是一个卡片在点击时同时改变大小和颜色的示例——想象一下,这就像一朵花在晨光中绽放,尺寸舒展、色彩流转:
@Composable
fun AnimatedCard() {
var expanded by remember { mutableStateOf(false) }
val cardWidth by animateDpAsState(
targetValue = if (expanded) 300.dp else 150.dp,
label = "cardWidth"
)
val cardHeight by animateDpAsState(
targetValue = if (expanded) 200.dp else 100.dp,
label = "cardHeight"
)
val cardColor by animateColorAsState(
targetValue = if (expanded) Color(0xFF6200EE) else Color(0xFF03DAC5),
label = "cardColor"
)
val cornerRadius by animateDpAsState(
targetValue = if (expanded) 24.dp else 8.dp,
label = "corner"
)
Box(
modifier = Modifier
.width(cardWidth).height(cardHeight)
.clip(RoundedCornerShape(cornerRadius))
.background(cardColor)
.clickable { expanded = !expanded },
contentAlignment = Alignment.Center
) {
Text(
if (expanded) "点击收起" else "点击展开",
color = Color.White
)
}
}
在我看来,运动的灵魂在于节奏。一个物体从高处落下,它不是匀速的——它加速、减速、反弹。animationSpec 参数正是你掌控这种运动韵律的工具,就像指挥家手中的指挥棒,决定了乐章的快慢与轻重。默认使用弹簧动画,但你可以通过 animationSpec 参数自定义:
| animationSpec | 特点 | 适用场景 |
|---|---|---|
spring() | 基于物理弹簧模型,自然流畅 | 大多数 UI 动画(默认) |
tween() | 基于时长的插值,可设置缓动曲线 | 精确控制时长 |
snap() | 瞬间切换,无过渡 | 调试或即时切换 |
keyframes() | 在指定时间点设置关键帧 | 分段控制动画过程 |
repeatable() | 重复指定次数 | 脉冲、闪烁效果 |
// 弹簧动画:设置阻尼比和刚度
val size by animateDpAsState(
targetValue = targetSize,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
label = "size"
)
// 补间动画:500ms,带缓动曲线
val alpha by animateFloatAsState(
targetValue = targetAlpha,
animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing),
label = "alpha"
)
在文艺复兴时期的戏剧中,角色的登场与退场总是经过精心编排的——光线渐亮、帷幕徐开,绝不是突然闪现。AnimatedVisibility 正是这位舞台导演,它控制组件优雅地出场与谢幕。你只需控制一个布尔值,如同拉动帷幕的绳索,剩下的编排交给 Compose。它提供了丰富的进入/退出动画,且可以用 + 号组合多种效果,就像在画布上叠加多层颜料以创造更丰富的视觉效果:
| 进入动画 | 退出动画 | 效果 |
|---|---|---|
fadeIn() | fadeOut() | 淡入 / 淡出 |
slideInHorizontally() | slideOutHorizontally() | 水平滑入 / 滑出 |
slideInVertically() | slideOutVertically() | 垂直滑入 / 滑出 |
expandVertically() | shrinkVertically() | 垂直展开 / 收缩 |
expandIn() | shrinkOut() | 从中心展开 / 收缩 |
scaleIn() | scaleOut() | 缩放进入 / 退出 |
下面是一个可折叠 FAQ 面板的示例——请注意它如何像一幅卷轴画般徐徐展开,将答案优雅地呈现给观者:
@Composable
fun ExpandableFAQ(question: String, answer: String) {
var expanded by remember { mutableStateOf(false) }
val rotationAngle by animateFloatAsState(
targetValue = if (expanded) 180f else 0f,
label = "rotation"
)
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column {
Row(
modifier = Modifier.fillMaxWidth()
.clickable { expanded = !expanded }.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(question, modifier = Modifier.weight(1f))
Icon(
Icons.Default.KeyboardArrowDown, null,
modifier = Modifier.rotate(rotationAngle)
)
}
AnimatedVisibility(
visible = expanded,
enter = fadeIn(tween(300)) + expandVertically(
tween(300), expandFrom = Alignment.Top
),
exit = fadeOut(tween(200)) + shrinkVertically(tween(200))
) {
Text(
answer,
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
回忆一下我在画布上描绘日出的过程——夜色并非骤然消失,而是缓缓被晨光取代,新旧光线在交界处互相渗透、融合。Crossfade 正是如此运作的:它接受一个目标状态值,状态变化时旧内容如夕阳般淡去、新内容如朝霞般显现:
@Composable
fun CrossfadeDemo() {
var currentPage by remember { mutableStateOf("home") }
Column {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { currentPage = "home" }) { Text("首页") }
Button(onClick = { currentPage = "profile" }) { Text("我的") }
}
Crossfade(
targetState = currentPage,
animationSpec = tween(400),
label = "pageCrossfade"
) { page ->
when (page) {
"home" -> Text("首页内容", style = MaterialTheme.typography.headlineMedium)
"profile" -> Text("我的页面", style = MaterialTheme.typography.headlineMedium)
}
}
}
}
AnimatedContent 是一位更具野心的舞台设计师。如同我在《最后的晚餐》中精心安排每个人物的位置与动态关系,AnimatedContent 允许你自定义进入和退出动画的方向,赋予场景切换以叙事感。非常适合标签页切换等需要方向感的场景:
@Composable
fun TabSwitchDemo() {
var selectedTab by remember { mutableIntStateOf(0) }
val tabs = listOf("推荐", "关注", "热榜")
Column {
TabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, title ->
Tab(selectedTab == index, { selectedTab = index }) { Text(title) }
}
}
AnimatedContent(
targetState = selectedTab,
transitionSpec = {
if (targetState > initialState) {
// 向右切换:新内容从右侧滑入
(slideInHorizontally { it } + fadeIn()) togetherWith
(slideOutHorizontally { -it } + fadeOut())
} else {
// 向左切换:新内容从左侧滑入
(slideInHorizontally { -it } + fadeIn()) togetherWith
(slideOutHorizontally { it } + fadeOut())
} using SizeTransform(clip = false)
},
label = "tabContent"
) { tab ->
Box(
Modifier.fillMaxWidth().height(300.dp),
contentAlignment = Alignment.Center
) {
Text("${tabs[tab]}页面内容", style = MaterialTheme.typography.headlineMedium)
}
}
}
}
如同选择画技——若只需柔和的色彩过渡,用 Crossfade,API 更简洁,如同晕涂法般自然。若需要讲述一个有方向的故事(如左右滑动切换标签页),则用 AnimatedContent,它就像透视法一样,赋予画面深度与方向。
在我的工作室里,画布的尺寸从来不是一成不变的——有时需要展开更大的空间来容纳更宏大的构图。animateContentSize 正是这样一个智慧的 Modifier,当组件内容尺寸发生变化时,它会如同活的画框一般自动添加动画,是实现"展开/收起"效果最简单的方式:
@Composable
fun ExpandableText(title: String, body: String) {
var expanded by remember { mutableStateOf(false) }
Card(
modifier = Modifier.fillMaxWidth().padding(8.dp).clickable { expanded = !expanded }
) {
Column(
modifier = Modifier.padding(16.dp).animateContentSize(
animationSpec = spring(stiffness = Spring.StiffnessMedium)
)
) {
Text(title, style = MaterialTheme.typography.titleMedium)
if (expanded) {
Spacer(Modifier.height(8.dp))
Text(body, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
想象一群舞者在舞台上重新排列位置——每一个人都不是瞬间移动,而是沿着优美的弧线滑向新的站位。在 LazyColumn 中,Modifier.animateItem() 正是这位编舞大师,为列表项的添加、删除和重新排序提供流畅的位移动画:
@Composable
fun AnimatedListDemo() {
var items by remember {
mutableStateOf(listOf("苹果", "香蕉", "橙子", "葡萄", "西瓜"))
}
Column {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { items = items.shuffled() }) { Text("打乱") }
Button(onClick = { items = items.sorted() }) { Text("排序") }
Button(onClick = {
if (items.isNotEmpty()) items = items.drop(1)
}) { Text("删除首项") }
}
LazyColumn {
items(items = items, key = { it }) { item ->
Card(
modifier = Modifier.fillMaxWidth()
.padding(vertical = 4.dp)
.animateItem() // key 是动画正确运作的关键
) {
Text(item, modifier = Modifier.padding(16.dp))
}
}
}
}
}
使用 Modifier.animateItem() 时,必须为列表项提供稳定且唯一的 key。如同画作中每个人物都需要独特的身份标识——没有 key,Compose 就像一个无法辨认舞者面孔的编舞,无法追踪哪些项被移动了,动画就无法正确执行。
绘画不仅仅是颜料与画布的事——画家需要调配颜料、清洗画笔、固定画框、等待干燥。这些"画面之外"的工作虽不直接出现在作品中,却是创作过程中不可或缺的环节。在 Compose 中,副作用(Side Effect)正是这类"画面之外"的操作——任何逃脱了可组合函数作用域的行为,如网络请求、写入数据库、注册监听器、显示 Toast 等。
为什么需要专门的副作用 API?因为可组合函数如同一幅随时可能被重新绘制的画——它可以随时被重组、执行顺序不保证、可能被跳过。如果你直接在可组合函数中发网络请求,就像每次重新画一笔都要重新购买颜料一样荒谬——每次重组都会重复执行。副作用 API 为你提供了安全、可控的执行时机,如同画室中严谨的工作流程。
| API | 特点 | 典型场景 |
|---|---|---|
LaunchedEffect | 启动协程,key 变化时取消并重启 | 加载数据、定时器 |
DisposableEffect | 有配套清理操作(onDispose) | 注册/注销监听器 |
SideEffect | 每次成功重组后执行,非挂起 | 日志记录、同步外部状态 |
rememberCoroutineScope | 获取绑定组合生命周期的协程作用域 | 在 onClick 中启动协程 |
derivedStateOf | 从其他状态派生新状态 | 列表过滤、格式化 |
snapshotFlow | 将 Compose 快照状态转换为 Flow | 监听状态变化并去抖 |
LaunchedEffect 如同你派出的一位信使,携带着你的指令奔赴远方。它接受一个或多个 key,在组件首次进入组合树时启动协程。当 key 变化时,旧的信使被召回、新的信使被派出;当组件离开组合树时,信使自动归队。这确保了每一个异步任务都在正确的时机启动和终止。
@Composable
fun UserProfileScreen(userId: String, viewModel: UserViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsState()
// 当 userId 改变时,重新加载数据
LaunchedEffect(userId) {
viewModel.loadUserProfile(userId)
}
when (val state = uiState) {
is UiState.Loading -> CircularProgressIndicator()
is UiState.Success -> UserContent(state.user)
is UiState.Error -> Text(state.message)
}
}
@Composable
fun CountdownTimer(totalSeconds: Int) {
var remaining by remember { mutableIntStateOf(totalSeconds) }
LaunchedEffect(totalSeconds) {
remaining = totalSeconds
while (remaining > 0) {
delay(1000L)
remaining--
}
}
Text("剩余 $remaining 秒", style = MaterialTheme.typography.headlineLarge)
}
LaunchedEffect(Unit) 意味着协程只在组件首次进入组合树时启动一次,之后不会重新启动。这就像你只在画室第一天派出信使,之后无论委托人更换了多少次需求,信使都不再出发。如果你需要响应某个值的变化(如 userId),必须将该值作为 key,否则即使 userId 改变也不会重新加载数据。
DisposableEffect 体现了一位严谨大师的工作哲学:每一次创作的开始都对应着一次有序的收尾。正如我在画室中——展开画布前必先准备画架,收工时必须清洗画笔、封存颜料。它用于需要"配对操作"的场景:注册 + 注销、绑定 + 解绑。它有一个必须实现的 onDispose 块来做清理工作,确保你的画室永远整洁有序。
@Composable
fun LifecycleObserverEffect(onStart: () -> Unit, onStop: () -> Unit) {
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> onStart()
Lifecycle.Event.ON_STOP -> onStop()
else -> { }
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}
@Composable
fun AnalyticsScreen(screenName: String) {
DisposableEffect(screenName) {
val startTime = System.currentTimeMillis()
Analytics.trackScreenView(screenName)
onDispose {
val duration = System.currentTimeMillis() - startTime
Analytics.trackScreenDuration(screenName, duration)
}
}
}
如同绘画中的两种不同工序:需要在协程中执行耗时操作(挂起函数)时——如同派信使远行——用 LaunchedEffect。需要非挂起的即时设置/清理配对操作时——如同搭起画架然后拆除画架——用 DisposableEffect。
一位画家在完成每一笔之后,可能需要退后一步审视全局,或在画作完成后签上名字。SideEffect 正是这种"完成后的附加动作"——它在每次成功重组后执行,不接受 key,非挂起函数。适合将 Compose 的内部状态同步到外部系统:
@Composable
fun SideEffectDemo(userName: String) {
SideEffect {
Analytics.setUserProperty("current_user", userName)
}
Text("欢迎, $userName")
}
LaunchedEffect 在组合阶段自动启动协程,如同画家按计划作画。但有时你需要在观者按下按钮时才执行某个动作——这种"按需启动"的场景需要 rememberCoroutineScope:
@Composable
fun CoroutineScopeDemo() {
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
Button(onClick = {
scope.launch { snackbarHostState.showSnackbar("操作已完成") }
}) {
Text("显示 Snackbar")
}
}
在音乐中,和声是从主旋律中自然派生出来的,只有当旋律真正改变时和声才需要更新。derivedStateOf 正是如此——当需要从频繁变化的状态派生新状态时,它只在派生结果实际变化时触发重组,避免了不必要的重绘:
@Composable
fun FilteredListDemo() {
var query by remember { mutableStateOf("") }
val allItems = remember { listOf("Kotlin", "Java", "Swift", "Dart", "Rust") }
val filtered by remember {
derivedStateOf {
if (query.isBlank()) allItems
else allItems.filter { it.contains(query, ignoreCase = true) }
}
}
Column {
OutlinedTextField(value = query, onValueChange = { query = it }, label = { Text("搜索") })
filtered.forEach { Text(it, modifier = Modifier.padding(8.dp)) }
}
}
有时你需要倾听画布的"脉搏"——感知用户滚动的节奏、状态变化的频率——并对这些脉动做出响应。snapshotFlow 将 Compose 快照状态转换为 Flow,让你能够使用 Flow 操作符(去抖、过滤等)来优雅地处理这些变化:
@Composable
fun ScrollTracker() {
val listState = rememberLazyListState()
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.collect { index ->
Analytics.trackScrollPosition(index)
}
}
LazyColumn(state = listState) {
items(100) { Text("第 $it 项", modifier = Modifier.padding(16.dp)) }
}
}
如同画家的工具箱,每件工具都有其专属用途:需要派信使执行耗时任务 → LaunchedEffect。需要搭架子又要拆架子 → DisposableEffect。画完后签名 → SideEffect。观者点击后才动笔 → rememberCoroutineScope。从主旋律提炼和声 → derivedStateOf。倾听画布脉搏 → snapshotFlow。
每一座文艺复兴时期的大教堂,都有其精密的建筑骨架——穹顶、中殿、侧廊、祭坛,各司其职又浑然一体。Scaffold 正是 Material Design 为你提供的应用"教堂骨架",它定义了标准的页面结构:顶部栏如穹顶般统领全局、底部栏如基座般稳固承托、浮动操作按钮如尖塔般画龙点睛、Snackbar 如壁画般适时呈现信息。
| 参数 | 说明 |
|---|---|
topBar | 顶部应用栏(TopAppBar / CenterAlignedTopAppBar) |
bottomBar | 底部导航栏(NavigationBar) |
floatingActionButton | 浮动操作按钮 |
snackbarHost | Snackbar 宿主 |
content | 主要内容区域,接收 PaddingValues |
下面我们将所有建筑元素组合在一起,构建一座完整的"应用教堂",并搭配 Snackbar 消息提示——这就像在宏伟的穹顶之下,为观者呈上一份精美的导览手册:
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen() {
var selectedTab by remember { mutableIntStateOf(0) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
Scaffold(
topBar = {
TopAppBar(
title = {
Text(when (selectedTab) {
0 -> "首页"; 1 -> "收藏"; else -> "我的"
})
},
actions = {
IconButton(onClick = {
scope.launch {
snackbarHostState.showSnackbar(
message = "这是一条消息通知",
actionLabel = "知道了",
duration = SnackbarDuration.Short
)
}
}) {
Icon(Icons.Default.Notifications, "通知")
}
}
)
},
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = selectedTab == 0,
onClick = { selectedTab = 0 },
icon = { Icon(Icons.Default.Home, null) },
label = { Text("首页") }
)
NavigationBarItem(
selected = selectedTab == 1,
onClick = { selectedTab = 1 },
icon = { Icon(Icons.Default.Favorite, null) },
label = { Text("收藏") }
)
NavigationBarItem(
selected = selectedTab == 2,
onClick = { selectedTab = 2 },
icon = { Icon(Icons.Default.Person, null) },
label = { Text("我的") }
)
}
},
floatingActionButton = {
FloatingActionButton(onClick = {
scope.launch { snackbarHostState.showSnackbar("新建内容") }
}) {
Icon(Icons.Default.Add, "添加")
}
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { innerPadding ->
// 必须使用 innerPadding,否则内容会被 topBar/bottomBar 遮挡
AnimatedContent(
targetState = selectedTab,
modifier = Modifier.padding(innerPadding),
label = "mainContent"
) { tab ->
when (tab) {
0 -> HomeContent()
1 -> FavoritesContent()
2 -> ProfileContent()
}
}
}
}
在大型工程项目中,消息的触发往往来自深层逻辑——就像教堂的钟声由钟楼内部的机械装置驱动,而非由站在门口的向导手动敲响。实际项目中 Snackbar 消息通常由 ViewModel 触发,用 LaunchedEffect 作为忠实的传令官,监听并显示:
@Composable
fun SnackbarWithViewModel(viewModel: MyViewModel = viewModel()) {
val snackbarHostState = remember { SnackbarHostState() }
val message by viewModel.snackbarMessage.collectAsState()
LaunchedEffect(message) {
message?.let { msg ->
val result = snackbarHostState.showSnackbar(
message = msg, actionLabel = "撤销"
)
if (result == SnackbarResult.ActionPerformed) {
viewModel.undoLastAction()
}
viewModel.clearSnackbarMessage()
}
}
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { padding ->
Button(
onClick = { viewModel.deleteItem() },
modifier = Modifier.padding(padding)
) { Text("删除项目") }
}
}
正如建筑师必须为穹顶和地基预留空间一样,Scaffold 的 content lambda 传入的 PaddingValues 包含了 topBar 和 bottomBar 占据的空间。必须将它应用到内容区域根组件上,否则你的画作会被画框遮挡——内容会隐藏在顶栏和底栏之下。
如同完成一幅宏大的壁画,让我们回顾这一章中所掌握的全部技法:
animate*AsState 是你手中最柔软的画笔,让色彩、尺寸、透明度如晕涂法般自然过渡AnimatedVisibility 是你的舞台帷幕,让组件优雅地登场与谢幕Crossfade 如晨昏交替般柔和,AnimatedContent 则赋予场景切换以透视与方向animateContentSize 让画布自然伸缩,Modifier.animateItem 让列表项如舞者般优美走位LaunchedEffect 是你的信使,DisposableEffect 是你的画架管理员,SideEffect 是你完成后的签名正如我常说的:"学无止境,行者无疆。"掌握了这些赋予界面生命与呼吸的技法,你已经具备了创造令人惊叹的用户体验的能力。下一章,我们将学习如何使用依赖注入来管理应用中的复杂依赖关系——就像管理一间大型画室中所有工具与材料的供给一样。
创建一个按钮,在空闲状态下持续显示"呼吸灯"效果——透明度在 0.3 到 1.0 之间无限循环渐变,如同画中人物胸口的微微起伏。点击按钮时,停止呼吸动画并显示一条 Snackbar 消息。
使用 rememberInfiniteTransition 创建无限循环动画,通过 animateFloat 配合 RepeatMode.Reverse 实现呼吸效果。将动画值应用到 Modifier.alpha() 上。Snackbar 部分使用 rememberCoroutineScope 在点击回调中启动。
@Composable
fun BreathingButton() {
val infiniteTransition = rememberInfiniteTransition(label = "breathing")
val alpha by infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = 1.0f,
animationSpec = infiniteRepeatable(
animation = tween(1200, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "alpha"
)
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { padding ->
Box(Modifier.padding(padding).fillMaxSize(), contentAlignment = Alignment.Center) {
Button(
onClick = { scope.launch { snackbarHostState.showSnackbar("按钮被点击了") } },
modifier = Modifier.alpha(alpha)
) { Text("点击我") }
}
}
}
创建一个待办事项列表,支持以下功能:(1)点击"添加"按钮在列表顶部插入新项目,新项目以 fadeIn + expandVertically 动画出现;(2)左滑删除项目,项目以 fadeOut + shrinkVertically 动画消失;(3)列表项的增删排序都带有流畅的 animateItem 动画。
使用 LazyColumn 配合 Modifier.animateItem(),为每个项目设置唯一的 key(可以用一个递增的 ID 计数器)。添加和删除通过修改列表状态实现。可以用 AnimatedVisibility 包裹每个列表项来控制进出动画。
@Composable
fun AnimatedTodoList() {
var nextId by remember { mutableIntStateOf(0) }
var todos by remember { mutableStateOf(listOf<Pair<Int, String>>()) }
Column(modifier = Modifier.padding(16.dp)) {
Button(onClick = {
todos = listOf(nextId to "待办 #$nextId") + todos
nextId++
}) { Text("添加待办") }
Spacer(Modifier.height(8.dp))
LazyColumn {
items(items = todos, key = { it.first }) { todo ->
var visible by remember { mutableStateOf(true) }
AnimatedVisibility(
visible = visible,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
modifier = Modifier.animateItem()
) {
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(
Modifier.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(todo.second, modifier = Modifier.weight(1f))
IconButton(onClick = {
visible = false
}) {
Icon(Icons.Default.Delete, "删除")
}
}
}
}
LaunchedEffect(visible) {
if (!visible) {
delay(300)
todos = todos.filter { it.first != todo.first }
}
}
}
}
}
}
实现一个文本编辑器组件,具备以下特性:(1)用户输入文字后,使用 snapshotFlow 配合 debounce 操作符,在用户停止输入 1.5 秒后自动"保存"(打印日志模拟);(2)使用 derivedStateOf 实时显示字数统计;(3)组件离开时使用 DisposableEffect 进行最终保存。
文本状态用 mutableStateOf 管理。在 LaunchedEffect 中使用 snapshotFlow { text } 将文本状态转为 Flow,然后用 .debounce(1500) 去抖后在 .collect 中执行保存。字数统计用 derivedStateOf { text.length }。DisposableEffect(Unit) 的 onDispose 中执行最终保存。
@Composable
fun AutoSaveDraftEditor() {
var text by remember { mutableStateOf("") }
val charCount by remember { derivedStateOf { text.length } }
// 自动保存:用户停止输入 1.5 秒后保存
LaunchedEffect(Unit) {
snapshotFlow { text }
.debounce(1500)
.collect { draft ->
if (draft.isNotEmpty()) {
println("自动保存草稿: $draft")
}
}
}
// 离开时最终保存
DisposableEffect(Unit) {
onDispose {
if (text.isNotEmpty()) {
println("组件销毁,最终保存: $text")
}
}
}
Column(modifier = Modifier.padding(16.dp)) {
OutlinedTextField(
value = text,
onValueChange = { text = it },
label = { Text("编辑草稿") },
modifier = Modifier.fillMaxWidth().height(200.dp)
)
Text("字数: $charCount", style = MaterialTheme.typography.bodySmall)
}
}
创建一个"心情卡片"组件,包含三种心情状态(开心、平静、难过)。每种状态对应不同的背景色、图标大小和圆角。使用 updateTransition 让所有属性在状态切换时协调动画——就像文艺复兴大师在画布上同时调和多种色彩,让整体和谐统一。要求切换动画时长为 600ms,使用 FastOutSlowInEasing 缓动。
定义一个枚举类 Mood,用 updateTransition 驱动状态转换。通过 transition.animateColor、transition.animateDp 等 API 分别为背景色、图标大小、圆角大小创建协调动画。所有动画共享同一个 transitionSpec,确保它们步调一致。
enum class Mood { Happy, Calm, Sad }
@Composable
fun MoodCard() {
var mood by remember { mutableStateOf(Mood.Calm) }
val transition = updateTransition(mood, label = "moodTransition")
val bgColor by transition.animateColor(
transitionSpec = { tween(600, easing = FastOutSlowInEasing) },
label = "bgColor"
) { state ->
when (state) {
Mood.Happy -> Color(0xFFFFF176)
Mood.Calm -> Color(0xFF81D4FA)
Mood.Sad -> Color(0xFFB0BEC5)
}
}
val iconSize by transition.animateDp(
transitionSpec = { tween(600, easing = FastOutSlowInEasing) },
label = "iconSize"
) { state ->
when (state) {
Mood.Happy -> 64.dp
Mood.Calm -> 48.dp
Mood.Sad -> 36.dp
}
}
val cornerRadius by transition.animateDp(
transitionSpec = { tween(600, easing = FastOutSlowInEasing) },
label = "corner"
) { state ->
when (state) {
Mood.Happy -> 32.dp
Mood.Calm -> 16.dp
Mood.Sad -> 4.dp
}
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { mood = Mood.Happy }) { Text("开心") }
Button(onClick = { mood = Mood.Calm }) { Text("平静") }
Button(onClick = { mood = Mood.Sad }) { Text("难过") }
}
Spacer(Modifier.height(16.dp))
Box(
modifier = Modifier.size(200.dp)
.clip(RoundedCornerShape(cornerRadius))
.background(bgColor),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Favorite, null,
modifier = Modifier.size(iconSize),
tint = Color.White
)
}
}
}