I'm working on an Android app using Jetpack Compose, and I'm trying to implement a scrolling interface similar to the YouTube app's channel UI.
Here's what I want to achieve:
The top of the screen has a profile section with an image, a description, and some additional elements.
Below that, there's a TabLayout (or something similar) with tabs like "Videos", "Playlists", etc.
When scrolling, the profile section collapses, and the tabs "stick" to the top of the screen.
The content below the tabs (e.g., videos or playlists) continues to scroll naturally.
Essentially, I need to create a collapsing toolbar with tabs that stay fixed at the top during scrolling. I'm using Jetpack Compose and would like to avoid falling back to XML-based layouts or old View systems.
I have tried using LazyColumn with ScrollableTabRow, but I'm struggling to achieve the collapsing and sticky behavior for the tabs.
Can anyone provide guidance or examples of how to achieve this with Jetpack Compose?
You can use a NestedScrollConnection
to achieve this.
Please have a look at the following code:
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ScrollableScreenWithCollapsibleHeader() {
val deviceDensity = LocalDensity.current
val tabs = listOf("Videos", "Shorts", "Podcasts", "Courses", "Playlists", "Community")
var selectedTabIndex by remember { mutableStateOf(0) }
var minHeaderHeightPx by remember { mutableFloatStateOf(-1f) }
var maxHeaderHeightPx by remember { mutableFloatStateOf(-1f) }
var currentHeaderHeightPx by remember { mutableFloatStateOf(0f) }
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y
if (delta >= 0) {
return Offset.Zero // don't consume upwards scroll beforehand, so that LazyColumn consumes it first
// any remaining upwards scroll will then be consumed in onPostScroll to expand the ChannelInfo
}
val newHeaderHeightPx = currentHeaderHeightPx + delta
val previousHeaderHeightPx = currentHeaderHeightPx
currentHeaderHeightPx = newHeaderHeightPx.coerceIn(minHeaderHeightPx, maxHeaderHeightPx)
val consumedPx = currentHeaderHeightPx - previousHeaderHeightPx
return Offset(x = 0f, y = consumedPx)
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
val delta = available.y
val newHeaderHeightPx = currentHeaderHeightPx + delta
val previousHeaderHeightPx = currentHeaderHeightPx
currentHeaderHeightPx = newHeaderHeightPx.coerceIn(minHeaderHeightPx, maxHeaderHeightPx)
val consumedPx = currentHeaderHeightPx - previousHeaderHeightPx
return Offset(x = 0f, y = consumedPx)
}
}
}
Scaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(nestedScrollConnection),
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
modifier = Modifier.alpha(
if ((minHeaderHeightPx / currentHeaderHeightPx) >= 0.5f) {
((minHeaderHeightPx / currentHeaderHeightPx - 0.5f) / 0.5f)
} else 0f),
text = "Android Developers"
)
},
navigationIcon = {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
},
actions = {
IconButton(
onClick = { /* doSomething() */ }
) {
Icon(
imageVector = Icons. Filled.Cast,
contentDescription = "Chromecast"
)
}
IconButton(
onClick = { /* doSomething() */ }
) {
Icon(
imageVector = Icons. Filled.Search,
contentDescription = "Search"
)
}
IconButton(
onClick = { /* doSomething() */ }
) {
Icon(
imageVector = Icons. Filled.MoreVert,
contentDescription = "Options"
)
}
}
)
}
) { contentPadding ->
Column(
modifier = Modifier.padding(contentPadding)
) {
ChannelInfo(
modifier = if (maxHeaderHeightPx == -1f) {
Modifier.onGloballyPositioned { coordinates ->
currentHeaderHeightPx = coordinates.size.height.toFloat()
maxHeaderHeightPx = coordinates.size.height.toFloat()
minHeaderHeightPx = 48.dp.dpToPx(deviceDensity) // default Tab height according to Material3 Design Guidelines
}
} else {
Modifier
.height(
currentHeaderHeightPx
.toInt()
.pxToDp(deviceDensity)
)
.clipToBounds()
},
title = "Android Developers",
description = "Welcome to Android Developers – your go-to channel for mastering Android development! \uD83D\uDE80\n" +
"Whether you're a beginner starting your coding journey or a seasoned developer looking to sharpen your skills, our channel has something for everyone.",
tabs = tabs,
selectedTabIndex = selectedTabIndex,
onTabClick = { newIndex ->
selectedTabIndex = newIndex
}
)
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
items(50) { item ->
Text("${tabs[selectedTabIndex]} $item", modifier = Modifier.padding(16.dp))
}
}
}
}
}
fun Dp.dpToPx(density: Density) = with(density) { this@dpToPx.toPx() }
fun Int.pxToDp(density: Density) = with(density) { this@pxToDp.toDp() }
@Composable
fun ChannelInfo(
modifier: Modifier = Modifier,
title: String = "Placeholder",
description: String = "Placeholder Description",
tabs: List<String>, selectedTabIndex: Int = 0,
onTabClick: (Int) -> Unit
) {
Column(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight(unbounded = true, align = Alignment.Bottom),
) {
Row(
modifier = Modifier.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(Color.LightGray)
.padding(8.dp),
imageVector = Icons.Default.Android,
contentDescription = "Android"
)
Text(
modifier = Modifier.weight(1f),
text = title,
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
}
Text(
modifier = Modifier.padding(8.dp),
text = description
)
ScrollableTabRow(
edgePadding = 0.dp,
selectedTabIndex = selectedTabIndex,
) {
tabs.forEachIndexed { index, title ->
Tab(
text = { Text(title) },
selected = selectedTabIndex == index,
onClick = { onTabClick(index) },
)
}
}
}
}
You then would also add a HorizontalPager
if needed to be able to swipe between the pages. See my other answer on how to achieve that.
Output: