23、Compose动画与手势:animateGesture、拖拽动画、滑动动画、fling效果实现
手势与动画,在Compose里是一对天生的搭档。你想想看,用户用手指拖拽一个卡片,松手后卡片飞出去——这中间既有手势识别,又有动画驱动。今天我们就来聊聊怎么把这两件事玩明白。
我个人习惯把这类交互拆成两层:手势层负责“用户想干什么”,动画层负责“界面该怎么动”。Compose的animateGesture系列API,就是帮我们把这两层粘在一起的胶水。
从拖拽开始:最简单的交互
先看一个最基础的拖拽效果。让一个方块跟着手指移动,这几乎是所有手势动画的起点。
@Composable
fun DragSample() {
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
}
}
)
}
这段代码很简单,但有个问题:松手后方块就停住了。没有惯性,没有回弹,用户体验很生硬。我在项目中第一次写这种拖拽时,产品经理看了一眼就说:“这不像原生啊。”嗯,他说得对。
加入动画:让拖拽有“手感”
真正的拖拽应该带点“弹性”。比如拖到边界时有个回弹,或者松手后继续滑一段再停下。这时候就需要animateGesture登场了。
Compose没有直接叫animateGesture的函数,但它的Animatable配合手势检测,能实现同样的效果。我习惯这么写:
@Composable
fun AnimatedDragSample() {
val offsetX = remember { Animatable(0f) }
val offsetY = remember { Animatable(0f) }
Box(
modifier = Modifier
.size(100.dp)
.offset { IntOffset(offsetX.value.roundToInt(), offsetY.value.roundToInt()) }
.background(Color.Blue)
.pointerInput(Unit) {
detectDragGestures(
onDragEnd = {
// 松手后回弹到原点
scope.launch {
offsetX.animateTo(0f, spring(Spring.DampingRatioMediumBouncy))
offsetY.animateTo(0f, spring(Spring.DampingRatioMediumBouncy))
}
},
onDrag = { change, dragAmount ->
change.consume()
scope.launch {
offsetX.snapTo(offsetX.value + dragAmount.x)
offsetY.snapTo(offsetY.value + dragAmount.y)
}
}
)
}
)
}
注意这里用了snapTo而不是animateTo。为什么?因为拖拽过程中,手指移动是连续的,如果用animateTo会产生滞后感。说白了,拖拽时用快照,松手后用弹性动画——这是我在多个项目里试出来的最佳实践。
滑动动画与fling:让卡片飞出去
拖拽只是第一步。更常见的场景是:用户快速滑动一个列表项,然后松手,它应该继续滑行一段距离再停下。这就是fling效果。
Compose的detectVerticalDragGestures和detectHorizontalDragGestures都支持fling回调。来看一个水平滑动的例子:
@Composable
fun FlingSample() {
val offsetX = remember { Animatable(0f) }
Box(
modifier = Modifier
.fillMaxWidth()
.height(80.dp)
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
.background(Color.Gray)
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragEnd = { /* fling由另一个回调处理 */ },
onHorizontalDrag = { change, dragAmount ->
change.consume()
scope.launch {
offsetX.snapTo(offsetX.value + dragAmount)
}
}
)
}
)
}
但上面这个写法没有fling。要真正实现fling,得用detectDragGestures的完整版本,或者自己计算速度。我个人更推荐用animateDecay配合VelocityTracker:
@Composable
fun FlingWithDecaySample() {
val offsetX = remember { Animatable(0f) }
val decay = rememberSplineBasedDecay<Float>()
Box(
modifier = Modifier
.fillMaxWidth()
.height(80.dp)
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
.background(Color.Gray)
.pointerInput(Unit) {
val velocityTracker = VelocityTracker()
detectDragGestures(
onDragStart = { velocityTracker.resetTracking() },
onDrag = { change, dragAmount ->
change.consume()
velocityTracker.addPosition(change.uptimeMillis, change.position)
scope.launch {
offsetX.snapTo(offsetX.value + dragAmount.x)
}
},
onDragEnd = {
val velocity = velocityTracker.calculateVelocity()
scope.launch {
offsetX.animateDecay(velocity.x, decay)
}
}
)
}
)
}
这里的关键是animateDecay。它会根据松手时的速度,自动计算一个减速动画。默认的splineBasedDecay效果很自然,跟系统列表的fling手感几乎一样。我曾经对比过,Compose的fling曲线和Android原生RecyclerView的fling曲线,重合度非常高。
核心要点:fling的本质是“速度驱动的衰减动画”。你只需要提供初始速度,剩下的交给animateDecay。
知识体系图
下面这张图总结了本章的核心脉络,从手势输入到动画输出,每一步都有对应的API:
避坑指南:我踩过的几个坑
手势动画看起来简单,但实际项目中容易出问题。我分享几个亲身经历:
坑一:忘记consume事件
我曾经在onDrag回调里忘了写change.consume(),结果手势事件被多个组件同时响应,卡片拖到一半突然跳走了。排查了半天才发现是事件没消费。
坑二:在拖拽中用animateTo
有次我想让拖拽更“平滑”,在onDrag里用了animateTo。结果手指移动快了,动画追不上,感觉像在拖一个橡皮筋。后来改成snapTo,问题立刻解决。
小技巧:调试手势速度
如果你觉得fling太快或太慢,可以调整animateDecay的摩擦系数。默认的splineBasedDecay已经很好,但如果你想要更慢的衰减,可以自己实现一个DecayAnimationSpec。
实战:一个可拖拽的卡片列表
最后,我把这些知识点串起来,写一个可拖拽删除的卡片。用户向左滑动卡片,超过阈值就删除,否则回弹:
@Composable
fun SwipeToDismissCard(
onDismiss: () -> Unit
) {
val offsetX = remember { Animatable(0f) }
val threshold = -300f
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
.pointerInput(Unit) {
val velocityTracker = VelocityTracker()
detectHorizontalDragGestures(
onDragStart = { velocityTracker.resetTracking() },
onHorizontalDrag = { change, dragAmount ->
change.consume()
velocityTracker.addPosition(change.uptimeMillis, change.position)
scope.launch {
offsetX.snapTo(
(offsetX.value + dragAmount).coerceIn(-500f, 0f)
)
}
},
onDragEnd = {
val velocity = velocityTracker.calculateVelocity()
if (offsetX.value < threshold) {
// 超过阈值,滑出
scope.launch {
offsetX.animateDecay(velocity.x, splineBasedDecay<Float>())
onDismiss()
}
} else {
// 回弹
scope.launch {
offsetX.animateTo(0f, spring(Spring.DampingRatioMediumBouncy))
}
}
}
)
}
) {
Text("滑动删除我", modifier = Modifier.padding(16.dp))
}
}
这段代码里,我用了coerceIn限制最大滑动距离,防止卡片滑到屏幕外太远。松手后根据是否超过阈值,决定是fling出去还是回弹回来。这个模式我在好几个项目里复用,效果很稳定。
总结一下:Compose的手势动画,核心就三件事——跟踪手势、计算速度、驱动动画。拖拽用snapTo,fling用animateDecay,回弹用spring。记住这个组合,大部分手势动画场景你都能搞定。
公众号:蓝海资料掘金营,微信deep3321