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

如何在Jetpack Compose中实现类似YouTube频道UI的折叠滚动效果?

实现Jetpack Compose版YouTube频道折叠滚动UI

核心思路是利用NestedScrollConnection监听滚动事件,结合动画状态实现资料区域的平滑折叠,同时让标签栏固定在顶部。以下是完整实现步骤和代码:

关键组件与状态定义

  • 使用NestedScrollConnection拦截滚动事件,计算折叠进度
  • 用animateDpAsState实现资料区域高度的平滑过渡
  • 用ScrollableTabRow实现标签切换,配合LazyColumn展示标签内容

完整代码示例

import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.ConstraintSet

@Composable
fun YoutubeChannelScreen() {
    val tabs = listOf("Videos", "Playlists", "Channels", "About")
    var selectedTabIndex by remember { mutableStateOf(0) }

    // 定义资料区域展开/折叠高度
    val expandedProfileHeight = 220.dp
    val collapsedProfileHeight = 60.dp
    val density = LocalDensity.current

    // 滚动偏移量,用于计算折叠进度
    var scrollOffset by remember { mutableStateOf(0f) }

    // 动画化的资料区域高度
    val profileHeight by animateDpAsState(
        targetValue = if (scrollOffset >= with(density) { expandedProfileHeight.toPx() }) {
            collapsedProfileHeight
        } else {
            expandedProfileHeight - with(density) { scrollOffset.toDp() }
        }
    )

    // 嵌套滚动连接,监听滚动事件
    val nestedScrollConnection = remember {
        object : NestedScrollConnection {
            override fun onPreScroll(available: androidx.compose.ui.geometry.Offset, source: NestedScrollSource): androidx.compose.ui.geometry.Offset {
                val delta = available.y
                scrollOffset = (scrollOffset - delta).coerceIn(0f, with(density) { expandedProfileHeight.toPx() })
                return androidx.compose.ui.geometry.Offset.Zero
            }
        }
    }

    Box(modifier = Modifier.fillMaxSize()) {
        // 主内容区域:标签内容
        LazyColumn(
            modifier = Modifier
                .fillMaxSize()
                .nestedScroll(nestedScrollConnection),
            contentPadding = PaddingValues(top = profileHeight + 56.dp) // 预留资料区域和TabRow的高度
        ) {
            items(20) { index ->
                ListItem(
                    modifier = Modifier.fillMaxWidth(),
                    text = { Text("${tabs[selectedTabIndex]} Item $index") },
                    secondaryText = { Text("Subtitle for item $index") },
                    icon = {
                        Box(
                            modifier = Modifier
                                .size(60.dp)
                                .background(Color.Gray)
                        )
                    }
                )
            }
        }

        // 顶部固定区域:资料栏 + TabRow
        Column(modifier = Modifier.fillMaxWidth()) {
            // 折叠的资料区域
            ConstraintLayout(
                modifier = Modifier
                    .fillMaxWidth()
                    .height(profileHeight)
                    .background(MaterialTheme.colors.surface),
                constraintSet = getProfileConstraintSet(profileHeight == collapsedProfileHeight)
            ) {
                Image(
                    modifier = Modifier
                        .layoutId("avatar")
                        .size(50.dp)
                        .clip(CircleShape)
                        .background(Color.Blue),
                    contentDescription = "Channel Avatar"
                )
                Column(modifier = Modifier.layoutId("info")) {
                    Text(text = "My Awesome Channel", style = MaterialTheme.typography.h6)
                    if (profileHeight != collapsedProfileHeight) {
                        Text(text = "1.2M subscribers • 250 videos", style = MaterialTheme.typography.body2)
                        Spacer(modifier = Modifier.height(8.dp))
                        Text(text = "Welcome to my channel! Here you'll find awesome tech content.", style = MaterialTheme.typography.body2)
                    }
                }
                Button(
                    modifier = Modifier.layoutId("subscribeBtn"),
                    onClick = {}
                ) {
                    Text("Subscribe")
                }
            }

            // 固定的标签栏
            ScrollableTabRow(
                selectedTabIndex = selectedTabIndex,
                edgePadding = 8.dp,
                modifier = Modifier.fillMaxWidth()
            ) {
                tabs.forEachIndexed { index, title ->
                    Tab(
                        selected = selectedTabIndex == index,
                        onClick = { selectedTabIndex = index },
                        text = { Text(title) }
                    )
                }
            }
        }
    }
}

// 根据折叠状态返回不同的约束布局规则
private fun getProfileConstraintSet(isCollapsed: Boolean): ConstraintSet {
    return if (isCollapsed) {
        ConstraintSet {
            val avatar = createRefFor("avatar")
            val info = createRefFor("info")
            val subscribeBtn = createRefFor("subscribeBtn")

            constrain(avatar) {
                start.linkTo(parent.start, margin = 16.dp)
                top.linkTo(parent.top, margin = 8.dp)
                bottom.linkTo(parent.bottom, margin = 8.dp)
            }

            constrain(info) {
                start.linkTo(avatar.end, margin = 12.dp)
                top.linkTo(parent.top, margin = 8.dp)
                bottom.linkTo(parent.bottom, margin = 8.dp)
                end.linkTo(subscribeBtn.start, margin = 12.dp)
                width = Dimension.fillToConstraints
            }

            constrain(subscribeBtn) {
                end.linkTo(parent.end, margin = 16.dp)
                top.linkTo(parent.top, margin = 8.dp)
                bottom.linkTo(parent.bottom, margin = 8.dp)
            }
        }
    } else {
        ConstraintSet {
            val avatar = createRefFor("avatar")
            val info = createRefFor("info")
            val subscribeBtn = createRefFor("subscribeBtn")

            constrain(avatar) {
                start.linkTo(parent.start, margin = 16.dp)
                top.linkTo(parent.top, margin = 16.dp)
            }

            constrain(info) {
                start.linkTo(avatar.end, margin = 12.dp)
                top.linkTo(parent.top, margin = 16.dp)
                end.linkTo(parent.end, margin = 16.dp)
            }

            constrain(subscribeBtn) {
                end.linkTo(parent.end, margin = 16.dp)
                top.linkTo(info.bottom, margin = 16.dp)
            }
        }
    }
}

@Preview(showBackground = true)
@Composable
fun YoutubeChannelScreenPreview() {
    MaterialTheme {
        YoutubeChannelScreen()
    }
}

代码说明

  1. 折叠逻辑:通过NestedScrollConnection拦截滚动的垂直偏移量,计算scrollOffset,进而动态调整资料区域的高度,从expandedProfileHeight过渡到collapsedProfileHeight。
  2. 平滑动画:使用animateDpAsState让资料区域的高度变化带有自然的过渡动画,避免生硬的折叠效果。
  3. 约束布局适配:根据折叠状态切换ConstraintSet,调整头像、描述、订阅按钮的位置,适配展开/折叠两种状态的布局。
  4. 标签栏固定:将资料区域和标签栏放在独立的Column中,悬浮在主内容上方,主内容通过contentPadding预留顶部空间,确保滚动时不会被遮挡。
  5. 纯Compose实现:全程使用Compose原生组件,未依赖任何XML或旧View系统组件。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.15 04:27:31