如何在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() } }
代码说明
- 折叠逻辑:通过
NestedScrollConnection拦截滚动的垂直偏移量,计算scrollOffset,进而动态调整资料区域的高度,从expandedProfileHeight过渡到collapsedProfileHeight。 - 平滑动画:使用
animateDpAsState让资料区域的高度变化带有自然的过渡动画,避免生硬的折叠效果。 - 约束布局适配:根据折叠状态切换
ConstraintSet,调整头像、描述、订阅按钮的位置,适配展开/折叠两种状态的布局。 - 标签栏固定:将资料区域和标签栏放在独立的
Column中,悬浮在主内容上方,主内容通过contentPadding预留顶部空间,确保滚动时不会被遮挡。 - 纯Compose实现:全程使用Compose原生组件,未依赖任何XML或旧View系统组件。
内容的提问来源于stack exchange,提问作者gadmtb
相关产品推荐
相关产品推荐

