What You Will Build
A WhatsApp-inspired chat list where tapping a contact's avatar triggers a hero animation that scales the profile picture to full screen with a dark overlay. This is the profile preview pattern used in messaging apps where users can quickly view a contact's photo without navigating away.
Why This Pattern Matters
Hero animations create visual continuity between list and detail views. The combination of AnimatedVisibility with scaleIn/scaleOut and fadeIn/fadeOut transitions is a fundamental animation pattern that applies to image galleries, product previews, and any tap-to-expand interaction.
Key Concepts
- AnimatedVisibility with combined transitions for scale + fade effects.
- mutableStateOf with nullable type for tracking selected items.
- Overlay pattern with semi-transparent background covering the entire screen.
The WhatsApp Hero Screen
@Composable
fun WhatsAppHeroScreen() {
var selectedProfile by remember { mutableStateOf<Int?>(null) }
val contacts = listOf("Alice", "Bob", "Charlie", "Diana", "Eve")
val colors = listOf(
Color(0xFF25D366), Color(0xFF128C7E),
Color(0xFF075E54), Color(0xFF34B7F1), Color(0xFFDCF8C6)
)
Box(Modifier.fillMaxSize()) {
// Chat list
Column(Modifier.fillMaxSize().padding(16.dp)) {
Text("Chats", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(16.dp))
contacts.forEachIndexed { i, name ->
Row(
Modifier.fillMaxWidth()
.clickable { selectedProfile = i }
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
Modifier.size(48.dp).clip(CircleShape)
.background(colors[i]),
contentAlignment = Alignment.Center
) {
Icon(Icons.Filled.Person, null,
tint = Color.White)
}
Spacer(Modifier.width(16.dp))
Column {
Text(name, style = MaterialTheme.typography.titleMedium)
Text("Last message...",
color = Color.Gray,
style = MaterialTheme.typography.bodySmall)
}
}
if (i < contacts.lastIndex) HorizontalDivider()
}
}
// Hero overlay
AnimatedVisibility(
selectedProfile != null,
enter = scaleIn(tween(300)) + fadeIn(),
exit = scaleOut(tween(300)) + fadeOut()
) {
selectedProfile?.let { idx ->
Box(
Modifier.fillMaxSize()
.background(Color.Black.copy(alpha = 0.9f)),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
Modifier.size(200.dp).clip(CircleShape)
.background(colors[idx]),
contentAlignment = Alignment.Center
) {
Icon(Icons.Filled.Person, null,
tint = Color.White,
modifier = Modifier.size(80.dp))
}
Spacer(Modifier.height(24.dp))
Text(contacts[idx],
color = Color.White,
style = MaterialTheme.typography.headlineMedium)
}
IconButton(
onClick = { selectedProfile = null },
modifier = Modifier.align(Alignment.TopEnd)
.padding(16.dp)
) {
Icon(Icons.Filled.Close, null,
tint = Color.White)
}
}
}
}
}
}
Tips and Pitfalls
- Nullable state pattern: Using
mutableStateOf<Int?>(null)elegantly represents "nothing selected" without extra boolean flags. - Combined transitions:
scaleIn() + fadeIn()runs both animations simultaneously for a polished entrance. - Overlay z-order: Place AnimatedVisibility after the list content in the Box so it renders on top.
- Back button handling: Add
BackHandler(selectedProfile != null) { selectedProfile = null }so the hardware back button dismisses the overlay.
Minimum SDK: API 21+ with Compose BOM