23、Compose动画之手势与动画结合:拖拽动画、缩放动画、旋转动画、组合手势
手势与动画的结合,是Compose动画体系里最让我兴奋的部分。你想想看,用户用手指在屏幕上划一下,界面就跟着动起来——这种即时反馈的体验,才是现代App该有的样子。
在View时代,我们处理手势动画通常要写一堆监听器、GestureDetector、Scroller,还要手动处理触摸事件的拦截。说实话,那套东西写起来挺痛苦的。到了Compose这里,一切都变得简洁多了。
拖拽动画:最基础的手势交互
拖拽动画,说白了就是让一个元素跟着手指移动。我在项目中遇到过很多需要拖拽的场景,比如卡片滑动删除、弹窗拖动关闭、列表项排序等等。
Compose里实现拖拽,核心是pointerInput修饰符配合detectDragGestures。来看一个最简单的例子:
@Composable
fun DraggableBox() {
var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) }
Box(
modifier = Modifier
.size(100.dp)
.offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
.background(Color.Blue)
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consume()
offsetX += dragAmount.x
offsetY += dragAmount.y
}
}
)
}
这段代码看着简单,但有几个坑我得提醒你。第一,change.consume()一定要调用,否则事件会继续往下传,造成奇怪的交互冲突。第二,用offset修饰符而不是Modifier.graphicsLayer来做位置偏移,因为offset会触发重组,而graphicsLayer只影响绘制阶段。
graphicsLayer做拖拽偏移,结果发现点击事件的位置没跟着变,用户点上去没反应。后来才意识到,graphicsLayer只改变绘制位置,不改变触摸区域。
缩放动画:双指捏合的实现
缩放动画在图片查看器、地图应用里很常见。Compose提供了detectTransformGestures来处理多指手势,它同时能检测缩放、旋转和平移。
@Composable
fun ZoomableImage() {
var scale by remember { mutableFloatStateOf(1f) }
Image(
painter = painterResource(id = R.drawable.sample),
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.graphicsLayer(
scaleX = scale,
scaleY = scale
)
.pointerInput(Unit) {
detectTransformGestures { _, _, zoom, _ ->
scale = (scale * zoom).coerceIn(0.5f, 3f)
}
}
)
}
这里有个细节:缩放用graphicsLayer比用Modifier.scale性能更好。为什么?因为graphicsLayer只触发绘制阶段,不触发重组。你想想看,如果每缩放一丁点就重组一次,那画面卡顿是必然的。
graphicsLayer里,拖拽这种需要改变布局位置的才用offset。这样性能最优。
旋转动画:让元素转起来
旋转动画通常和缩放一起出现,比如图片查看器里用户双指旋转图片。实现起来也很直接:
@Composable
fun RotatableBox() {
var rotation by remember { mutableFloatStateOf(0f) }
Box(
modifier = Modifier
.size(150.dp)
.graphicsLayer(rotationZ = rotation)
.background(Color.Red)
.pointerInput(Unit) {
detectTransformGestures { _, _, _, rotationChange ->
rotation += rotationChange
}
}
)
}
注意detectTransformGestures回调里的参数顺序:centroid(中心点)、pan(平移)、zoom(缩放)、rotation(旋转)。我第一次用的时候把参数顺序搞反了,结果旋转变成了缩放,画面乱跳。
组合手势:一个手势搞定所有
实际项目中,很少单独用拖拽或缩放。用户期望的是:可以拖拽、可以缩放、可以旋转,而且这些操作要无缝切换。Compose的detectTransformGestures天然支持组合手势:
@Composable
fun TransformableCard() {
var scale by remember { mutableFloatStateOf(1f) }
var rotation by remember { mutableFloatStateOf(0f) }
var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) }
Box(
modifier = Modifier
.size(200.dp)
.offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
.graphicsLayer(
scaleX = scale,
scaleY = scale,
rotationZ = rotation
)
.background(Color.Green)
.pointerInput(Unit) {
detectTransformGestures { centroid, pan, zoom, rotationChange ->
// 处理缩放
scale = (scale * zoom).coerceIn(0.3f, 3f)
// 处理旋转
rotation += rotationChange
// 处理拖拽
offsetX += pan.x
offsetY += pan.y
}
}
)
}
这段代码里,单指拖拽、双指缩放、双指旋转全部在一个回调里处理。Compose会自动判断当前是哪种手势,你只需要更新对应的状态就行。
手势动画的进阶技巧
光有手势还不够,我们通常需要配合动画让交互更流畅。比如拖拽结束后,让元素平滑回到原位:
@Composable
fun AnimatedDraggableBox() {
var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) }
val animatableOffsetX = remember { Animatable(0f) }
val animatableOffsetY = remember { Animatable(0f) }
Box(
modifier = Modifier
.size(100.dp)
.offset {
IntOffset(
animatableOffsetX.value.roundToInt(),
animatableOffsetY.value.roundToInt()
)
}
.background(Color.Magenta)
.pointerInput(Unit) {
detectDragGestures(
onDragEnd = {
// 松手后平滑回到原位
scope.launch {
animatableOffsetX.animateTo(0f)
animatableOffsetY.animateTo(0f)
}
}
) { change, dragAmount ->
change.consume()
offsetX += dragAmount.x
offsetY += dragAmount.y
// 同步更新Animatable
animatableOffsetX.snapTo(offsetX)
animatableOffsetY.snapTo(offsetY)
}
}
)
}
这里用了Animatable来做动画驱动。拖拽过程中用snapTo直接跳转到目标值,松手后用animateTo平滑回到原点。这种「手势驱动+动画收尾」的模式,在项目中非常实用。
知识体系总览
下面这张图梳理了本章的核心内容,方便你快速回顾:
性能优化建议
手势动画对性能要求很高,尤其是组合手势。我总结了几条经验:
- 用
graphicsLayer做变换:缩放、旋转、透明度这些,放在graphicsLayer里只触发绘制,不触发重组 - 避免在手势回调里做耗时操作:手势回调是高频触发的,任何耗时操作都会导致掉帧
- 合理使用
remember:手势相关的状态尽量用mutableFloatStateOf或mutableIntStateOf,避免装箱开销 - 考虑使用
animate*AsState:对于简单的动画收尾,用animateFloatAsState比手动启动协程更简洁
GestureController,统一处理拖拽、缩放、旋转的状态更新。这样Composable函数里只保留UI描述,逻辑更清晰。
嗯,手势与动画的结合就讲到这里。核心就是记住:pointerInput是入口,detectDragGestures和detectTransformGestures是主力,graphicsLayer和offset是输出。把这几个点串起来,大部分手势动画场景都能搞定。