28、Compose动画之实战案例2:商品详情页的动画设计(图片缩放、轮播、加入购物车)

好,咱们继续。上一章我们聊了点赞动画,这次来个更贴近业务的——商品详情页。说实话,这个页面是电商App里最「卷」的地方,动画做得好不好,直接影响到转化率。我在好几个项目里都折腾过这个页面,踩过不少坑,今天把我觉得最值得讲的部分拎出来。

整体架构:动画的「三驾马车」

先看下我们要实现什么。一个典型的商品详情页,动画主要集中在这三个区域:

  • 顶部图片区:支持双指缩放、左右滑动轮播
  • 中间内容区:展开/收起详情描述
  • 底部操作栏:加入购物车的飞入动画

这三个场景,分别对应了 Compose 中三种不同的动画能力:transformable 手势、AnimatedPager 页面切换、以及 AnimatedVisibility + 自定义偏移。咱们一个一个来拆。

核心思路:不要试图用一个动画 API 搞定所有事。手势、页面、属性动画各司其职,组合起来才自然。

商品详情页动画架构 图片缩放 transformable 手势 graphicsLayer 缩放 边界回弹动画 animateFloatAsState 图片轮播 HorizontalPager pageTransition 动画 指示器联动 AnimatedVisibility 加入购物车 飞入动画 贝塞尔曲线路径 Animatable 驱动 offset + animateTo 三者通过状态共享联动,互不阻塞

图片缩放:别让用户「卡住」

先讲图片缩放。这个功能看起来简单,但坑不少。我最早用 Modifier.scale() 配合 detectTransformGestures 来做,结果发现一个问题——缩放完了回不去。

用户双指放大后,如果松手时图片处于一个「半放大」状态,你得让它平滑回弹到边界。嗯,这里就要用到 Animatable 了。

@Composable
fun ZoomableImage(
    imageRes: Int,
    modifier: Modifier = Modifier
) {
    var scale by remember { mutableFloatStateOf(1f) }
    val animatableScale = remember { Animatable(1f) }

    val transformState = rememberTransformableState { zoomChange, _, _ ->
        val newScale = (scale * zoomChange).coerceIn(1f, 3f)
        scale = newScale
    }

    Box(
        modifier = modifier
            .transformable(transformState)
            .graphicsLayer {
                scaleX = scale
                scaleY = scale
            }
    ) {
        Image(
            painter = painterResource(id = imageRes),
            contentDescription = null,
            modifier = Modifier.fillMaxSize(),
            contentScale = ContentScale.Fit
        )
    }

    // 松手后回弹到边界
    LaunchedEffect(scale) {
        animatableScale.snapTo(scale)
        animatableScale.animateTo(
            targetValue = scale.coerceIn(1f, 3f),
            animationSpec = spring(
                dampingRatio = Spring.DampingRatioMediumBouncy,
                stiffness = Spring.StiffnessLow
            )
        )
    }
}

个人经验spring() 的阻尼比别调太小,否则回弹时图片会「抖」好几下,用户会觉得廉价。我一般用 DampingRatioMediumBouncy,有点弹性但不夸张。

轮播:HorizontalPager 的正确用法

轮播这块,Compose 官方提供了 HorizontalPager,比 ViewPager 好用多了。但有个细节——页面切换时的「视差效果」怎么做?

其实很简单,利用 pageTransition 拿到当前页面的偏移量,然后动态调整 graphicsLayertranslationXalpha 就行。

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ImageCarousel(
    images: List<Int>,
    modifier: Modifier = Modifier
) {
    val pagerState = rememberPagerState(
        pageCount = { images.size }
    )

    HorizontalPager(
        state = pagerState,
        modifier = modifier.height(300.dp),
        pageSpacing = 8.dp
    ) { page ->
        val pageOffset = pagerState.calculateCurrentOffsetForPage(page)

        Image(
            painter = painterResource(id = images[page]),
            contentDescription = null,
            modifier = Modifier
                .fillMaxSize()
                .graphicsLayer {
                    translationX = pageOffset * size.width * 0.3f
                    alpha = (1 - abs(pageOffset)).coerceIn(0.5f, 1f)
                },
            contentScale = ContentScale.Crop
        )
    }

    // 指示器
    Row(
        modifier = Modifier
            .align(Alignment.BottomCenter)
            .padding(12.dp),
        horizontalArrangement = Arrangement.spacedBy(6.dp)
    ) {
        repeat(images.size) { index ->
            Box(
                modifier = Modifier
                    .size(if (pagerState.currentPage == index) 10.dp else 6.dp)
                    .clip(CircleShape)
                    .background(
                        if (pagerState.currentPage == index) Color.White
                        else Color.White.copy(alpha = 0.5f)
                    )
            )
        }
    }
}

