What You Will Build

A profile page with a cover photo that shrinks as you scroll, an avatar that overlaps the cover edge, follower counts, and a tweet feed below. This replicates the Twitter/X profile scrolling behavior where the header collapses dynamically based on scroll position.

Why This Pattern Matters

Collapsible profile headers maximize screen space for content while keeping the identity context visible. Learning to calculate dynamic heights from scroll offset and overlap elements with negative offsets is essential for social media apps, portfolio pages, and user profile screens.

Key Concepts

  • Dynamic height calculation from LazyListState scroll offset.
  • coerceAtLeast to prevent negative heights.
  • Negative offset to overlap the avatar onto the cover photo edge.

The Twitter Profile Screen

@Composable
fun TwitterProfileScrollingScreen() {
    val listState = rememberLazyListState()
    val scrollOffset = listState.firstVisibleItemScrollOffset.toFloat()
    val firstIdx = listState.firstVisibleItemIndex
    val coverHeight = if (firstIdx == 0)
        (200f - scrollOffset * 0.4f).coerceAtLeast(0f)
    else 0f

    LazyColumn(
        state = listState,
        modifier = Modifier.fillMaxSize()
    ) {
        // Cover photo
        item {
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .height(coverHeight.dp)
                    .background(
                        Brush.verticalGradient(
                            listOf(Color(0xFF1DA1F2), Color(0xFF0D47A1))
                        )
                    )
            )
        }
        // Profile info section
        item {
            Column(modifier = Modifier.padding(16.dp)) {
                Box(
                    modifier = Modifier
                        .size(80.dp)
                        .offset(y = (-40).dp)
                        .clip(CircleShape)
                        .background(Color.White)
                        .padding(3.dp)
                        .clip(CircleShape)
                        .background(Color(0xFF1DA1F2)),
                    contentAlignment = Alignment.Center
                ) {
                    Text("JD", fontSize = 28.sp,
                        fontWeight = FontWeight.Bold,
                        color = Color.White)
                }
                Text("John Doe",
                    fontSize = 22.sp, fontWeight = FontWeight.Bold)
                Text("@johndoe",
                    fontSize = 14.sp, color = Color.Gray)
                Spacer(modifier = Modifier.height(8.dp))
                Row(
                    horizontalArrangement = Arrangement.spacedBy(16.dp)
                ) {
                    Text("142 Following", fontSize = 14.sp)
                    Text("2.4K Followers", fontSize = 14.sp)
                }
            }
        }
        // Tweet feed
        items(20) { idx ->
            Card(
                modifier = Modifier.fillMaxWidth()
                    .padding(horizontal = 16.dp, vertical = 4.dp)
            ) {
                Text(
                    "Tweet ${'$'}{idx + 1}: Sample tweet content for the profile feed.",
                    modifier = Modifier.padding(16.dp),
                    fontSize = 14.sp
                )
            }
        }
    }
}

Tips and Pitfalls

  • 0.4f collapse rate: The cover shrinks at 40% of scroll speed, giving a smooth collapse rather than an abrupt disappearance.
  • coerceAtLeast(0f): Prevents the height from going negative, which would cause a crash.
  • White border avatar: The pattern of .background(Color.White).padding(3.dp).clip(CircleShape).background(...) creates a white ring border around the avatar.
  • Negative offset trick: offset(y = (-40).dp) pulls the avatar upward to overlap the cover photo edge, a signature social media profile pattern.

Minimum SDK: API 21+ with Compose BOM

Get New Tutorials by Email

No spam. Just clear, practical breakdowns you can apply right away.

Enjoy this tutorial?

Get new practical tech tutorials in your inbox.