Build a profile screen with coordinated animations: a collapsing header with avatar scale/fade, content fade-in on scroll, and a tab bar that slides in when the header collapses.
Goal
A scrollable screen where:
- Hero header with avatar + name collapses as user scrolls up
- Avatar scales from 80dp to 40dp and moves to the toolbar
- Name fades out in the hero; appears in the toolbar title
- Content list items fade and slide in staggered
Step 1: Scroll State and Derived Values
@Composable
fun ProfileScreen(profile: Profile) {
val scrollState = rememberScrollState()
val density = LocalDensity.current
// Header is 240dp tall — compute collapse progress
val headerHeightPx = with(density) { 240.dp.toPx() }
val collapseProgress by remember {
derivedStateOf { (scrollState.value / headerHeightPx).coerceIn(0f, 1f) }
}
Box(modifier = Modifier.fillMaxSize()) {
// Main content
Column(modifier = Modifier.verticalScroll(scrollState)) {
ProfileHeader(collapseProgress = collapseProgress, profile = profile)
ProfileContent(profile = profile)
}
// Fixed toolbar that appears as header collapses
CollapsedToolbar(
collapseProgress = collapseProgress,
title = profile.name
)
}
}
Step 2: Animated Header
@Composable
fun ProfileHeader(collapseProgress: Float, profile: Profile) {
// Header shrinks from 240dp to 56dp (toolbar height)
val headerHeight = lerp(240.dp, 56.dp, collapseProgress)
// Avatar shrinks from 80dp to 40dp
val avatarSize = lerp(80.dp, 40.dp, collapseProgress)
// Name fades out as header collapses
val nameAlpha = 1f - collapseProgress
// Avatar moves right from center to toolbar position
val avatarOffsetX = lerp(0.dp, (-100).dp, collapseProgress)
Box(
modifier = Modifier
.fillMaxWidth()
.height(headerHeight)
.background(MaterialTheme.colorScheme.primaryContainer)
) {
// Avatar
AsyncImage(
model = profile.avatarUrl,
contentDescription = null,
modifier = Modifier
.size(avatarSize)
.align(Alignment.Center)
.offset(x = avatarOffsetX)
.clip(CircleShape)
)
// Name — fades out as we collapse
Text(
text = profile.name,
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 16.dp)
.alpha(nameAlpha) // direct alpha from scroll position
)
}
}
fun lerp(start: Dp, end: Dp, fraction: Float): Dp =
start + (end - start) * fraction
Step 3: Collapsed Toolbar
@Composable
fun CollapsedToolbar(collapseProgress: Float, title: String) {
// Toolbar appears as header collapses
val toolbarAlpha = collapseProgress
TopAppBar(
title = {
Text(
text = title,
modifier = Modifier.alpha(toolbarAlpha)
)
},
modifier = Modifier.alpha(toolbarAlpha),
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
.copy(alpha = toolbarAlpha)
),
navigationIcon = {
IconButton(onClick = { /* back */ }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
Step 4: Staggered Content Animation
@Composable
fun ProfileContent(profile: Profile) {
Column(modifier = Modifier.padding(16.dp)) {
profile.posts.forEachIndexed { index, post ->
// Each item appears with a staggered delay
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(index * 80L) // 80ms stagger between items
visible = true
}
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(300)) + slideInVertically(
initialOffsetY = { it / 3 },
animationSpec = tween(300)
)
) {
PostCard(post = post, modifier = Modifier.padding(bottom = 8.dp))
}
}
}
}
Step 5: Tab Bar with Animated Indicator
@Composable
fun AnimatedTabRow(
selectedTab: Int,
tabs: List<String>,
onTabSelected: (Int) -> Unit
) {
val indicator = @Composable { tabPositions: List<TabPosition> ->
// Animate indicator position with spring
val currentTabWidth by animateDpAsState(
targetValue = tabPositions[selectedTab].width,
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
label = "tab_width"
)
val indicatorOffset by animateDpAsState(
targetValue = tabPositions[selectedTab].left,
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
label = "tab_offset"
)
Box(
modifier = Modifier
.wrapContentSize(Alignment.BottomStart)
.offset(x = indicatorOffset)
.width(currentTabWidth)
.height(3.dp)
.background(MaterialTheme.colorScheme.primary, RoundedCornerShape(topStart = 3.dp, topEnd = 3.dp))
)
}
TabRow(
selectedTabIndex = selectedTab,
indicator = indicator
) {
tabs.forEachIndexed { index, tab ->
Tab(
selected = selectedTab == index,
onClick = { onTabSelected(index) },
text = { Text(tab) }
)
}
}
}
Verification Checklist
[ ] Scroll down: header collapses, avatar shrinks and shifts right
[ ] Toolbar title fades in as header collapses
[ ] Content items appear with staggered fade/slide on initial load
[ ] Tab indicator slides smoothly between tabs (spring animation)
[ ] No recomposition storms: use Compose layout inspector to verify
[ ] Slow animation (developer options: 5x animator speed) — animations look intentional
[ ] Fling gesture — scroll continues smoothly after lifting finger