A
第 51 章KOTLIN45 分钟

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 启动。

常见坑与最佳实践

  1. 在 LaunchedEffect 里 while(true) 模拟无限动画:会阻塞协程并增加调度负担,用 rememberInfiniteTransition。
  2. Animatable 不放 remember:每次重组都新建实例,动画状态丢失。
  3. animate*AsState 的 animationSpec 写成 lambda:每次重组都 new 一个 spec 影响性能。尽量用常量 spec 或顶层函数。
  4. AnimatedContent 的 transitionSpec 与目标不匹配:例如刷新前后是同一个值,但 spec 期望连续递增,会出现动画错位。在 spec 内根据 targetState/initialState 判断方向。
  5. 手势 Modifier.pointerInput(Unit) 内部读取外部状态:手势识别器只创建一次,外部的 var offset 变化它感知不到。把要用的状态作为 key 或通过回调改动。
  6. detectDragGestures 里直接修改 MutableState:单线程下没问题,但要避免在拖动回调里做 IO/重计算。
  7. 嵌套滚动忘了 nestedScroll Modifier:导致顶栏不跟随 LazyColumn 折叠。Scaffold 自己不会自动接上。
  8. swipeable 阈值用 FixedThreshold 太死板:通常用 FractionalThreshold(0.5f) 更符合手感。
  9. AnimatedVisibility 内部状态丢失:AnimatedVisibility 的子组件进入退出时其 remember 状态会随销毁丢失;要保留就用 rememberSaveable。
  10. 大量动画共用同一个 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 自定义形状、组件级主题覆盖与整套自定义主题方案。