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平滑回到原点。这种「手势驱动+动画收尾」的模式,在项目中非常实用。

知识体系总览

下面这张图梳理了本章的核心内容,方便你快速回顾:

Compose手势动画知识体系 手势 + 动画 拖拽动画 缩放动画 旋转动画 组合手势 detectDragGestures offset + consume() Animatable 平滑回位 detectTransformGestures graphicsLayer 缩放 coerceIn 限制范围 rotationZ 属性 graphicsLayer 旋转 参数顺序注意 单回调处理所有 状态分离管理 手势自动识别 核心:pointerInput + 状态驱动 + 动画收尾

性能优化建议

手势动画对性能要求很高,尤其是组合手势。我总结了几条经验:

  • graphicsLayer做变换:缩放、旋转、透明度这些,放在graphicsLayer里只触发绘制,不触发重组
  • 避免在手势回调里做耗时操作:手势回调是高频触发的,任何耗时操作都会导致掉帧
  • 合理使用remember:手势相关的状态尽量用mutableFloatStateOfmutableIntStateOf,避免装箱开销
  • 考虑使用animate*AsState:对于简单的动画收尾,用animateFloatAsState比手动启动协程更简洁
我的经验:在复杂手势场景下,建议把手势逻辑抽到单独的类里管理。比如写一个GestureController,统一处理拖拽、缩放、旋转的状态更新。这样Composable函数里只保留UI描述,逻辑更清晰。

嗯,手势与动画的结合就讲到这里。核心就是记住:pointerInput是入口,detectDragGesturesdetectTransformGestures是主力,graphicsLayeroffset是输出。把这几个点串起来,大部分手势动画场景都能搞定。

公众号:蓝海资料掘金营,微信deep3321