You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何为带水平滚动的Compose Row实现可见性动画?

Jetpack Compose 实现水平列表的顺序折叠/展开动画

问题描述

我有一个带.horizontalScroll(scrollState)的Row,需要实现列表项的显示与隐藏动画:

  • 隐藏时:所有元素依次折叠到第一个元素的位置,最后整体平滑消失
  • 显示时:从第一个元素开始,依次展开为完整的水平列表

我尝试用AnimatedVisibility实现,但效果不符合预期,我的代码如下:

val delay = index * 100

AnimatedVisibility(
    visible = visible,
    enter = slideInHorizontally(
        initialOffsetX = { fullWidth -> -fullWidth / 2 }
    ) + fadeIn(
        animationSpec = tween(durationMillis = 300, delayMillis = delay)
    ) + scaleIn(
        initialScale = 0.8f,
        animationSpec = tween(durationMillis = 300, delayMillis = delay)
    ),
    exit = fadeOut(
        animationSpec = tween(durationMillis = 200)
    ) + shrinkHorizontally(
        targetWidth = { it / 2 } 
    )
) {
    // ... 列表项内容
}

期望实现的效果类似以下CSS动画:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Sequential Collapse and Expand</title>
    <style>
        body {
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            margin: 0;
            background-color: #f0f0f0;
        }

        .list-container {
            display: flex;
            flex-direction: row;
            position: relative;
            gap: 10px;
        }

        .list-item {
            background-color: #3498db;
            color: white;
            font-size: 14px;
            font-family: Arial, sans-serif;
            text-align: center;
            line-height: 40px;
            width: 100px;
            height: 40px;
            border-radius: 5px;
            transform-origin: left center;
            transition: transform 0.5s ease-in-out, opacity 0.5s ease-in-out;
        }

        /* 默认状态(展开) */
        .list-container:not(:hover) .list-item {
            transform: translateX(0) scaleX(1);
            opacity: 1;
        }

        /* hover状态(折叠) */
        .list-container:hover .list-item {
            opacity: 0;
        }

        /* 顺序折叠动画 */
        .list-container:hover .list-item:nth-child(5) {
            transform: translateX(-400px) scaleX(0);
            transition-delay: 0s;
        }

        .list-container:hover .list-item:nth-child(4) {
            transform: translateX(-300px) scaleX(0);
            transition-delay: 0.1s;
        }

        .list-container:hover .list-item:nth-child(3) {
            transform: translateX(-200px) scaleX(0);
            transition-delay: 0.2s;
        }

        .list-container:hover .list-item:nth-child(2) {
            transform: translateX(-100px) scaleX(0);
            transition-delay: 0.3s;
        }

        .list-container:hover .list-item:nth-child(1) {
            transform: translateX(0) scaleX(1);
            opacity: 0;
            transition-delay: 0.4s;
        }

        /* 顺序展开动画 */
        .list-container:not(:hover) .list-item:nth-child(1) {
            transform: translateX(0) scaleX(1);
            transition-delay: 0.4s;
        }

        .list-container:not(:hover) .list-item:nth-child(2) {
            transform: translateX(0) scaleX(1);
            transition-delay: 0.3s;
        }

        .list-container:not(:hover) .list-item:nth-child(3) {
            transform: translateX(0) scaleX(1);
            transition-delay: 0.2s;
        }

        .list-container:not(:hover) .list-item:nth-child(4) {
            transform: translateX(0) scaleX(1);
            transition-delay: 0.1s;
        }

        .list-container:not(:hover) .list-item:nth-child(5) {
            transform: translateX(0) scaleX(1);
            transition-delay: 0s;
        }
    </style>
</head>
<body>
    <div class="list-container">
        <div class="list-item">Item 1</div>
        <div class="list-item">Item 2</div>
        <div class="list-item">Item 3</div>
        <div class="list-item">Item 4</div>
        <div class="list-item">Item 5</div>
    </div>
</body>
</html>

解决方案

要实现这种顺序折叠/展开的效果,不能单纯依赖AnimatedVisibility,需要结合Animatable控制每个列表项的缩放、位移和透明度,同时处理反向延迟逻辑(折叠从最后一个元素开始,展开从第一个元素开始)。

完整实现代码

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp

@Composable
fun SequentialCollapseList() {
    val scrollState = rememberScrollState()
    var isExpanded by remember { mutableStateOf(true) }
    val itemWidth = 100.dp
    val itemGap = 10.dp
    val items = listOf("Item 1", "Item 2", "Item 3", "Item 4", "Item 5")
    val animationDuration = 500
    val delayStep = 100

    // 为每个元素创建独立的动画状态
    val animationStates = remember(items.size) {
        List(items.size) { index ->
            Animatable(if (isExpanded) 1f else 0f).apply {
                if (!isExpanded) snapTo(0f)
            }
        }
    }

    // 状态变化时启动对应动画
    LaunchedEffect(isExpanded) {
        items.forEachIndexed { index, _ ->
            // 折叠时反向延迟,展开时正向延迟
            val delay = if (isExpanded) {
                index * delayStep
            } else {
                (items.size - 1 - index) * delayStep
            }
            animationStates[index].animateTo(
                targetValue = if (isExpanded) 1f else 0f,
                animationSpec = tween(
                    durationMillis = animationDuration,
                    delayMillis = delay
                )
            )
        }
    }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                detectTapGestures(onTap = { isExpanded = !isExpanded })
            },
        contentAlignment = Alignment.Center
    ) {
        Row(
            modifier = Modifier.horizontalScroll(scrollState),
            horizontalArrangement = Arrangement.spacedBy(itemGap)
        ) {
            items.forEachIndexed { index, text ->
                val progress = animationStates[index].value
                // 计算当前元素需要移动的总距离
                val totalOffset = (items.size - 1 - index) * (itemWidth + itemGap).value
                val offsetX = (1 - progress) * totalOffset * (-1)
                // 第一个元素折叠时保持缩放为1,最后消失
                val scaleX = if (index == 0 && !isExpanded) 1f else progress

                Box(
                    modifier = Modifier
                        .width(itemWidth)
                        .height(40.dp)
                        .background(Color(0xFF3498DB))
                        .offset(x = offsetX.dp)
                        .scale(scaleX = scaleX, scaleY = 1f),
                    contentAlignment = Alignment.Center
                ) {
                    Text(
                        text = text,
                        color = Color.White,
                        modifier = Modifier.alpha(progress)
                    )
                }
            }
        }
    }
}

关键实现细节

  1. 反向延迟逻辑:
    • 折叠时:最后一个元素先执行动画,延迟依次递减
    • 展开时:第一个元素先执行动画,延迟依次递增
  2. 精准位移计算:每个元素的位移距离等于它到第一个元素的总宽度(包含自身宽度和间隙),确保折叠时完全贴合第一个元素
  3. 第一个元素特殊处理:折叠状态下保持缩放为1,最后再通过透明度消失,和CSS示例效果一致
  4. 独立动画控制:用Animatable为每个元素单独管理动画进度,实现更细腻的顺序动画效果

内容的提问来源于stack exchange,提问作者testivanivan

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.15 16:55:12