Compose 动画与手势
Jetpack Compose 声明式动画与手势体系:animate*AsState、Animatable、Crossfade、AnimatedContent、updateTransition、Modifier.animateContentSize、rememberInfiniteTransition,以及 pointerInput/detectDragGestures/draggable/swipeable 手势与嵌套滚动。
学习目标
- 掌握 animate*AsState 与 Animatable 的差异与使用场景
- 熟练使用 Crossfade、AnimatedContent、updateTransition 处理状态切换动画
- 用 Modifier.animateContentSize 与 rememberInfiniteTransition 实现尺寸/循环动画
- 掌握 enter/exit transition 的组合方式
- 用 pointerInput、detectDragGestures、draggable、swipeable 处理手势并理解嵌套滚动
学习目标
- 掌握
animate*AsState与Animatable的差异与使用场景; - 熟练使用
Crossfade、AnimatedContent、updateTransition处理状态切换动画; - 用
Modifier.animateContentSize与rememberInfiniteTransition实现尺寸/循环动画; - 掌握 enter/exit transition 的组合方式;
- 用
pointerInput、detectDragGestures、draggable、swipeable处理手势并理解嵌套滚动。
Compose 动画与手势
与第 31 章命令式动画(ObjectAnimator/AnimatorSet)不同,Compose 的动画是 声明式的:你描述“目标状态”或“目标值”,框架自动插值出中间帧。手势也是“声明 + 回调”,省去了传统 View.OnTouchListener 的样板代码。
依赖配置
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.09.03")
implementation(composeBom)
implementation("androidx.compose.animation:animation")
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material3:material3")
}
animate*AsState:单值变化的最简动画
animate*AsState 是最常用的“目标值驱动”动画 API。传入目标值,返回当前动画值:
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateDpAsState
@Composable
fun ColorButton() {
var selected by remember { mutableStateOf(false) }
val bgColor by animateColorAsState(
targetValue = if (selected) Color.Blue else Color.Gray,
label = "bg"
)
val size by animateDpAsState(
targetValue = if (selected) 56.dp else 48.dp,
label = "size"
)
Button(
onClick = { selected = !selected },
modifier = Modifier.size(size),
colors = ButtonDefaults.buttonColors(containerColor = bgColor)
) { Text(if (selected) "已选" else "未选") }
}
支持的变体:animateFloatAsState / animateDpAsState / animateIntAsState / animateColorAsState / animateOffsetAsState / animateRectAsState / animateValueAsState(自定义 TwoWayConverter)。
animationSpec 控制时长与曲线:
val size by animateDpAsState(
targetValue = if (expanded) 200.dp else 80.dp,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
label = "size"
)
// 或
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
animationSpec = keyframes { durationMillis = 400; at(150) with Values … }
animate*AsState的“目标”在每次重组时被读取,状态变化时它会自动从当前值平滑过渡到新目标。
Animatable:精细控制
Animatable 是 animate*AsState 的“手控版”:你可以 animateTo()、snapTo()、stop(),常用于手势驱动的连续动画(拖动球、滑动条):
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import kotlinx.coroutines.launch
@Composable
fun PulseDot() {
val scale = remember { Animatable(1f) }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
while (isActive) {
scale.animateTo(2f, tween(800))
scale.animateTo(1f, tween(800))
}
}
Box(
Modifier
.size(40.dp)
.background(Color.Red, CircleShape)
.scale(scale.value)
)
}
要点:
Animatable是有状态的,必须放在remember里;animateTo是 suspend 函数,必须在协程中调用(LaunchedEffect/rememberCoroutineScope);- 与
animate*AsState的差异:前者 可被代码主动驱动(如手势过程实时改目标),后者只跟随重组。
Crossfade:内容交叉淡入淡出
Crossfade 会在不同状态值之间做“旧内容淡出、新内容淡入”:
import androidx.compose.animation.Crossfade
@Composable
fun TabContent(tab: Int) {
Crossfade(targetState = tab, label = "tab") { current ->
when (current) {
0 -> HomeTab()
1 -> OrderTab()
2 -> ProfileTab()
}
}
}
适合 Tab、单选/多选的图标等简单切换。如果需要更复杂的“前一个向左滑出、新的从右滑入”,用 AnimatedContent。
AnimatedContent:多形态切换
AnimatedContent 提供 transitionSpec 自定义进入/退出/原位置变化:
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
@Composable
fun CounterTransition(count: Int) {
AnimatedContent(
targetState = count,
transitionSpec = {
if (targetState > initialState) {
// 递增:新内容从右滑入,旧内容向左滑出
(slideInHorizontally { it } + fadeIn()) togetherWith
(slideOutHorizontally { -it } + fadeOut())
} else {
(slideInHorizontally { -it } + fadeIn()) togetherWith
(slideOutHorizontally { it } + fadeOut())
}
},
label = "counter"
) { target ->
Text("$target", fontSize = 48.sp)
}
}
togetherWith 把 enter/exit 配成一对;+ 让多个 transition 同时生效。
updateTransition:多状态多属性
当一次状态切换要驱动 多个属性 同时变化时,updateTransition 比 AnimatedContent 更适合:
import androidx.compose.animation.core.updateTransition
enum class ChipState { Normal, Selected, Pressed }
@Composable
fun Chip(state: ChipState) {
val transition = updateTransition(targetState = state, label = "chip")
val bg by transition.animateColor(label = "bg") { st ->
when (st) {
ChipState.Normal -> Color.LightGray
ChipState.Selected -> Color.Blue
ChipState.Pressed -> Color.DarkGray
}
}
val padding by transition.animateDp(label = "pad") { st ->
when (st) {
ChipState.Normal -> 8.dp
ChipState.Selected -> 16.dp
ChipState.Pressed -> 12.dp
}
}
Box(Modifier.background(bg).padding(padding)) { Text("chip") }
}
updateTransition 把状态切换“统一驱动多个属性”,避免多个 animate*AsState 各跑各的曲线不一致。
Modifier.animateContentSize
Modifier.animateContentSize() 让一个组件的尺寸变化自动过渡,常用于“展开/折叠”:
@Composable
fun ExpandableCard() {
var expanded by remember { mutableStateOf(false) }
Column(
Modifier
.padding(16.dp)
.animateContentSize(
animationSpec = spring(dampingRatio = Spring.DampingRatioLowBouncy)
)
.background(Color.White, RoundedCornerShape(8.dp))
) {
Text("标题", Modifier.clickable { expanded = !expanded })
if (expanded) {
Text("这里是很长的正文内容……")
}
}
}
animateContentSize与if (expanded) Text(...)的“显隐切换”完美匹配,无需手写高度动画。
rememberInfiniteTransition:循环动画
rememberInfiniteTransition 创建一个永不停止的动画,适合呼吸灯、加载圈、波浪:
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
@Composable
fun LoadingDot() {
val transition = rememberInfiniteTransition(label = "loading")
val scale by transition.animateFloat(
initialValue = 0.8f,
targetValue = 1.4f,
animationSpec = infiniteRepeatable(
animation = tween(800, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "scale"
)
Box(Modifier.size(20.dp).scale(scale).background(Color.Blue, CircleShape))
}
不要把无限动画跑在
LaunchedEffect里手动while(true),rememberInfiniteTransition已经做了帧调度优化。
enter / exit transition
可组合函数的“进入/退出组合”用 AnimatedVisibility:
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@Composable
fun ErrorBanner(show: Boolean) {
AnimatedVisibility(
visible = show,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Text("出错了!", color = Color.Red)
}
}
可用的 enter:fadeIn/slideIn/expandIn/scaleIn;exit:fadeOut/slideOut/shrinkOut/scaleOut。用 + 叠加。
Compose Navigation 的目的地也支持
enterTransition/exitTransition,与AnimatedVisibility的 spec 同源。
手势基础:pointerInput
Modifier.pointerInput 是手势处理的低层入口,可识别任意指针事件。常用 detect*Gestures 扩展:
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectDragGestures
@Composable
fun TapBox() {
Box(
Modifier
.size(200.dp)
.background(Color.Yellow)
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = { /* 双击 */ },
onLongPress = { /* 长按 */ },
onTap = { /* 单击 */ }
)
}
)
}
@Composable
fun DraggableBall() {
var offset by remember { mutableStateOf(IntOffset.Zero) }
Box(
Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectDragGestures { change, drag ->
// drag: 拖动增量(px)
offset = IntOffset(
offset.x + drag.x.roundToInt(),
offset.y + drag.y.roundToInt()
)
}
}
) {
Box(
Modifier
.offset { offset }
.size(60.dp)
.background(Color.Red, CircleShape)
)
}
}
pointerInput(key)的 key 决定“何时重新设置手势识别器”。传Unit表示一次性设置;传某个状态值表示该值变化时重新识别。
draggable / swipeable:高层手势 Modifier
不需要自己写 detectDragGestures 时,用高层 Modifier:
draggable:单向拖动
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
@Composable
fun SliderKnob() {
var pos by remember { mutableStateOf(0f) }
val state = rememberDraggableState { delta -> pos += delta }
Box(
Modifier
.draggable(
orientation = Orientation.Horizontal,
state = state,
onDragStopped = { velocity -> /* 收尾 */ }
)
.size(40.dp)
.background(Color.Blue, CircleShape)
)
}
swipeable:贴边切换
swipeable 把拖动结果吸附到一组锚点(典型:底部抽屉、Tab 左右滑):
import androidx.compose.foundation.gestures.swipeable
import androidx.compose.foundation.gestures.rememberSwipeableState
@Composable
fun SwipeTab() {
val state = rememberSwipeableState(0)
val anchors = mapOf(
0f to 0, // px 0 -> 状态 0
-400f to 1 // px -400 -> 状态 1
)
Box(
Modifier
.swipeable(
state = state,
anchors = anchors,
thresholds = { _, _ -> FractionalThreshold(0.3f) },
orientation = Orientation.Horizontal
)
) {
Text("当前 tab: ${state.currentValue}")
}
}
swipeable已被标注为 unstable API,未来可能被anchoredDraggable替代,但目前仍可用。
嵌套滚动
Modifier.nestedScroll 让父子组件协作滚动,典型场景:上方折叠头 + 下方 LazyColumn。Compose 提供了现成的 enterAlwaysScrollBehavior / exitUntilCollapsedScrollBehavior(Material 3 顶栏):
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.ExperimentalMaterial3Api
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CollapseScreen() {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
title = { Text("详情") },
scrollBehavior = scrollBehavior
)
}
) { padding ->
LazyColumn(contentPadding = padding) {
items(50) { Text("条目 #$it", Modifier.padding(16.dp)) }
}
}
}
自定义 nestedScrollConnection
val connection = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// 先消费滚动量,剩余给子
return Offset.Zero
}
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
// 子消耗完后的剩余
return Offset.Zero
}
}
Box(Modifier.nestedScroll(connection)) { /* ... */ }
嵌套滚动是 Compose 处理复杂手势的核心机制。Material 3 顶栏已经实现好了,自己写复杂的折叠头时才需要手搓。
实战:可拖拽并回弹的卡片
把 Animatable + detectDragGestures + spring 串起来,做一个“拖动松手回弹”的卡片:
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
@Composable
fun BouncyCard() {
val offsetX = remember { Animatable(0f) }
val offsetY = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
Box(
Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectDragGestures(
onDragEnd = {
// 松手后用 spring 平滑回到原点
scope.launch {
launch { offsetX.animateTo(0f, spring(dampingRatio = 0.4f)) }
launch { offsetY.animateTo(0f, spring(dampingRatio = 0.4f)) }
}
}
) { _, drag ->
scope.launch {
offsetX.snapTo(offsetX.value + drag.x)
offsetY.snapTo(offsetY.value + drag.y)
}
}
}
) {
Card(
Modifier
.offset { IntOffset(offsetX.value.roundToInt(), offsetY.value.roundToInt()) }
.padding(20.dp)
) {
Text("拖我", Modifier.padding(20.dp))
}
}
}
要点:拖动时用 snapTo 即时跟随手指;松手后用 animateTo + spring 回弹,多个方向并行用 launch 启动。
常见坑与最佳实践
- 在
LaunchedEffect里while(true)模拟无限动画:会阻塞协程并增加调度负担,用rememberInfiniteTransition。 Animatable不放remember:每次重组都新建实例,动画状态丢失。animate*AsState的animationSpec写成 lambda:每次重组都 new 一个 spec 影响性能。尽量用常量 spec 或顶层函数。AnimatedContent的transitionSpec与目标不匹配:例如刷新前后是同一个值,但 spec 期望连续递增,会出现动画错位。在 spec 内根据targetState/initialState判断方向。- 手势
Modifier.pointerInput(Unit)内部读取外部状态:手势识别器只创建一次,外部的var offset变化它感知不到。把要用的状态作为 key 或通过回调改动。 detectDragGestures里直接修改MutableState:单线程下没问题,但要避免在拖动回调里做 IO/重计算。- 嵌套滚动忘了
nestedScrollModifier:导致顶栏不跟随LazyColumn折叠。Scaffold自己不会自动接上。 swipeable阈值用FixedThreshold太死板:通常用FractionalThreshold(0.5f)更符合手感。AnimatedVisibility内部状态丢失:AnimatedVisibility的子组件进入退出时其remember状态会随销毁丢失;要保留就用rememberSaveable。- 大量动画共用同一个 spec 实例造成重组过频:把 spec 提到顶层常量,多组件共享同一引用更高效。
章节小结
本章梳理了 Compose 的声明式动画与手势体系:animate*AsState 是单值目标动画的最简形态;Animatable 提供手控版本;Crossfade、AnimatedContent、updateTransition 处理状态切换;Modifier.animateContentSize 处理显隐;rememberInfiniteTransition 处理循环;AnimatedVisibility 把 enter/exit 封装好;手势层 pointerInput/detect*Gestures 是底层,draggable/swipeable 是高层;嵌套滚动 nestedScroll 让父子组件协作。
下一章预告
下一章进入主题与 Material 3:MaterialTheme 的颜色/字体/形状三大支柱、lightColorScheme/darkColorScheme、Android 12+ 动态颜色、自定义字体 Typography、Shapes 自定义形状、组件级主题覆盖与整套自定义主题方案。