避坑指南:我曾经在 pageSpacing 上吃过亏。如果间距设得太大,左右页面露出的部分会「穿帮」,因为图片是 ContentScale.Crop 裁剪的。建议间距控制在 4-12.dp 之间。

加入购物车:飞入动画的「灵魂」

最后说加入购物车。这个动画说白了就是:点击按钮后,一个小球从按钮位置飞到购物车图标位置。听起来简单,但要做到「流畅自然」,有几个关键点。

第一,你得知道起点和终点的坐标。这个用 onGloballyPositioned 来拿。第二,路径不能是直线,得有点弧度——贝塞尔曲线走起。第三,动画过程中要有一个「缩放」效果,小球从大到小再消失。

@Composable
fun FlyToCartAnimation(
    startOffset: Offset,
    endOffset: Offset,
    onFinished: () -> Unit
) {
    val animatable = remember { Animatable(0f) }

    LaunchedEffect(Unit) {
        animatable.animateTo(
            targetValue = 1f,
            animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing)
        )
        onFinished()
    }

    val progress = animatable.value
    val currentOffset = remember(startOffset, endOffset, progress) {
        // 贝塞尔曲线插值
        val controlX = (startOffset.x + endOffset.x) / 2f
        val controlY = startOffset.y - 200f // 向上拱起
        lerp(
            start = startOffset,
            end = endOffset,
            fraction = progress,
            control = Offset(controlX, controlY)
        )
    }

    val scale = (1 - progress).coerceIn(0.2f, 1f)

    Box(
        modifier = Modifier
            .offset { IntOffset(currentOffset.x.roundToInt(), currentOffset.y.roundToInt()) }
            .graphicsLayer {
                scaleX = scale
                scaleY = scale
                alpha = 1 - progress * 0.5f
            }
    ) {
        // 这里放一个小圆点或商品缩略图
        Box(
            modifier = Modifier
                .size(24.dp)
                .clip(CircleShape)
                .background(Color.Red)
        )
    }
}

// 辅助函数:二次贝塞尔插值
private fun lerp(start: Offset, end: Offset, fraction: Float, control: Offset): Offset {
    val t = fraction
    val u = 1 - t
    return Offset(
        x = u * u * start.x + 2 * u * t * control.x + t * t * end.x,
        y = u * u * start.y + 2 * u * t * control.y + t * t * end.y
    )
}

我的习惯:飞入动画的时长控制在 500-700ms 之间,太短了看不清,太长了用户会等得不耐烦。另外,别忘了在动画结束后把小球「移除」,否则会一直留在屏幕上。

组合起来:状态共享是关键

这三个动画不是孤立的。比如,当用户正在缩放图片时,轮播应该暂停自动播放。再比如,加入购物车成功后,轮播的指示器可以闪一下作为反馈。

怎么做?用 remember 共享一个状态即可:

var isZooming by remember { mutableStateOf(false) }

// 缩放时暂停轮播
LaunchedEffect(isZooming) {
    if (!isZooming) {
        while (true) {
            delay(3000)
            // 自动翻页
        }
    }
}

说白了,Compose 的动画优势就在这里——所有东西都是状态驱动的,你不需要像 View 系统那样写一堆监听器。状态一变,动画自然跟着走。

性能优化:别让动画卡成 PPT

最后聊点实在的。商品详情页的图片通常很大,如果直接对 Image 做缩放动画,很容易掉帧。我的建议是:

  • graphicsLayer 代替 Modifier.scale(),前者走 GPU 合成,不触发重组
  • 轮播图片用 rememberAsyncImagePainter 或 Coil 的 AsyncImage,避免在主线程解码
  • 飞入动画的小球用 Canvas 绘制,不要用 Image 组件,减少布局开销

一句话总结:商品详情页的动画设计,本质是「手势 + 页面 + 属性」三种动画的有机组合。用 Compose 做这件事,比 View 系统至少省一半代码,而且更容易维护。

好,这一章就到这。代码都在上面了,你可以直接拿去跑。下一章我们聊点更进阶的——自定义动画规格,让动画效果完全掌控在你手里。


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