28、Compose动画之实战案例2:商品详情页的动画设计(图片缩放、轮播、加入购物车)
好,咱们继续。上一章我们聊了点赞动画,这次来个更贴近业务的——商品详情页。说实话,这个页面是电商App里最「卷」的地方,动画做得好不好,直接影响到转化率。我在好几个项目里都折腾过这个页面,踩过不少坑,今天把我觉得最值得讲的部分拎出来。
整体架构:动画的「三驾马车」
先看下我们要实现什么。一个典型的商品详情页,动画主要集中在这三个区域:
- 顶部图片区:支持双指缩放、左右滑动轮播
- 中间内容区:展开/收起详情描述
- 底部操作栏:加入购物车的飞入动画
这三个场景,分别对应了 Compose 中三种不同的动画能力:transformable 手势、AnimatedPager 页面切换、以及 AnimatedVisibility + 自定义偏移。咱们一个一个来拆。
核心思路:不要试图用一个动画 API 搞定所有事。手势、页面、属性动画各司其职,组合起来才自然。
图片缩放:别让用户「卡住」
先讲图片缩放。这个功能看起来简单,但坑不少。我最早用 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 拿到当前页面的偏移量,然后动态调整 graphicsLayer 的 translationX 和 alpha 就行。
@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