第13章:Compose 动画与副作用

让界面"活"起来 —— 掌握动画系统与副作用管理,打造流畅且可靠的用户体验

🎨

本章导师:达芬奇

核心方法论:赋予画面生命与呼吸

「一幅伟大的画作,关键不在于静止的完美,而在于它能让观者感受到运动与生命。Compose 的动画系统正是如此——你赋予界面呼吸的节奏,用户便能感受到应用的生命力。让我们像文艺复兴的大师一样,用工程的精确与艺术的感性,为每一个像素注入灵魂。」

13.1 Compose 动画概览

在我的画室里,每一幅杰作都始于对运动的深刻理解。鸟的飞翔、水的流淌、光影的变幻——自然界中没有真正静止的事物。同样,现代移动应用中,动画是不可或缺的灵魂。恰到好处的动画就像画作中精心设计的视线引导——它牵引用户的注意力,为操作提供反馈,让界面过渡如丝般自然。Compose 的声明式特性让动画创作变得前所未有的简单,就像我发明的晕涂法(sfumato)一样,你只需要告诉画布目标状态,Compose 就会自动帮你完成那优雅的过渡。

Compose 为你准备了一套层次分明的动画工具,如同画家工作台上从粗到细的各种画笔:

API 层级代表 API适用场景
高级 APIAnimatedVisibility, AnimatedContent, Crossfade组件显示/隐藏、内容切换
值动画 APIanimate*AsState, animateContentSize单个属性值的动画过渡
过渡 APIupdateTransition, rememberInfiniteTransition多属性协调动画、无限循环
低级 APIAnimatable, AnimationState精确控制时机、手势驱动
选择合适的画笔

正如绘画中应先用宽笔铺大色块、再用细笔勾勒细节,动画 API 的选择也遵循同样的原则:优先使用高级 API。只有当高级 API 无法满足需求时,才降级到低级 API。大多数业务场景下,animate*AsStateAnimatedVisibility 就已经足够了——好的大师知道何时克制,何时精雕。

13.2 值动画:animate*AsState

如果说文艺复兴绘画的精髓在于"渐变"——从光到影的柔和过渡、从暖色到冷色的微妙转换——那么 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 参数自定义:

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

13.3 可见性动画:AnimatedVisibility

在文艺复兴时期的戏剧中,角色的登场与退场总是经过精心编排的——光线渐亮、帷幕徐开,绝不是突然闪现。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
                )
            }
        }
    }
}

13.4 内容切换:Crossfade 与 AnimatedContent

Crossfade:如同黎明与黄昏的交替

回忆一下我在画布上描绘日出的过程——夜色并非骤然消失,而是缓缓被晨光取代,新旧光线在交界处互相渗透、融合。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 是一位更具野心的舞台设计师。如同我在《最后的晚餐》中精心安排每个人物的位置与动态关系,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 vs AnimatedContent

如同选择画技——若只需柔和的色彩过渡,用 Crossfade,API 更简洁,如同晕涂法般自然。若需要讲述一个有方向的故事(如左右滑动切换标签页),则用 AnimatedContent,它就像透视法一样,赋予画面深度与方向。

13.5 布局动画

animateContentSize:画布的自然伸缩

在我的工作室里,画布的尺寸从来不是一成不变的——有时需要展开更大的空间来容纳更宏大的构图。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)
            }
        }
    }
}

Modifier.animateItem:列表中的芭蕾舞

想象一群舞者在舞台上重新排列位置——每一个人都不是瞬间移动,而是沿着优美的弧线滑向新的站位。在 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 就像一个无法辨认舞者面孔的编舞,无法追踪哪些项被移动了,动画就无法正确执行。

13.6 副作用 API 概览

绘画不仅仅是颜料与画布的事——画家需要调配颜料、清洗画笔、固定画框、等待干燥。这些"画面之外"的工作虽不直接出现在作品中,却是创作过程中不可或缺的环节。在 Compose 中,副作用(Side Effect)正是这类"画面之外"的操作——任何逃脱了可组合函数作用域的行为,如网络请求、写入数据库、注册监听器、显示 Toast 等。

为什么需要专门的副作用 API?因为可组合函数如同一幅随时可能被重新绘制的画——它可以随时被重组、执行顺序不保证、可能被跳过。如果你直接在可组合函数中发网络请求,就像每次重新画一笔都要重新购买颜料一样荒谬——每次重组都会重复执行。副作用 API 为你提供了安全、可控的执行时机,如同画室中严谨的工作流程。

API特点典型场景
LaunchedEffect启动协程,key 变化时取消并重启加载数据、定时器
DisposableEffect有配套清理操作(onDispose)注册/注销监听器
SideEffect每次成功重组后执行,非挂起日志记录、同步外部状态
rememberCoroutineScope获取绑定组合生命周期的协程作用域在 onClick 中启动协程
derivedStateOf从其他状态派生新状态列表过滤、格式化
snapshotFlow将 Compose 快照状态转换为 Flow监听状态变化并去抖

13.7 LaunchedEffect

LaunchedEffect 如同你派出的一位信使,携带着你的指令奔赴远方。它接受一个或多个 key,在组件首次进入组合树时启动协程。当 key 变化时,旧的信使被召回、新的信使被派出;当组件离开组合树时,信使自动归队。这确保了每一个异步任务都在正确的时机启动和终止。

示例 1:加载数据

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

示例 2:倒计时计时器

@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)
}
常见陷阱:将 Unit 作为 key

LaunchedEffect(Unit) 意味着协程只在组件首次进入组合树时启动一次,之后不会重新启动。这就像你只在画室第一天派出信使,之后无论委托人更换了多少次需求,信使都不再出发。如果你需要响应某个值的变化(如 userId),必须将该值作为 key,否则即使 userId 改变也不会重新加载数据。

13.8 DisposableEffect

DisposableEffect 体现了一位严谨大师的工作哲学:每一次创作的开始都对应着一次有序的收尾。正如我在画室中——展开画布前必先准备画架,收工时必须清洗画笔、封存颜料。它用于需要"配对操作"的场景:注册 + 注销、绑定 + 解绑。它有一个必须实现的 onDispose 块来做清理工作,确保你的画室永远整洁有序。

示例 1:生命周期观察者

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

示例 2:页面统计

@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 vs DisposableEffect

如同绘画中的两种不同工序:需要在协程中执行耗时操作(挂起函数)时——如同派信使远行——用 LaunchedEffect。需要非挂起的即时设置/清理配对操作时——如同搭起画架然后拆除画架——用 DisposableEffect

13.9 其他副作用 API

SideEffect:画作完成后的签名

一位画家在完成每一笔之后,可能需要退后一步审视全局,或在画作完成后签上名字。SideEffect 正是这种"完成后的附加动作"——它在每次成功重组后执行,不接受 key,非挂起函数。适合将 Compose 的内部状态同步到外部系统:

@Composable
fun SideEffectDemo(userName: String) {
    SideEffect {
        Analytics.setUserProperty("current_user", userName)
    }
    Text("欢迎, $userName")
}

rememberCoroutineScope:应观者之请而挥笔

LaunchedEffect 在组合阶段自动启动协程,如同画家按计划作画。但有时你需要在观者按下按钮时才执行某个动作——这种"按需启动"的场景需要 rememberCoroutineScope

@Composable
fun CoroutineScopeDemo() {
    val scope = rememberCoroutineScope()
    val snackbarHostState = remember { SnackbarHostState() }

    Button(onClick = {
        scope.launch { snackbarHostState.showSnackbar("操作已完成") }
    }) {
        Text("显示 Snackbar")
    }
}

derivedStateOf:从主旋律中提炼和声

在音乐中,和声是从主旋律中自然派生出来的,只有当旋律真正改变时和声才需要更新。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:将画布的脉搏转化为河流

有时你需要倾听画布的"脉搏"——感知用户滚动的节奏、状态变化的频率——并对这些脉动做出响应。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

13.10 Scaffold 页面骨架

每一座文艺复兴时期的大教堂,都有其精密的建筑骨架——穹顶、中殿、侧廊、祭坛,各司其职又浑然一体。Scaffold 正是 Material Design 为你提供的应用"教堂骨架",它定义了标准的页面结构:顶部栏如穹顶般统领全局、底部栏如基座般稳固承托、浮动操作按钮如尖塔般画龙点睛、Snackbar 如壁画般适时呈现信息。

参数说明
topBar顶部应用栏(TopAppBar / CenterAlignedTopAppBar)
bottomBar底部导航栏(NavigationBar)
floatingActionButton浮动操作按钮
snackbarHostSnackbar 宿主
content主要内容区域,接收 PaddingValues

完整的 Scaffold 示例

下面我们将所有建筑元素组合在一起,构建一座完整的"应用教堂",并搭配 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 配合

在大型工程项目中,消息的触发往往来自深层逻辑——就像教堂的钟声由钟楼内部的机械装置驱动,而非由站在门口的向导手动敲响。实际项目中 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("删除项目") }
    }
}
别忘了 innerPadding

正如建筑师必须为穹顶和地基预留空间一样,Scaffold 的 content lambda 传入的 PaddingValues 包含了 topBar 和 bottomBar 占据的空间。必须将它应用到内容区域根组件上,否则你的画作会被画框遮挡——内容会隐藏在顶栏和底栏之下。

本章小结

如同完成一幅宏大的壁画,让我们回顾这一章中所掌握的全部技法:

正如我常说的:"学无止境,行者无疆。"掌握了这些赋予界面生命与呼吸的技法,你已经具备了创造令人惊叹的用户体验的能力。下一章,我们将学习如何使用依赖注入来管理应用中的复杂依赖关系——就像管理一间大型画室中所有工具与材料的供给一样。

本章练习

练习 1:呼吸灯按钮 入门

创建一个按钮,在空闲状态下持续显示"呼吸灯"效果——透明度在 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("点击我") }
        }
    }
}

练习 2:带动画的待办事项列表 进阶

创建一个待办事项列表,支持以下功能:(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 }
                    }
                }
            }
        }
    }
}

练习 3:副作用综合实战——自动保存草稿 进阶

实现一个文本编辑器组件,具备以下特性:(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)
    }
}

练习 4:状态驱动的多属性协调动画 挑战

创建一个"心情卡片"组件,包含三种心情状态(开心、平静、难过)。每种状态对应不同的背景色、图标大小和圆角。使用 updateTransition 让所有属性在状态切换时协调动画——就像文艺复兴大师在画布上同时调和多种色彩,让整体和谐统一。要求切换动画时长为 600ms,使用 FastOutSlowInEasing 缓动。

提示

定义一个枚举类 Mood,用 updateTransition 驱动状态转换。通过 transition.animateColortransition.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
            )
        }
    }
}
« 上一章:Flow 响应式编程 目录 下一章:依赖注入实战 »